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> packs = new ArrayList<>(); + if (arr != null) { + for (int i = 0; i < arr.length(); i++) { + org.json.JSONObject pack = arr.getJSONObject(i); + Map packInfo = new java.util.HashMap<>(); + packInfo.put("name", pack.optString("name", "")); + packInfo.put("displayName", pack.optString("displayName", pack.optString("name", ""))); + packInfo.put("version", pack.optString("version", "")); + packInfo.put("mcVersion", pack.optString("minecraft_version", "")); + packInfo.put("loader", pack.optString("loader_type", "vanilla")); + packInfo.put("description", pack.optString("description", "")); + packs.add(packInfo); + } + } + LauncherLogger.info("getZernMCPacks: loaded " + packs.size() + " packs"); + return ApiResponse.success(packs); + } catch (Exception e) { + LauncherLogger.error("getZernMCPacks failed: " + e.getMessage()); + return ApiResponse.error("Failed to load packs: " + e.getMessage()); + } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java new file mode 100644 index 0000000..ee7647c --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java @@ -0,0 +1,178 @@ +package me.sashegdev.zernmc.launcher.api.auth; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import me.sashegdev.zernmc.launcher.api.ApiResponse; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.IOException; + +public class AuthService { + + public ApiResponse register(String username, String password) { + try { + JsonObject json = new JsonObject(); + json.addProperty("username", username); + json.addProperty("password", password); + String response = post("/auth/register", json.toString()); + + // If registration succeeds, auto-login + AuthManager.AuthResult result = AuthManager.login(username, password); + if (result.success) { + LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken()); + return ApiResponse.success(loginResult); + } + return ApiResponse.error(result.error != null ? result.error : "Registration failed"); + } catch (Exception e) { + String msg = e.getMessage(); + if (msg != null) { + java.util.regex.Matcher m = java.util.regex.Pattern.compile("HTTP (\\d{3})").matcher(msg); + if (m.find()) { + int code = Integer.parseInt(m.group(1)); + String body = msg.substring(m.end()).trim(); + return ApiResponse.error(AuthManager.friendlyHttpError(code, body)); + } + } + return ApiResponse.error(AuthManager.friendlyConnectionError(e)); + } + } + + public ApiResponse login(String username, String password) { + try { + AuthManager.AuthResult result = AuthManager.login(username, password); + if (result.success) { + LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken()); + return ApiResponse.success(loginResult); + } + return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль"); + } catch (Exception e) { + return ApiResponse.error(AuthManager.friendlyConnectionError(e)); + } + } + + public ApiResponse logout() { + try { + AuthManager.logout(); + return ApiResponse.success(true); + } catch (Exception e) { + return ApiResponse.error("Logout error: " + e.getMessage()); + } + } + + public ApiResponse checkSession() { + try { + boolean restored = AuthManager.loadSavedSession(); + if (restored) { + SessionInfo info = new SessionInfo( + AuthManager.getUsername(), + AuthManager.getAccessToken(), + AuthManager.hasActivePass(), + AuthManager.getRole(), + AuthManager.getRoleName() + ); + return ApiResponse.success(info); + } + return ApiResponse.error("Session not found"); + } catch (Exception e) { + return ApiResponse.error("Session check error: " + e.getMessage()); + } + } + + public ApiResponse activatePass(String passCode) { + try { + JsonObject json = new JsonObject(); + json.addProperty("pass_code", passCode); + String response = post("/auth/pass/activate", json.toString()); + AuthManager.refreshUserInfo(); + return ApiResponse.success(true); + } catch (Exception e) { + return ApiResponse.error("Pass activation error: " + e.getMessage()); + } + } + + private String post(String endpoint, String jsonBody) throws Exception { + String fullUrl = ZHttpClient.getBaseUrl() + endpoint; + java.net.URL url = new java.net.URL(fullUrl); + java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection(); + + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); + + if (AuthManager.getAccessToken() != null && !AuthManager.getAccessToken().equals("0")) { + conn.setRequestProperty("Authorization", "Bearer " + AuthManager.getAccessToken()); + } + + conn.setDoOutput(true); + + try (var os = conn.getOutputStream()) { + byte[] input = jsonBody.getBytes(java.nio.charset.StandardCharsets.UTF_8); + os.write(input); + } + + int statusCode = conn.getResponseCode(); + var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream(); + + String responseBody; + try (var scanner = new java.util.Scanner(is, java.nio.charset.StandardCharsets.UTF_8.name())) { + responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; + } + + conn.disconnect(); + + if (statusCode != 200) { + throw new IOException("HTTP " + statusCode + ": " + responseBody); + } + + return responseBody; + } + + public boolean isLoggedIn() { + return AuthManager.isLoggedIn(); + } + + public String getCurrentUsername() { + return AuthManager.getUsername(); + } + + public String getCurrentToken() { + return AuthManager.getAccessToken(); + } + + public static class LoginResult { + private String username; + private String token; + + public LoginResult(String username, String token) { + this.username = username; + this.token = token; + } + + public String getUsername() { return username; } + public String getToken() { return token; } + } + + public static class SessionInfo { + private String username; + private String token; + private boolean passActive; + private int role; + private String roleName; + + public SessionInfo(String username, String token, boolean passActive, int role, String roleName) { + this.username = username; + this.token = token; + this.passActive = passActive; + this.role = role; + this.roleName = roleName; + } + + public String getUsername() { return username; } + public String getToken() { return token; } + public boolean isPassActive() { return passActive; } + public int getRole() { return role; } + public String getRoleName() { return roleName; } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/instance/InstanceService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/instance/InstanceService.java new file mode 100644 index 0000000..f2d4b12 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/instance/InstanceService.java @@ -0,0 +1,122 @@ +package me.sashegdev.zernmc.launcher.api.instance; + +import me.sashegdev.zernmc.launcher.api.ApiResponse; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +public class InstanceService { + + public ApiResponse> getAllInstances() { + try { + List instances = InstanceManager.getAllInstances(); + List infoList = instances.stream() + .map(this::toInstanceInfo) + .collect(Collectors.toList()); + return ApiResponse.success(infoList); + } catch (IOException e) { + return ApiResponse.error("Error getting instances list: " + e.getMessage()); + } + } + + public ApiResponse getInstance(String name) { + try { + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + return ApiResponse.error("Pack not found: " + name); + } + return ApiResponse.success(toInstanceInfo(instance)); + } catch (Exception e) { + return ApiResponse.error("Error getting pack: " + e.getMessage()); + } + } + + public ApiResponse createInstance(String name) { + try { + boolean created = InstanceManager.createInstanceFolder(name); + if (!created) { + return ApiResponse.error("A pack with this name already exists: " + name); + } + Instance instance = InstanceManager.getInstance(name); + return ApiResponse.success(toInstanceInfo(instance)); + } catch (IOException e) { + return ApiResponse.error("Error creating pack: " + e.getMessage()); + } + } + + public ApiResponse deleteInstance(String name) { + try { + boolean deleted = InstanceManager.deleteInstance(name); + if (!deleted) { + return ApiResponse.error("Failed to delete pack: " + name); + } + return ApiResponse.success(true); + } catch (Exception e) { + return ApiResponse.error("Error deleting pack: " + e.getMessage()); + } + } + + public ApiResponse isInstanceExists(String name) { + try { + Instance instance = InstanceManager.getInstance(name); + return ApiResponse.success(instance != null); + } catch (Exception e) { + return ApiResponse.error("Error checking pack: " + e.getMessage()); + } + } + + private InstanceInfo toInstanceInfo(Instance instance) { + String name = instance.getName().toLowerCase(); + String category = instance.isServerPack() ? "zernmc" : "local"; + + return new InstanceInfo( + instance.getName(), + instance.getPath().toString(), + instance.getMinecraftVersion(), + instance.getLoaderType(), + category, + instance.isServerPack(), + instance.getServerVersion(), + instance.getLoaderVersion(), + instance.getServerPackName() + ); + } + + public static class InstanceInfo { + private String name; + private String path; + private String version; + private String loaderType; + private String category; + private boolean isServerPack; + private int serverVersion; + private String loaderVersion; + private String serverPackName; + + public InstanceInfo(String name, String path, String version, String loaderType, String category, + boolean isServerPack, int serverVersion, String loaderVersion, String serverPackName) { + this.name = name; + this.path = path; + this.version = version; + this.loaderType = loaderType; + this.category = category; + this.isServerPack = isServerPack; + this.serverVersion = serverVersion; + this.loaderVersion = loaderVersion; + this.serverPackName = serverPackName; + } + + public String getName() { return name; } + public String getPath() { return path; } + public String getVersion() { return version; } + public String getLoaderType() { return loaderType; } + public String getCategory() { return category; } + public boolean isServerPack() { return isServerPack; } + public int getServerVersion() { return serverVersion; } + public String getLoaderVersion() { return loaderVersion; } + public String getServerPackName() { return serverPackName; } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java new file mode 100644 index 0000000..17e0615 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java @@ -0,0 +1,302 @@ +package me.sashegdev.zernmc.launcher.api.launch; + +import me.sashegdev.zernmc.launcher.api.ApiResponse; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; +import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; +import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +public class LaunchService { + + private static final ConcurrentHashMap runningProcesses = new ConcurrentHashMap<>(); + + static { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + LauncherLogger.info("[LAUNCH] Shutting down all running processes..."); + runningProcesses.values().forEach(p -> { + try { + p.destroy(); + } catch (Exception ignored) {} + }); + })); + } + + public ApiResponse prepareLaunch(String instanceName) { + try { + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + return ApiResponse.error("Pack not found: " + instanceName); + } + + LauncherLogger.info("Preparing launch for: " + instanceName); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + LaunchOptions options = createOptions(); + + List command = builder.build(options); + + LaunchInfo info = new LaunchInfo( + instanceName, + command, + instance.getPath().toString() + ); + return ApiResponse.success(info); + } catch (Exception e) { + LauncherLogger.error("Error preparing launch for " + instanceName, e); + return ApiResponse.error("Error preparing launch: " + e.getMessage()); + } + } + + public ApiResponse launch(String instanceName) { + try { + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + return ApiResponse.error("Pack not found: " + instanceName); + } + + LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")"); + + LaunchOptions options = createOptions(); + options.setUsername(AuthManager.getUsername()); + options.setAccessToken(AuthManager.getAccessToken()); + options.setUuid(AuthManager.getUuid()); + + return launchProcess(instance, instanceName, options); + } catch (Exception e) { + LauncherLogger.error("Launch error for " + instanceName, e); + return ApiResponse.error("Launch error: " + e.getMessage()); + } + } + + public ApiResponse launchOffline(String instanceName, String nickname) { + try { + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + return ApiResponse.error("Pack not found: " + instanceName); + } + + LauncherLogger.info("Offline launch: " + instanceName); + String safeName = (nickname == null || nickname.isBlank()) ? "Player" : nickname.trim(); + LaunchOptions options = createOptions(); + options.setUsername(safeName); + options.setAccessToken("0"); + options.setUuid(java.util.UUID.nameUUIDFromBytes( + ("OfflinePlayer:" + safeName).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString()); + + return launchProcess(instance, instanceName, options); + } catch (Exception e) { + LauncherLogger.error("Offline launch error for " + instanceName, e); + return ApiResponse.error("Offline launch error: " + e.getMessage()); + } + } + + private ApiResponse launchProcess(Instance instance, String instanceName, LaunchOptions options) { + try { + long t0 = System.currentTimeMillis(); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + List command = builder.build(options); + LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms"); + LauncherLogger.info("Generated command for " + instanceName + ":"); + command.forEach(arg -> LauncherLogger.debug(" " + arg)); + + long t1 = System.currentTimeMillis(); + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.directory(instance.getPath().toFile()); + processBuilder.redirectErrorStream(true); + + Path logsDir = instance.getPath().resolve("logs"); + java.nio.file.Files.createDirectories(logsDir); + Path gameLog = logsDir.resolve("game.log"); + + Process process = processBuilder.start(); + long pid = process.pid(); + LauncherLogger.info("Launch: process started in " + (System.currentTimeMillis() - t1) + " ms (total " + (System.currentTimeMillis() - t0) + " ms), pid=" + pid); + + runningProcesses.put(pid, process); + LauncherLogger.info("Process started, pid=" + pid); + + java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true); + + Thread logReader = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line; + JFXBridge.appendGameLog(line); + try { + logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + logFileOut.flush(); + } catch (Exception ignored) {} + } + } catch (Exception e) { + JFXBridge.appendGameLog("[Error reading logs: " + e.getMessage() + "]"); + } finally { + try { logFileOut.close(); } catch (Exception ignored) {} + } + }, "GameLogReader-" + instanceName); + logReader.setDaemon(true); + logReader.start(); + + // Watch the child on a DAEMON thread via Process.waitFor() instead of + // process.onExit().thenRun(...): Process.onExit() completes its action on + // the common ForkJoinPool, whose worker threads are non-daemon and keep + // the launcher JVM alive on exit (the v1.0.14.2 "JFX hang (ForkJoinPool)" + // regression, re-introduced in v1.0.14.3's sendBounded). A daemon watcher + // does not prevent JVM shutdown and avoids pinning the common pool. + Thread processWatcher = new Thread(() -> { + try { + int exitCode = process.waitFor(); + runningProcesses.remove(pid); + JFXBridge.appendGameLog("[Minecraft exited with code: " + exitCode + "]"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + JFXBridge.appendGameLog("[Error watching process: " + e.getMessage() + "]"); + } + }, "ProcessWatcher-" + instanceName); + processWatcher.setDaemon(true); + processWatcher.start(); + + ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING"); + return ApiResponse.success(info); + } catch (Exception e) { + LauncherLogger.error("Launch error for " + instanceName, e); + return ApiResponse.error("Launch error: " + e.getMessage()); + } + } + + public static void killAllProcesses() { + runningProcesses.values().forEach(p -> { + try { + p.destroyForcibly(); + } catch (Exception ignored) {} + }); + runningProcesses.clear(); + } + + public ApiResponse isReady(String instanceName) { + try { + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + return ApiResponse.error("Pack not found: " + instanceName); + } + + Path versionJson = instance.getPath().resolve("version.json"); + boolean hasVersionJson = versionJson.toFile().exists(); + + return ApiResponse.success(hasVersionJson); + } catch (Exception e) { + return ApiResponse.error("Readiness check error: " + e.getMessage()); + } + } + + public ApiResponse getLaunchInfo(String instanceName) { + try { + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + return ApiResponse.error("Pack not found: " + instanceName); + } + + InstanceInfo info = new InstanceInfo( + instance.getName(), + instance.getMinecraftVersion(), + instance.getLoaderType(), + instance.getLoaderVersion(), + instance.getAssetIndex() + ); + return ApiResponse.success(info); + } catch (Exception e) { + return ApiResponse.error("Info retrieval error: " + e.getMessage()); + } + } + + private static LaunchOptions createOptions() { + LaunchOptions options = new LaunchOptions(); + options.setMaxMemory(Config.getMaxMemory()); + options.setWidth(Config.getWindowWidth()); + options.setHeight(Config.getWindowHeight()); + options.setJavaPath(Config.getJavaPath()); + List extraArgs = new ArrayList<>(); + if (Config.isSystemBasedJvm()) { + String[] systemFlags = Config.getSystemJvmFlags().split("\\s+"); + for (String arg : systemFlags) { + if (!arg.isEmpty()) extraArgs.add(arg); + } + } + String args = Config.getExtraJvmArgs(); + if (args != null && !args.isEmpty()) { + for (String arg : args.split("\n")) { + arg = arg.trim(); + if (!arg.isEmpty()) extraArgs.add(arg); + } + } + options.setExtraJvmArgs(extraArgs); + return options; + } + + public static class LaunchInfo { + private String instanceName; + private List command; + private String workingDirectory; + + public LaunchInfo(String instanceName, List command, String workingDirectory) { + this.instanceName = instanceName; + this.command = command; + this.workingDirectory = workingDirectory; + } + + public String getInstanceName() { return instanceName; } + public List getCommand() { return command; } + public String getWorkingDirectory() { return workingDirectory; } + } + + public static class ProcessInfo { + private String instanceName; + private long pid; + private String status; + + public ProcessInfo(String instanceName, long pid, String status) { + this.instanceName = instanceName; + this.pid = pid; + this.status = status; + } + + public String getInstanceName() { return instanceName; } + public long getPid() { return pid; } + public String getStatus() { return status; } + } + + public static class InstanceInfo { + private String name; + private String minecraftVersion; + private String loaderType; + private String loaderVersion; + private String assetIndex; + + public InstanceInfo(String name, String minecraftVersion, String loaderType, + String loaderVersion, String assetIndex) { + this.name = name; + this.minecraftVersion = minecraftVersion; + this.loaderType = loaderType; + this.loaderVersion = loaderVersion; + this.assetIndex = assetIndex; + } + + public String getName() { return name; } + public String getMinecraftVersion() { return minecraftVersion; } + public String getLoaderType() { return loaderType; } + public String getLoaderVersion() { return loaderVersion; } + public String getAssetIndex() { return assetIndex; } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java new file mode 100644 index 0000000..509f59a --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java @@ -0,0 +1,531 @@ +package me.sashegdev.zernmc.launcher.auth; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.annotations.SerializedName; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class AuthManager { + + private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json"); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private static volatile AuthSession session = null; + private static volatile UserInfo userInfo = null; + + public static final int ROLE_USER = 0; + public static final int ROLE_PASS_HOLDER = 1; + public static final int ROLE_MODERATOR = 2; + public static final int ROLE_ELDER = 3; + public static final int ROLE_CREATOR = 4; + + public static final String PERM_VIEW_PACKS = "view_packs"; + public static final String PERM_DOWNLOAD_PACK = "download_pack"; + + public static boolean loadSavedSession() { + if (!Files.exists(AUTH_FILE)) { + LauncherLogger.warn("loadSavedSession: auth.json not found at " + AUTH_FILE); + return false; + } + try { + String json = Files.readString(AUTH_FILE); + AuthSession loaded = GSON.fromJson(json, AuthSession.class); + if (loaded == null || loaded.accessToken == null) { + LauncherLogger.warn("loadSavedSession: invalid auth.json content, deleting"); + Files.deleteIfExists(AUTH_FILE); + return false; + } + + session = loaded; + LauncherLogger.info("loadSavedSession: loaded session for " + loaded.username + + " expiresAt=" + loaded.expiresAt + " hasRefresh=" + (loaded.refreshToken != null)); + + refreshUserInfo(); + + if (isAccessTokenExpired()) { + LauncherLogger.info("loadSavedSession: token expired, attempting refresh"); + boolean refreshed = tryRefresh(); + if (!refreshed) { + if (session == null) { + LauncherLogger.warn("loadSavedSession: token rejected by server (401)"); + return false; + } + LauncherLogger.warn("loadSavedSession: refresh failed (network/no refreshToken)," + + " keeping session for retry on next launch"); + return false; + } + } + if (session == null) { + LauncherLogger.warn("loadSavedSession: session invalidated during token refresh"); + return false; + } + LauncherLogger.info("loadSavedSession: session valid for " + session.username); + return true; + } catch (Exception e) { + LauncherLogger.error("loadSavedSession error: " + e.getMessage()); + invalidateSession(); + return false; + } + } + + public static boolean tryAutoLogin() { + if (isLoggedIn()) return true; + if (!Files.exists(AUTH_FILE)) return false; + boolean loaded = loadSavedSession(); + if (loaded) { + refreshUserInfo(); + } + return loaded; + } + + public static AuthResult login(String username, String password) { + return authRequest("/auth/login", username, password); + } + + public static AuthResult register(String username, String password) { + return authRequest("/auth/register", username, password); + } + + private static AuthResult authRequest(String endpoint, String username, String password) { + try { + String body = GSON.toJson(new LoginRequest(username, password)); + SimpleHttpResponse resp = post(endpoint, body); + + if (resp.statusCode() == 200) { + session = GSON.fromJson(resp.body(), AuthSession.class); + session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn; + LauncherLogger.info("authRequest: login successful, expiresAt=" + session.expiresAt + + " hasRefresh=" + (session.refreshToken != null)); + saveSession(); + userInfo = fetchUserInfo(); + return AuthResult.ok(); + } else { + return AuthResult.fail(friendlyHttpError(resp.statusCode(), resp.body())); + } + } catch (Exception e) { + LauncherLogger.warn("authRequest failed (" + endpoint + "): " + e.getMessage()); + return AuthResult.fail(friendlyConnectionError(e)); + } + } + + /** + * Maps an HTTP status code + raw body to a short, human-readable error message + * (in Russian) suitable for showing directly to the user. + */ + public static String friendlyHttpError(int statusCode, String body) { + switch (statusCode) { + case 401: + case 403: + return "Неверный логин или пароль"; + case 404: + return "Сервис временно недоступен, попробуйте позже"; + case 409: + return "Логин уже занят"; + case 422: + return "Некорректные данные: " + extractError(body); + case 429: + return "Слишком много попыток. Подождите немного."; + default: + if (statusCode >= 500) { + return "Сервер временно недоступен, попробуйте позже"; + } + } + String detail = extractError(body); + if (detail.isEmpty() || detail.startsWith("<")) { + return "Ошибка сервера (код " + statusCode + ")"; + } + if (detail.length() > 120) { + detail = detail.substring(0, 117) + "..."; + } + return detail; + } + + /** + * Maps a network/IO exception to a short, human-readable message. + */ + public static String friendlyConnectionError(Exception e) { + String m = e == null ? "" : String.valueOf(e.getMessage()).toLowerCase(); + if (e instanceof java.net.SocketTimeoutException + || e instanceof java.net.ConnectException + || e instanceof java.net.UnknownHostException + || m.contains("timeout") || m.contains("connect") || m.contains("unknownhost") + || m.contains("refused")) { + return "Нет соединения с сервером. Проверьте интернет."; + } + return "Ошибка соединения с сервером. Попробуйте позже."; + } + + public static void logout() { + if (session != null && session.refreshToken != null) { + try { + JsonObject json = new JsonObject(); + json.addProperty("refresh_token", session.refreshToken); + post("/auth/logout", json.toString()); + } catch (Exception e) { + LauncherLogger.warn("Logout error: " + e.getMessage()); + } + } + session = null; + userInfo = null; + try { Files.deleteIfExists(AUTH_FILE); } catch (Exception e) { + LauncherLogger.warn("Failed to delete auth.json: " + e.getMessage()); + } + } + + public static boolean isLoggedIn() { + return session != null && session.accessToken != null; + } + + public static boolean authFileExists() { + return Files.exists(AUTH_FILE); + } + + public static String getUsername() { + AuthSession localSession = session; + return localSession != null ? localSession.username : "Player"; + } + + public static String getUuid() { + AuthSession localSession = session; + return localSession != null ? localSession.uuid : "00000000-0000-0000-0000-000000000000"; + } + + public static String getAccessToken() { + AuthSession localSession = session; + if (localSession == null) return "0"; + if (isAccessTokenExpired()) { + boolean refreshed = tryRefresh(); + if (!refreshed) { + localSession = session; + if (localSession == null) return "0"; + return localSession.accessToken != null ? localSession.accessToken : "0"; + } + } + localSession = session; + return localSession != null && localSession.accessToken != null ? localSession.accessToken : "0"; + } + + private static boolean isAccessTokenExpired() { + if (session == null) return true; + return System.currentTimeMillis() / 1000L >= session.expiresAt - 300; + } + + private static boolean tryRefresh() { + if (session == null) { + LauncherLogger.warn("tryRefresh: session is null"); + return false; + } + if (session.refreshToken == null) { + LauncherLogger.warn("tryRefresh: no refreshToken in session"); + return false; + } + try { + JsonObject json = new JsonObject(); + json.addProperty("refresh_token", session.refreshToken); + SimpleHttpResponse resp = post("/auth/refresh", json.toString()); + + if (resp.statusCode() == 200) { + AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class); + newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn; + session = newSession; + userInfo = fetchUserInfo(); + if (userInfo != null) { + session.role = userInfo.role; + } + saveSession(); + LauncherLogger.info("tryRefresh: token refreshed successfully"); + return true; + } + + if (resp.statusCode() == 401) { + LauncherLogger.warn("tryRefresh: server rejected refresh token (401)"); + invalidateSession(); + } else { + LauncherLogger.warn("tryRefresh: server returned " + resp.statusCode()); + } + } catch (Exception e) { + LauncherLogger.warn("tryRefresh: network error: " + e.getMessage()); + return false; + } + return false; + } + + private static void invalidateSession() { + session = null; + userInfo = null; + try { + Files.deleteIfExists(AUTH_FILE); + LauncherLogger.info("Session invalidated, auth.json deleted"); + } catch (Exception e) { + LauncherLogger.error("Failed to delete auth.json", e); + } + } + + private static void saveSession() { + try { + Files.createDirectories(AUTH_FILE.getParent()); + Files.writeString(AUTH_FILE, GSON.toJson(session)); + LauncherLogger.info("Session saved to " + AUTH_FILE); + } catch (IOException e) { + LauncherLogger.error("Failed to save session", e); + } + } + + private static UserInfo fetchUserInfo() { + if (!isLoggedIn() || session.accessToken == null) return null; + + try { + java.net.HttpURLConnection conn = null; + try { + URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me"); + conn = (java.net.HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Authorization", "Bearer " + session.accessToken); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) return null; + + StringBuilder response = new StringBuilder(); + try (var reader = new java.io.BufferedReader( + new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + return GSON.fromJson(response.toString(), UserInfo.class); + } finally { + if (conn != null) conn.disconnect(); + } + } catch (Exception e) { + LauncherLogger.warn("Failed to get UserInfo: " + e.getMessage()); + return null; + } + } + + public static boolean hasPass() { + if (!isLoggedIn()) return false; + if (userInfo != null) return userInfo.has_pass; + if (getRole() >= ROLE_PASS_HOLDER) return true; + try { + String response = ZHttpClient.get("/auth/pass/my"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + if (json.has("has_active")) { + return json.get("has_active").getAsBoolean(); + } + } catch (Exception e) { + LauncherLogger.warn("Failed to check pass: " + e.getMessage()); + } + return false; + } + + public static boolean canViewPacks() { + if (userInfo != null && userInfo.permissions != null) { + return userInfo.permissions.contains(PERM_VIEW_PACKS); + } + return hasPass(); + } + + public static boolean canDownloadPacks() { + if (userInfo != null && userInfo.permissions != null) { + return userInfo.permissions.contains(PERM_DOWNLOAD_PACK); + } + return hasPass(); + } + + public static int getRole() { + return session != null ? session.role : ROLE_USER; + } + + public static String getRoleName() { + if (userInfo != null && userInfo.role_name != null) { + return userInfo.role_name; + } + return "USER"; + } + + private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception { + String fullUrl = ZHttpClient.getBaseUrl() + endpoint; + HttpURLConnection conn = null; + + try { + URL url = new URL(fullUrl); + conn = (HttpURLConnection) url.openConnection(); + + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); + conn.setRequestProperty("Connection", "close"); + + if (session != null && session.accessToken != null) { + conn.setRequestProperty("Authorization", "Bearer " + session.accessToken); + } + + conn.setDoOutput(true); + conn.setConnectTimeout(15000); + conn.setReadTimeout(15000); + + byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(input.length); + + try (var os = conn.getOutputStream()) { + os.write(input); + os.flush(); + } + + int statusCode = conn.getResponseCode(); + InputStream is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream(); + + String responseBody; + if (is != null) { + try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) { + responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; + } + } else { + responseBody = "No response body (status " + statusCode + ")"; + } + + return new SimpleHttpResponse(statusCode, responseBody); + + } finally { + if (conn != null) conn.disconnect(); + } + } + + private static String extractError(String body) { + try { + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + if (json.has("detail")) { + if (json.get("detail").isJsonArray()) { + return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString(); + } + return json.get("detail").getAsString(); + } + } catch (Exception ignored) {} + return body.length() > 200 ? body.substring(0, 200) + "..." : body; + } + + public static void updateRole(int newRole) { + if (session != null) { + session.role = newRole; + saveSession(); + } + refreshUserInfo(); + } + + public static void refreshUserInfo() { + UserInfo fresh = fetchUserInfo(); + if (fresh != null) { + userInfo = fresh; + if (session != null) { + session.role = fresh.role; + } + } + if (session != null) { + saveSession(); + } + } + + public static boolean hasActivePass() { + if (!isLoggedIn()) return false; + return hasPass(); + } + + public static String getPassStatus() { + if (!isLoggedIn()) return "Not logged in"; + try { + String response = ZHttpClient.get("/auth/pass/my"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean(); + return hasActive ? "Active pass" : "No pass"; + } catch (Exception e) { + return "Check error"; + } + } + + public static class AuthSession { + @SerializedName("access_token") public String accessToken; + @SerializedName("refresh_token") public String refreshToken; + @SerializedName("expires_in") public int expiresIn; + public long expiresAt; + public String username; + public String uuid; + public int role; + } + + public static class UserInfo { + public int id; + public String username; + public String uuid; + public int role; + public String role_name; + public boolean has_pass; + public List permissions; + + public boolean hasPermission(String perm) { + return permissions != null && permissions.contains(perm); + } + } + + private static class LoginRequest { + final String username; + final String password; + LoginRequest(String u, String p) { + this.username = u; + this.password = p; + } + } + + public static class AuthResult { + public final boolean success; + public final String error; + private AuthResult(boolean s, String e) { success = s; error = e; } + public static AuthResult ok() { return new AuthResult(true, null); } + public static AuthResult fail(String msg) { return new AuthResult(false, msg); } + } + + // === TEST HELPERS === + static void resetForTest() { + session = null; + userInfo = null; + } + + static void setTestSession(AuthSession s) { + session = s; + } + + static void setTestUserInfo(UserInfo u) { + userInfo = u; + } +} + +class SimpleHttpResponse { + final int statusCode; + final String body; + + SimpleHttpResponse(int statusCode, String body) { + this.statusCode = statusCode; + this.body = body; + } + + int statusCode() { return statusCode; } + String body() { return body; } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LaunchMenu.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LaunchMenu.java new file mode 100644 index 0000000..33b54ec --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LaunchMenu.java @@ -0,0 +1,686 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; +import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib; +import me.sashegdev.zernmc.launcher.minecraft.PackDownloader; +import me.sashegdev.zernmc.launcher.minecraft.ServerPack; +import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; +import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion; +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.Input; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.awt.*; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class LaunchMenu { + + public void show() throws Exception { + showGlobal(); + } + + private boolean awaitActivePass() throws Exception { + if (AuthManager.hasActivePass()) { + System.out.println(ZAnsi.brightGreen("✓ Active pass confirmed")); + return true; + } + + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.brightRed("You don't have an active pass!")); + System.out.println(ZAnsi.white("Access to ZernMC packs requires an active pass.")); + System.out.println(); + + openActivationWebsite(); + + System.out.println(ZAnsi.cyan("Waiting for pass activation... (checking every 10 seconds)")); + System.out.println(ZAnsi.white("Press Enter to cancel")); + + for (int i = 0; i < 60; i++) { + try { + if (System.in.available() > 0) { + Input.readLine(); + System.out.println(ZAnsi.yellow("\nWaiting cancelled.")); + return false; + } + } catch (Exception ignored) {} + + Thread.sleep(10000); + + if (AuthManager.hasActivePass()) { + System.out.println(ZAnsi.brightGreen("\n✓ Pass activated successfully!")); + return true; + } + + System.out.print(ZAnsi.cyan(".")); + if ((i + 1) % 6 == 0) System.out.println(); + } + + System.out.println(ZAnsi.brightRed("\n\nWaiting time expired.")); + return false; + } + + private void openActivationWebsite() { + String url = ZHttpClient.getBaseUrl() + "/activate-pass"; + + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(new URI(url)); + System.out.println(ZAnsi.cyan("Browser opened: " + url)); + } else { + System.out.println(ZAnsi.yellow("Could not open browser automatically.")); + System.out.println(ZAnsi.white("Open manually: " + url)); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("Error opening browser: " + e.getMessage())); + System.out.println(ZAnsi.white("Link: " + url)); + } + } + + private void showGlobal() throws Exception { + while (true) { + ConsoleUtils.clearScreen(); + List instances = InstanceManager.getAllInstances(); + + List options = instances.stream() + .map(Instance::toString) + .collect(Collectors.toList()); + + options.add("Install new pack"); + options.add("Back to main menu"); + + ArrowMenu menu = new ArrowMenu("Manage packs", options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) break; + + if (choice == instances.size()) { + installNewPackGlobal(); + continue; + } + + Instance selected = instances.get(choice); + manageInstance(selected); + } + } + + private void installNewPackGlobal() throws Exception { + ConsoleUtils.clearScreen(); + + List options = List.of( + "Install pack from ZernMC server", + "Install Vanilla Minecraft", + "Create custom pack (Fabric/Forge)", + "Back" + ); + + ArrowMenu menu = new ArrowMenu("Install new pack", options); + int choice = menu.show(); + + if (choice == -1 || choice == 3) return; + + switch (choice) { + case 0 -> installServerPackGlobal(); + case 1 -> createVanillaInstance(); + case 2 -> createCustomInstance(); + } + } + + private void installServerPackGlobal() throws Exception { + if (!awaitActivePass()) return; + + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Fetching available packs...")); + + PackDownloader tempDownloader = new PackDownloader(null); + List availablePacks = tempDownloader.getAvailablePacks(); + + if (availablePacks.isEmpty()) { + System.out.println(ZAnsi.yellow("No packs available on the server.")); + ConsoleUtils.pause(); + return; + } + + List options = availablePacks.stream() + .map(p -> String.format("%s [%s + %s v%d] - %d files", + p.getName(), + p.getMinecraftVersion(), + p.getLoaderType(), + p.getVersion(), + p.getFilesCount())) + .collect(Collectors.toList()); + options.add("Back"); + + ArrowMenu menu = new ArrowMenu("Select a pack to install", options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return; + + ServerPack selected = availablePacks.get(choice); + + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("Installing pack: " + selected.getName())); + + System.out.print(ZAnsi.white("\nEnter local pack name (Enter = pack name): ")); + String localName = Input.readLine().trim(); + if (localName.isEmpty()) localName = selected.getName(); + + if (InstanceManager.getInstance(localName) != null) { + System.out.println(ZAnsi.brightRed("A pack with this name already exists!")); + ConsoleUtils.pause(); + return; + } + + InstanceManager.createInstanceFolder(localName); + Instance newInstance = InstanceManager.getInstance(localName); + + PackDownloader packDownloader = new PackDownloader(newInstance); + boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected); + + if (success) { + System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!")); + } else { + System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack.")); + } + + ConsoleUtils.pause(); + } + + private void manageInstance(Instance instance) throws Exception { + while (true) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("Managing pack: " + instance.getName())); + System.out.println(ZAnsi.white("Version: " + instance.getMinecraftVersion())); + System.out.println(ZAnsi.white("Loader: " + instance.getLoaderType() + + (instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : ""))); + + if (instance.isServerPack()) { + System.out.println(ZAnsi.green("Server pack: v" + instance.getServerVersion())); + } + + List options = new ArrayList<>(); + options.add("Launch pack"); + if (instance.isServerPack()) { + options.add("Check for updates"); + } + options.add("Change loader version"); + options.add("Delete pack"); + options.add("Back"); + + ArrowMenu menu = new ArrowMenu("Actions", options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return; + + switch (choice) { + case 0 -> launchExistingInstance(instance); + case 1 -> { + if (instance.isServerPack()) { + checkAndUpdateServerPack(instance); + } else { + changeLoaderVersion(instance); + } + } + case 2 -> { + if (instance.isServerPack()) { + changeLoaderVersion(instance); + } else { + deleteInstance(instance); + } + } + case 3 -> deleteInstance(instance); + } + } + } + + private void checkAndUpdateServerPack(Instance instance) throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName())); + + PackDownloader downloader = new PackDownloader(instance); + boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); + + if (!hasUpdate) { + System.out.println(ZAnsi.green("Pack is up to date (v" + instance.getServerVersion() + ")")); + ConsoleUtils.pause(); + return; + } + + System.out.println(ZAnsi.brightYellow("Update available!")); + if (Input.confirm("Update pack")) { + boolean success = downloader.updatePack(instance.getServerPackName()); + if (success) { + System.out.println(ZAnsi.brightGreen("Pack updated successfully!")); + } else { + System.out.println(ZAnsi.brightRed("Failed to update pack.")); + } + } else { + System.out.println(ZAnsi.yellow("Update cancelled.")); + } + ConsoleUtils.pause(); + } + + private void changeLoaderVersion(Instance instance) throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Changing loader version for " + instance.getName())); + + String currentLoader = instance.getLoaderType(); + String mcVersion = instance.getMinecraftVersion(); + + if ("vanilla".equalsIgnoreCase(currentLoader)) { + System.out.println(ZAnsi.yellow("This is a vanilla instance. Cannot change loader.")); + ConsoleUtils.pause(); + return; + } + + String newLoaderVersion; + if ("fabric".equalsIgnoreCase(currentLoader)) { + newLoaderVersion = askFabricLoaderVersion(); + } else if ("neoforge".equalsIgnoreCase(currentLoader)) { + newLoaderVersion = askNeoForgeVersion(mcVersion); + } else { + newLoaderVersion = askForgeVersion(mcVersion); + } + + if (newLoaderVersion == null) return; + + System.out.println(ZAnsi.cyan("Reinstalling loader " + currentLoader + " -> " + newLoaderVersion + "...")); + + MinecraftLib lib = new MinecraftLib(instance); + boolean success; + + try { + if ("fabric".equalsIgnoreCase(currentLoader)) { + success = lib.installFabric(mcVersion, newLoaderVersion); + } else if ("neoforge".equalsIgnoreCase(currentLoader)) { + success = lib.installNeoForge(mcVersion, newLoaderVersion); + } else { + success = lib.installForge(mcVersion, newLoaderVersion); + } + + if (success) { + System.out.println(ZAnsi.brightGreen("Loader version changed successfully!")); + } else { + System.out.println(ZAnsi.brightRed("Failed to change loader version.")); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("Error changing loader: " + e.getMessage())); + } + + ConsoleUtils.pause(); + } + + private void deleteInstance(Instance instance) throws IOException { + ConsoleUtils.clearScreen(); + + List confirmOptions = List.of( + "Yes, delete pack", + "No, cancel" + ); + + ArrowMenu confirmMenu = new ArrowMenu( + "Are you sure you want to delete '" + instance.getName() + "'?", + confirmOptions + ); + + int choice = confirmMenu.show(); + + if (choice == 0) { + boolean deleted = InstanceManager.deleteInstance(instance.getName()); + if (deleted) { + System.out.println(ZAnsi.brightGreen("Pack '" + instance.getName() + "' deleted successfully.")); + } else { + System.out.println(ZAnsi.brightRed("Failed to delete pack.")); + } + } else { + System.out.println(ZAnsi.yellow("Deletion cancelled.")); + } + + ConsoleUtils.pause(); + } + + private void launchExistingInstance(Instance instance) { + if (instance.isServerPack() && !AuthManager.hasActivePass()) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.brightRed("Launching a server pack requires an active pass!")); + ConsoleUtils.pause(); + return; + } + + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName())); + + MinecraftLib lib = new MinecraftLib(instance); + LaunchOptions options = new LaunchOptions(); + options.setMaxMemory(Config.getMaxMemory()); + options.setWidth(Config.getWindowWidth()); + options.setHeight(Config.getWindowHeight()); + options.setJavaPath(Config.getJavaPath()); + + options.setUsername(AuthManager.getUsername()); + options.setUuid(AuthManager.getUuid()); + options.setAccessToken(AuthManager.getAccessToken()); + + try { + lib.launch(options); + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("Error launching: " + e.getMessage())); + e.printStackTrace(); + } + + ConsoleUtils.pause(); + } + + private String askPackName() { + System.out.print(ZAnsi.white("\nEnter new pack name: ")); + String name = Input.readLine().trim(); + if (name.isEmpty()) { + System.out.println(ZAnsi.yellow("Cancelled.")); + return null; + } + return name; + } + + private void createVanillaInstance() throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Fetching Minecraft versions...")); + + VersionInstaller versionInstaller = new VersionInstaller(null); + List allVersions = versionInstaller.getAvailableVersions(); + + List versionOptions = allVersions.stream() + .map(v -> v.getId() + " (" + v.getType() + ")") + .collect(Collectors.toList()); + versionOptions.add("Back"); + + ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions); + int versionChoice = versionMenu.show(); + + if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; + + MinecraftVersion selectedMc = allVersions.get(versionChoice); + String mcVersion = selectedMc.getId(); + + String packName = askPackName(); + if (packName == null) return; + + if (InstanceManager.getInstance(packName) != null) { + System.out.println(ZAnsi.brightRed("A pack with this name already exists!")); + ConsoleUtils.pause(); + return; + } + + InstanceManager.createInstanceFolder(packName); + Instance newInstance = InstanceManager.getInstance(packName); + + MinecraftLib lib = new MinecraftLib(newInstance); + boolean success = lib.installMinecraft(mcVersion); + + if (success) { + System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla pack '" + packName + "' created successfully!")); + } else { + System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to create pack.")); + } + + ConsoleUtils.pause(); + } + + private void createCustomInstance() throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Fetching Minecraft versions...")); + + VersionInstaller versionInstaller = new VersionInstaller(null); + List allVersions = versionInstaller.getAvailableVersions(); + + List versionOptions = allVersions.stream() + .map(v -> v.getId() + " (" + v.getType() + ")") + .collect(Collectors.toList()); + versionOptions.add("Back"); + + ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions); + int versionChoice = versionMenu.show(); + + if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; + + MinecraftVersion selectedMc = allVersions.get(versionChoice); + String mcVersion = selectedMc.getId(); + + List loaderOptions = buildLoaderOptions(mcVersion); + ArrowMenu loaderMenu = new ArrowMenu("Select mod loader for " + mcVersion, loaderOptions); + int loaderChoice = loaderMenu.show(); + + if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return; + + String selectedLoader = loaderOptions.get(loaderChoice); + + if (selectedLoader.contains("Vanilla")) { + createVanillaInstance(); + return; + } + + String loaderType; + if (selectedLoader.contains("Fabric")) { + loaderType = "fabric"; + } else if (selectedLoader.contains("NeoForge")) { + loaderType = "neoforge"; + } else { + loaderType = "forge"; + } + + String loaderVersion; + if (loaderType.equals("fabric")) { + loaderVersion = askFabricLoaderVersion(); + } else if (loaderType.equals("neoforge")) { + loaderVersion = askNeoForgeVersion(mcVersion); + } else { + loaderVersion = askForgeVersion(mcVersion); + } + + if (loaderVersion == null) return; + + String packName = askPackName(); + if (packName == null) return; + + if (InstanceManager.getInstance(packName) != null) { + System.out.println(ZAnsi.brightRed("A pack with this name already exists!")); + ConsoleUtils.pause(); + return; + } + + InstanceManager.createInstanceFolder(packName); + Instance newInstance = InstanceManager.getInstance(packName); + + MinecraftLib lib = new MinecraftLib(newInstance); + + boolean success; + if (loaderType.equals("fabric")) { + success = lib.installFabric(mcVersion, loaderVersion); + } else if (loaderType.equals("neoforge")) { + success = lib.installNeoForge(mcVersion, loaderVersion); + } else { + success = lib.installForge(mcVersion, loaderVersion); + } + + if (success) { + System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + packName + "' installed successfully!")); + } else { + System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to install pack.")); + } + + ConsoleUtils.pause(); + } + + private List buildLoaderOptions(String mcVersion) { + List options = new ArrayList<>(); + + if (isFabricSupported(mcVersion)) options.add("Fabric"); + if (isNeoForgeSupported(mcVersion)) options.add("NeoForge"); + if (isForgeSupported(mcVersion)) options.add("Forge"); + options.add("Vanilla"); + options.add("Back"); + + return options; + } + + private boolean isFabricSupported(String version) { + return version.matches("^1\\.(1[4-9]|[2-9]\\d).*"); + } + + private boolean isForgeSupported(String version) { + if (version.matches("^1\\.2[2-9].*") || version.matches("^\\d{2}.*")) return false; + return version.matches("^1\\.(1[2-9]|[2-9]\\d).*") || + version.matches("^1\\.20.*") || version.matches("^1\\.21.*"); + } + + private boolean isNeoForgeSupported(String version) { + return version.matches("^1\\.20\\.[1-9].*") || + version.matches("^1\\.21.*") || + version.matches("^\\d{2}\\..*"); + } + + private String askFabricLoaderVersion() throws Exception { + System.out.println(ZAnsi.cyan("Fetching Fabric Loader versions...")); + List versions = ZHttpClient.getFabricLoaderVersions(); + + List options = versions.stream() + .limit(30) + .map(v -> "Fabric Loader " + v) + .collect(Collectors.toList()); + options.add("Back"); + + ArrowMenu menu = new ArrowMenu("Select Fabric Loader version", options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return null; + return versions.get(choice); + } + + private String askForgeVersion(String mcVersion) throws Exception { + System.out.println(ZAnsi.cyan("Fetching Forge versions for " + mcVersion + "...")); + + List allForgeVersions = getAllForgeVersions(); + + List compatibleVersions = allForgeVersions.stream() + .filter(v -> v.startsWith(mcVersion + "-")) + .map(v -> v.substring(mcVersion.length() + 1)) + .collect(Collectors.toList()); + + if (compatibleVersions.isEmpty()) { + System.out.println(ZAnsi.yellow("No compatible Forge versions found for " + mcVersion)); + ConsoleUtils.pause(); + return null; + } + + List options = compatibleVersions.stream() + .limit(30) + .map(v -> "Forge " + v) + .collect(Collectors.toList()); + options.add("Back"); + + ArrowMenu menu = new ArrowMenu("Select Forge version for " + mcVersion, options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return null; + + return compatibleVersions.get(choice); + } + + private List getAllForgeVersions() throws Exception { + String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"); + + List versions = new ArrayList<>(); + int index = 0; + + while ((index = xml.indexOf("", index)) != -1) { + int start = index + 9; + int end = xml.indexOf("", start); + if (end == -1) break; + + String version = xml.substring(start, end).trim(); + versions.add(version); + index = end; + } + + versions.sort((a, b) -> b.compareTo(a)); + return versions; + } + + private String askNeoForgeVersion(String mcVersion) throws Exception { + System.out.println(ZAnsi.cyan("Fetching NeoForge versions for " + mcVersion + "...")); + + List allNeoForgeVersions = getAllNeoForgeVersions(); + + List compatibleVersions = allNeoForgeVersions.stream() + .filter(v -> isNeoForgeVersionCompatible(v, mcVersion)) + .collect(Collectors.toList()); + + if (compatibleVersions.isEmpty()) { + System.out.println(ZAnsi.yellow("No compatible NeoForge versions found for " + mcVersion)); + ConsoleUtils.pause(); + return null; + } + + List options = compatibleVersions.stream() + .limit(30) + .map(v -> "NeoForge " + v) + .collect(Collectors.toList()); + options.add("Back"); + + ArrowMenu menu = new ArrowMenu("Select NeoForge version for " + mcVersion, options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return null; + + return compatibleVersions.get(choice); + } + + private boolean isNeoForgeVersionCompatible(String version, String mcVersion) { + if (mcVersion.equals("1.20.1")) { + return version.startsWith("47."); + } + String majorMinor = mcVersion.replace("1.", ""); + String[] parts = majorMinor.split("\\."); + int targetMajor = Integer.parseInt(parts[0]); + return version.startsWith(targetMajor + "."); + } + + private List getAllNeoForgeVersions() throws Exception { + List versions = new ArrayList<>(); + + String[] mavenUrls = { + "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml", + "https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml" + }; + + for (String mavenUrl : mavenUrls) { + try { + String xml = ZHttpClient.downloadString(mavenUrl); + int index = 0; + while ((index = xml.indexOf("", index)) != -1) { + int start = index + 9; + int end = xml.indexOf("", start); + if (end == -1) break; + + String version = xml.substring(start, end).trim(); + if (!versions.contains(version)) { + versions.add(version); + } + index = end; + } + } catch (Exception e) { + } + } + + versions.sort((a, b) -> b.compareTo(a)); + return versions; + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LoginMenu.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LoginMenu.java new file mode 100644 index 0000000..13b558d --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LoginMenu.java @@ -0,0 +1,186 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.auth.AuthManager.AuthResult; +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.Input; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; + +import java.io.IOException; +import java.util.List; + +public class LoginMenu { + + public boolean show() throws IOException { + while (true) { + ConsoleUtils.clearScreen(); + printBanner(); + + List options = List.of( + "Sign In", + "Create Account", + "Exit Launcher" + ); + + ArrowMenu menu = new ArrowMenu("Welcome to ZernMC!", options); + int choice = menu.show(); + + if (choice == -1 || choice == 2) return false; + + boolean success = switch (choice) { + case 0 -> doLogin(); + case 1 -> doRegister(); + default -> false; + }; + + if (success) return true; + } + } + + public void showAccountMenu() throws IOException { + ConsoleUtils.clearScreen(); + + System.out.println(ZAnsi.header("=== Account ===")); + System.out.println(); + System.out.println(ZAnsi.white(" Player: ") + ZAnsi.brightGreen(AuthManager.getUsername())); + System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid())); + System.out.println(); + + List options = List.of( + "Log Out", + "Back" + ); + + ArrowMenu menu = new ArrowMenu("Account Management", options); + int choice = menu.show(); + + if (choice == 0) { + AuthManager.logout(); + System.out.println(ZAnsi.yellow("Logged out.")); + ConsoleUtils.pause(); + } + } + + private boolean doLogin() throws IOException { + ConsoleUtils.clearScreen(); + printBanner(); + System.out.println(ZAnsi.cyan(" [ Sign In ]")); + System.out.println(); + + String username = Input.readLine(ZAnsi.white(" Username: ")); + if (username.isEmpty()) return false; + + String password = readPassword(" Password: "); + if (password.isEmpty()) return false; + + System.out.println(); + System.out.print(ZAnsi.cyan(" Signing in...")); + + AuthResult result = AuthManager.login(username, password); + + if (result.success) { + System.out.println("\r" + ZAnsi.brightGreen(" Welcome, " + AuthManager.getUsername() + "! ")); + ConsoleUtils.pause(); + return true; + } else { + System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " ")); + ConsoleUtils.pause(); + return false; + } + } + + private boolean doRegister() throws IOException { + ConsoleUtils.clearScreen(); + printBanner(); + System.out.println(ZAnsi.cyan(" [ Create Account ]")); + System.out.println(); + System.out.println(ZAnsi.yellow(" Allowed characters: a-z, A-Z, 0-9, _")); + System.out.println(ZAnsi.yellow(" Name length: 3-16 chars | Password length: 6+ chars")); + System.out.println(); + + String username = Input.readLine(ZAnsi.white(" Username: ")); + if (username.isEmpty()) return false; + + String password = readPassword(" Password: "); + if (password.isEmpty()) return false; + + String confirm = readPassword(" Confirm password: "); + if (!password.equals(confirm)) { + System.out.println(ZAnsi.brightRed("\n Passwords do not match!")); + ConsoleUtils.pause(); + return false; + } + + System.out.println(); + System.out.print(ZAnsi.cyan(" Creating account...")); + + AuthResult result = AuthManager.register(username, password); + + if (result.success) { + System.out.println("\r" + ZAnsi.brightGreen(" Account created! Welcome, " + AuthManager.getUsername() + "! ")); + ConsoleUtils.pause(); + return true; + } else { + System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " ")); + ConsoleUtils.pause(); + return false; + } + } + + private String readPassword(String prompt) throws IOException { + org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder() + .system(true) + .jna(true) + .build(); + + passTerminal.enterRawMode(); + passTerminal.writer().print(prompt); + passTerminal.writer().flush(); + + StringBuilder password = new StringBuilder(); + + try { + while (true) { + int key = passTerminal.reader().read(); + + if (key == 27) { + int next = passTerminal.reader().read(); + if (next == 91) { + passTerminal.reader().read(); + } + continue; + } + + if (key == 13 || key == 10) { + passTerminal.writer().println(); + break; + } else if (key == 127 || key == 8) { + if (password.length() > 0) { + password.setLength(password.length() - 1); + passTerminal.writer().print("\b \b"); + passTerminal.writer().flush(); + } + } else if (key == 3) { + passTerminal.writer().println(); + System.exit(0); + } else if (key >= 32 && key < 127) { + password.append((char) key); + passTerminal.writer().print('*'); + passTerminal.writer().flush(); + } + } + } finally { + passTerminal.close(); + } + + return password.toString(); + } + + private void printBanner() { + System.out.println(ZAnsi.header("╔══════════════════════════════╗")); + System.out.println(ZAnsi.header("║ ZernMC Launcher ║")); + System.out.println(ZAnsi.header("╚══════════════════════════════╝")); + System.out.println(); + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java new file mode 100644 index 0000000..611b505 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java @@ -0,0 +1,141 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; + +public class ServerCheckMenu { + + public void show() throws IOException { + while (true) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("Connection Diagnostics")); + + List options = List.of( + "Check ZernMC server connection", + "Check Mojang (Minecraft) access", + "Check Fabric Meta access", + "Check Forge Maven access", + "Back to main menu" + ); + + ArrowMenu menu = new ArrowMenu("Select check", options); + int choice = menu.show(); + + if (choice == -1 || choice == 4) { + return; + } + + ConsoleUtils.clearScreen(); + + switch (choice) { + case 0 -> checkZernServer(); + case 1 -> checkMojang(); + case 2 -> checkFabric(); + case 3 -> checkForge(); + } + + ConsoleUtils.pause(); + } + } + + private void checkZernServer() { + System.out.println(ZAnsi.cyan("Checking connection to ZernMC server...")); + + try { + String response = ZHttpClient.get("/health"); + System.out.println(ZAnsi.brightGreen("[OK] ZernMC server connected successfully!")); + System.out.println(ZAnsi.white("Server response: ") + response); + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("[FAIL] Could not connect to ZernMC server")); + System.out.println(ZAnsi.white("Error: ") + e.getMessage()); + } + } + + private void checkMojang() { + System.out.println(ZAnsi.cyan("Checking Mojang access...")); + + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + System.out.println(ZAnsi.brightGreen("[OK] Mojang is accessible")); + } else { + System.out.println(ZAnsi.brightRed("[FAIL] Mojang returned code " + response.statusCode())); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Mojang")); + System.out.println(ZAnsi.white("Error: ") + e.getMessage()); + } + } + + private void checkFabric() { + System.out.println(ZAnsi.cyan("Checking Fabric Meta access...")); + + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://meta.fabricmc.net/v2/versions")) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta is accessible")); + } else { + System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta returned code " + response.statusCode())); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Fabric Meta")); + System.out.println(ZAnsi.white("Error: ") + e.getMessage()); + } + } + + private void checkForge() { + System.out.println(ZAnsi.cyan("Checking Forge Maven access...")); + + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml")) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + System.out.println(ZAnsi.brightGreen("[OK] Forge Maven is accessible")); + } else { + System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven returned code " + response.statusCode())); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Forge Maven")); + System.out.println(ZAnsi.white("Error: ") + e.getMessage()); + } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/SettingsMenu.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/SettingsMenu.java new file mode 100644 index 0000000..c9e705b --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/SettingsMenu.java @@ -0,0 +1,68 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.Input; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; + +import java.io.IOException; +import java.util.List; + +public class SettingsMenu { + + public void show() throws IOException { + List options = List.of( + "Configure Java path", + "Configure allocated RAM", + "Additional JVM parameters", + "Back to main menu" + ); + + ArrowMenu menu = new ArrowMenu("Launcher Settings", options); + int choice = menu.show(); + + if (choice == -1 || choice == 3) return; + + ConsoleUtils.clearScreen(); + + switch (choice) { + case 0 -> configureJava(); + case 1 -> configureRam(); + case 2 -> configureJvmArgs(); + } + + ConsoleUtils.pause(); + } + + private void configureJava() { + System.out.println(ZAnsi.cyan("Java path:")); + System.out.println(" " + Config.getJreDir().toAbsolutePath()); + System.out.println(ZAnsi.white("\nJava will be searched automatically in ~/.zernmc/jre/")); + System.out.println("If needed, place your own Java version there."); + } + + private void configureRam() { + System.out.println(ZAnsi.cyan("RAM Allocation")); + System.out.println(Config.getRamInfo()); + + int newRam = Input.readInt( + ZAnsi.white("\nEnter new RAM value in MB (or 0 to cancel): "), + 0, 32768 + ); + + if (newRam == 0) { + System.out.println(ZAnsi.yellow("Setting cancelled.")); + return; + } + + Config.setMaxMemory(newRam); + System.out.println(ZAnsi.brightGreen("Allocated RAM changed to " + newRam + " MB")); + } + + private void configureJvmArgs() { + System.out.println(ZAnsi.yellow("Additional JVM parameters")); + System.out.println("Currently in development."); + System.out.println("A list of preset optimizations will be available in the future."); + } +} diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/UpdateMenu.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/UpdateMenu.java similarity index 61% rename from launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/UpdateMenu.java rename to launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/UpdateMenu.java index 41f8b20..02e3e45 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/UpdateMenu.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/UpdateMenu.java @@ -18,12 +18,12 @@ public class UpdateMenu { public void show() throws IOException { List options = List.of( - "Проверить обновления сборки (модпака)", - "Проверить обновления лаунчера", - "Назад в главное меню" + "Check pack updates", + "Check launcher updates", + "Back to main menu" ); - ArrowMenu menu = new ArrowMenu("Проверка обновлений", options); + ArrowMenu menu = new ArrowMenu("Update Check", options); int choice = menu.show(); if (choice == -1 || choice == 2) return; @@ -34,7 +34,7 @@ public class UpdateMenu { try { checkPackUpdates(); } catch (Exception e) { - System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage())); + System.out.println(ZAnsi.brightRed("Error: " + e.getMessage())); e.printStackTrace(); ConsoleUtils.pause(); } @@ -44,7 +44,7 @@ public class UpdateMenu { } private void checkPackUpdates() throws Exception { - System.out.println(ZAnsi.cyan("Проверка обновлений сборок...")); + System.out.println(ZAnsi.cyan("Checking pack updates...")); List instances = InstanceManager.getAllInstances(); List serverInstances = instances.stream() @@ -52,12 +52,12 @@ public class UpdateMenu { .collect(Collectors.toList()); if (serverInstances.isEmpty()) { - System.out.println(ZAnsi.yellow("Нет сборок, установленных с сервера.")); + System.out.println(ZAnsi.yellow("No server-installed packs found.")); ConsoleUtils.pause(); return; } - System.out.println(ZAnsi.cyan("\nПроверка обновлений для серверных сборок:\n")); + System.out.println(ZAnsi.cyan("\nChecking updates for server packs:\n")); boolean hasUpdates = false; List updatableInstances = new ArrayList<>(); @@ -68,42 +68,41 @@ public class UpdateMenu { try { boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); if (hasUpdate) { - System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!")); + System.out.println(ZAnsi.yellow(instance.getName() + " - Update available!")); updatableInstances.add(instance); hasUpdates = true; } else { - System.out.println(ZAnsi.green(instance.getName() + " - Актуальна")); + System.out.println(ZAnsi.green(instance.getName() + " - Up to date")); } } catch (Exception e) { - System.out.println(ZAnsi.red(instance.getName() + " - Ошибка проверки: " + e.getMessage())); + System.out.println(ZAnsi.red(instance.getName() + " - Check error: " + e.getMessage())); } } if (!hasUpdates) { - System.out.println(ZAnsi.green("\nВсе сборки актуальны!")); + System.out.println(ZAnsi.green("\nAll packs are up to date!")); ConsoleUtils.pause(); return; } - // Предлагаем обновить каждую сборку отдельно for (Instance instance : updatableInstances) { - System.out.println(ZAnsi.brightYellow("\nОбновить сборку '" + instance.getName() + "'?")); - if (Input.confirm("Обновить")) { - System.out.println(ZAnsi.cyan("Обновление " + instance.getName() + "...")); + System.out.println(ZAnsi.brightYellow("\nUpdate pack '" + instance.getName() + "'?")); + if (Input.confirm("Update")) { + System.out.println(ZAnsi.cyan("Updating " + instance.getName() + "...")); PackDownloader downloader = new PackDownloader(instance); try { boolean success = downloader.updatePack(instance.getServerPackName()); if (success) { - System.out.println(ZAnsi.brightGreen(instance.getName() + " обновлен")); + System.out.println(ZAnsi.brightGreen(instance.getName() + " updated")); } else { - System.out.println(ZAnsi.brightRed(instance.getName() + " не удалось обновить")); + System.out.println(ZAnsi.brightRed(instance.getName() + " update failed")); } } catch (Exception e) { System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage())); } } else { - System.out.println(ZAnsi.yellow(" Пропущено: " + instance.getName())); + System.out.println(ZAnsi.yellow(" Skipped: " + instance.getName())); } } @@ -111,28 +110,27 @@ public class UpdateMenu { } private void checkLauncherUpdates() { - System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера...")); + System.out.println(ZAnsi.cyan("Checking launcher updates...")); try { String json = ZHttpClient.getLauncherVersionInfo(); String serverVersion = extractVersion(json); String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion(); - System.out.println(ZAnsi.white("Текущая версия: ") + currentVersion); - System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion); + System.out.println(ZAnsi.white("Current version: ") + currentVersion); + System.out.println(ZAnsi.white("Server version: ") + serverVersion); if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) { - System.out.println(ZAnsi.brightYellow("\nДоступна новая версия!")); - if (Input.confirm("Обновить лаунчер?")) { - // Обновление будет при следующем запуске - System.out.println(ZAnsi.green("Лаунчер будет обновлен при следующем запуске.")); + System.out.println(ZAnsi.brightYellow("\nNew version available!")); + if (Input.confirm("Update launcher?")) { + System.out.println(ZAnsi.green("Launcher will be updated on next restart.")); } } else { - System.out.println(ZAnsi.brightGreen("Лаунчер актуален.")); + System.out.println(ZAnsi.brightGreen("Launcher is up to date.")); } } catch (Exception e) { - System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера.")); - System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); + System.out.println(ZAnsi.yellow("Could not check launcher updates.")); + System.out.println(ZAnsi.white("Error: ") + e.getMessage()); } ConsoleUtils.pause(); @@ -149,4 +147,4 @@ public class UpdateMenu { return "unknown"; } } -} \ No newline at end of file +} diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java similarity index 88% rename from launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java rename to launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java index 86be644..64b2f65 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java @@ -1,5 +1,7 @@ package me.sashegdev.zernmc.launcher.minecraft; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; + import com.google.gson.Gson; import com.google.gson.GsonBuilder; import java.io.IOException; @@ -14,13 +16,14 @@ public class Instance { private final Path path; private String minecraftVersion; - private String loaderType; // vanilla, fabric, forge + private String loaderType; // vanilla, fabric, forge, neoforge private String loaderVersion; private String assetIndex; private boolean isServerPack; // флаг, что это сборка с сервера private int serverVersion; // версия сборки на сервере private String serverPackName; // имя пака на сервере private String fabricVersionId; + private String presetId; private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @@ -88,6 +91,15 @@ public class Instance { saveMetadata(); } + public String getPresetId() { + return presetId; + } + + public void setPresetId(String presetId) { + this.presetId = presetId; + saveMetadata(); + } + public String getServerPackName() { return serverPackName; } @@ -130,14 +142,17 @@ public class Instance { this.isServerPack = meta.isServerPack; this.serverVersion = meta.serverVersion; this.serverPackName = meta.serverPackName; - } catch (Exception ignored) {} + this.presetId = meta.presetId; + } catch (Exception e) { + LauncherLogger.warn("[Instance] Failed to load metadata for " + name + ": " + e.getMessage()); + } } private void saveMetadata() { Path metaFile = path.resolve("instance.json"); InstanceMeta meta = new InstanceMeta( minecraftVersion, loaderType, loaderVersion, assetIndex, - isServerPack, serverVersion, serverPackName + isServerPack, serverVersion, serverPackName, presetId ); try { Files.writeString(metaFile, GSON.toJson(meta)); @@ -154,12 +169,13 @@ public class Instance { boolean isServerPack = false; int serverVersion = 0; String serverPackName; + String presetId; public InstanceMeta(String minecraftVersion, String loaderType, String loaderVersion, String assetIndex, boolean isServerPack, int serverVersion, - String serverPackName) { + String serverPackName, String presetId) { this.minecraftVersion = minecraftVersion; this.loaderType = loaderType; this.loaderVersion = loaderVersion; @@ -167,6 +183,7 @@ public class Instance { this.isServerPack = isServerPack; this.serverVersion = serverVersion; this.serverPackName = serverPackName; + this.presetId = presetId; } } } \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/InstanceManager.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/InstanceManager.java similarity index 93% rename from launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/InstanceManager.java rename to launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/InstanceManager.java index 9d16fbe..ff83b5a 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/InstanceManager.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/InstanceManager.java @@ -1,5 +1,7 @@ package me.sashegdev.zernmc.launcher.minecraft; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; + import me.sashegdev.zernmc.launcher.utils.Config; import java.io.IOException; @@ -51,7 +53,7 @@ public class InstanceManager { try { Files.deleteIfExists(path); } catch (IOException e) { - System.err.println("Не удалось удалить: " + path); + LauncherLogger.warn("Не удалось удалить: " + path); } }); return true; diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java new file mode 100644 index 0000000..748fe6d --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java @@ -0,0 +1,274 @@ +package me.sashegdev.zernmc.launcher.minecraft; + +import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller; +import me.sashegdev.zernmc.launcher.minecraft.installer.ModLoaderInstaller; +import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; +import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; +import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; + +public class MinecraftLib { + + private final Instance instance; + + public MinecraftLib(Instance instance) { + this.instance = instance; + } + + //Установка + public boolean installMinecraft(String versionId) throws Exception { + VersionInstaller installer = new VersionInstaller(instance.getPath()); + + String assetIndex = installer.install(versionId); // ← теперь возвращается String + + if (assetIndex != null && !assetIndex.isEmpty()) { + instance.setMinecraftVersion(versionId); + instance.setAssetIndex(assetIndex); // ← сохраняем правильный индекс! + instance.setLoaderType("vanilla"); + return true; + } + return false; + } + + public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception { + return new ModLoaderInstaller(instance) + .install(minecraftVersion, forgeVersion, ModLoaderInstaller.LoaderType.FORGE); + } + + public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception { + return new ModLoaderInstaller(instance) + .install(minecraftVersion, neoforgeVersion, ModLoaderInstaller.LoaderType.NEOFORGE); + } + + public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception { + FabricInstaller installer = new FabricInstaller(instance); + boolean success = installer.install(minecraftVersion, loaderVersion); + + if (success) { + // Save info to Instance + instance.setMinecraftVersion(minecraftVersion); + instance.setLoaderType("fabric"); + instance.setLoaderVersion(loaderVersion); + } + return success; + } + + /** + * Full pack install (vanilla + loader + mods) + * Stub - will be expanded + */ + public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception { + LauncherLogger.info("Starting full pack install: " + packName); + + // 1. Install Minecraft + boolean mcInstalled = installMinecraft(minecraftVersion); + if (!mcInstalled) { + LauncherLogger.error("Failed to install Minecraft " + minecraftVersion); + return false; + } + + // 2. Install loader + if ("fabric".equalsIgnoreCase(loaderType)) { + boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion); + if (!fabricInstalled) { + LauncherLogger.error("Failed to install Fabric"); + return false; + } + } else if ("forge".equalsIgnoreCase(loaderType)) { + boolean forgeInstalled = installForge(minecraftVersion, loaderVersion); + if (!forgeInstalled) { + LauncherLogger.error("Failed to install Forge"); + return false; + } + } else if ("neoforge".equalsIgnoreCase(loaderType)) { + boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion); + if (!neoforgeInstalled) { + LauncherLogger.error("Failed to install NeoForge"); + return false; + } + } + + // 3. In the future: diff and mod download + + LauncherLogger.info("Basic pack install complete!"); + return true; + } + + //Launch + public void launch(LaunchOptions options) throws Exception { + LauncherLogger.info("Launching pack: " + instance.getName()); + cleanupOldLoaders(); + validateJarFiles(); + + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + List command = builder.build(options); + + LauncherLogger.info("Launch command (" + command.size() + " args) written below"); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(instance.getPath().toFile()); + + // Write launch command to file for debugging + Path cmdLogFile = instance.getPath().resolve("launch-command.log"); + try { + Files.writeString(cmdLogFile, String.join(" \\\n ", command)); + LauncherLogger.info("Launch command written to " + cmdLogFile.toAbsolutePath()); + } catch (Exception e) { + LauncherLogger.warn("Failed to write launch command log: " + e.getMessage()); + } + + LauncherLogger.info("Starting Minecraft..."); + ConsoleUtils.clearScreen(); + + Process process = pb.start(); + + // Capture output + Thread outThread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + JFXBridge.appendGameLog(line); + } + } catch (Exception e) { + JFXBridge.appendGameLog("[Error reading output: " + e.getMessage() + "]"); + } + }); + outThread.setDaemon(true); + outThread.start(); + + // Capture errors + Thread errThread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = reader.readLine()) != null) { + JFXBridge.appendGameLog("[ERR] " + line); + } + } catch (Exception e) { + JFXBridge.appendGameLog("[Error reading stderr: " + e.getMessage() + "]"); + } + }); + errThread.setDaemon(true); + errThread.start(); + + int exitCode = process.waitFor(); + outThread.join(1000); + errThread.join(1000); + + LauncherLogger.info("Minecraft exited with code: " + exitCode); + } + + private void safeDeleteDirectory(Path dir) { + try (var stream = Files.walk(dir)) { + stream.sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException e) { /* ignore */ } + }); + } catch (IOException e) { + LauncherLogger.warn("safeDeleteDirectory: " + e.getMessage()); + } + } + + private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException { + if (!Files.exists(versionsDir)) return; + + try (var stream = Files.walk(versionsDir)) { + stream.filter(Files::isDirectory) + .filter(dir -> dir.getFileName().toString().contains("fabric-loader") || + dir.getFileName().toString().contains("forge") || + dir.getFileName().toString().contains("neoforge")) + .filter(dir -> !dir.getFileName().toString().contains(keepVersion)) + .forEach(this::safeDeleteDirectory); + } + } + + private void deleteAllExcept(Path baseDir, String keepVersion) throws IOException { + if (!Files.exists(baseDir)) return; + + try (var stream = Files.walk(baseDir)) { + stream.filter(Files::isDirectory) + .filter(dir -> { + String name = dir.getFileName().toString(); + return name.contains(".") && !name.contains(keepVersion); + }) + .forEach(this::safeDeleteDirectory); + } + } + + private void cleanupOldLoaders() throws IOException { + String loaderType = instance.getLoaderType().toLowerCase(); + String currentLoaderVer = instance.getLoaderVersion(); + + if (currentLoaderVer == null) return; + + LauncherLogger.info("Cleaning old loader versions..."); + + // Delete all old fabric-loader / forge + Path libraries = instance.getPath().resolve("libraries"); + + if ("fabric".equals(loaderType)) { + deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer); + } else if ("forge".equals(loaderType)) { + deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer); + } else if ("neoforge".equals(loaderType)) { + deleteAllExcept(libraries.resolve("net/neoforged/neoforge"), currentLoaderVer); + } + + // Также чистим versions/ от старых fabric/forge версий + Path versionsDir = instance.getPath().resolve("versions"); + deleteOldVersionDirs(versionsDir, currentLoaderVer); + } + + + + private void validateJarFiles() { + Path[] dirs = { + instance.getPath().resolve("mods"), + instance.getPath().resolve("libraries") + }; + for (Path dir : dirs) { + if (!Files.exists(dir)) continue; + try (var stream = Files.walk(dir)) { + stream.filter(p -> p.toString().endsWith(".jar")) + .filter(p -> !isValidJar(p)) + .forEach(p -> { + try { + LauncherLogger.warn("Removing corrupt JAR: " + instance.getPath().relativize(p)); + Files.delete(p); + } catch (IOException e) { + LauncherLogger.error("Failed to delete: " + p.getFileName()); + } + }); + } catch (Exception e) { + LauncherLogger.warn("Error scanning for corrupt JARs: " + e.getMessage()); + } + } + } + + private boolean isValidJar(Path path) { + try (ZipFile zf = new ZipFile(path.toFile())) { + return true; + } catch (ZipException e) { + return false; + } catch (IOException e) { + return false; + } + } + + public Instance getInstance() { + return instance; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java new file mode 100644 index 0000000..e6afbef --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java @@ -0,0 +1,836 @@ +package me.sashegdev.zernmc.launcher.minecraft; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +public class PackDownloader { + + @FunctionalInterface + public interface ProgressCallback { + void onProgress(String label, int percent, String stageName, int stageIndex, int stageCount); + } + + private static final String USER_FILES_FILE = ".user-files.json"; + + private final Instance instance; + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private ProgressCallback progressCallback; + //private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + public PackDownloader(Instance instance) { + this.instance = instance; + } + + public void setProgressCallback(ProgressCallback callback) { + this.progressCallback = callback; + } + + private void reportProgress(String label, int percent, String stageName, int stageIndex, int stageCount) { + if (progressCallback != null) { + try { + progressCallback.onProgress(label, percent, stageName, stageIndex, stageCount); + } catch (Exception ignored) {} + } + } + + /** + * Get list of available packs from server + */ + public List getAvailablePacks() throws Exception { + String accessToken = AuthManager.getAccessToken(); + if (accessToken == null) { + throw new IOException("Not authenticated. Active pass required to view packs."); + } + if (!AuthManager.canViewPacks()) { + throw new IOException("Active pass required to view packs"); + } + + // Use HttpURLConnection for GET with auth + java.net.HttpURLConnection connection = null; + try { + java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs"); + connection = (java.net.HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + accessToken); + connection.setConnectTimeout(15000); + connection.setReadTimeout(15000); + + int responseCode = connection.getResponseCode(); + + if (responseCode == 403) { + throw new IOException("Active pass required to view packs"); + } + + StringBuilder response = new StringBuilder(); + try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); + java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + + if (responseCode != 200) { + throw new IOException("HTTP " + responseCode); + } + + return parsePacksResponse(response.toString()); + + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private List parsePacksResponse(String responseBody) { + JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject(); + JsonArray packsArray = root.getAsJsonArray("packs"); + List result = new ArrayList<>(); + + for (JsonElement elem : packsArray) { + JsonObject pack = elem.getAsJsonObject(); + + if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) { + continue; + } + + try { + String name = pack.get("name").getAsString(); + int version = pack.has("version") ? pack.get("version").getAsInt() : 0; + String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown"; + String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla"; + String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() + ? pack.get("loader_version").getAsString() : ""; + int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0; + + LocalDateTime updatedAt = null; + if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) { + try { + updatedAt = LocalDateTime.parse(pack.get("updated_at").getAsString(), + DateTimeFormatter.ISO_DATE_TIME); + } catch (Exception ignored) {} + } + + result.add(new ServerPack(name, version, minecraftVersion, loaderType, + loaderVersion, updatedAt, filesCount)); + } catch (Exception e) { + LauncherLogger.warn("Error parsing pack: " + e.getMessage()); + } + } + + return result; + } + + /** + * Get pack manifest + */ + public PackManifest getPackManifest(String packName) throws Exception { + String response = ZHttpClient.get("/pack/" + packName); + return gson.fromJson(response, PackManifest.class); + } + + /** + * Install or update a pack from the server + */ + public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception { + return installOrUpdatePack(packName, serverPack, null); + } + + public boolean installOrUpdatePack(String packName, ServerPack serverPack, String preset) throws Exception { + LauncherLogger.info("installOrUpdatePack: START packName=" + packName + " mc=" + serverPack.getMinecraftVersion() + " loader=" + serverPack.getLoaderType() + " preset=" + (preset != null ? preset : "none")); + reportProgress("Fetching manifest...", 0, "manifest", 0, 5); + + // 1. Get manifest + PackManifest manifest = getPackManifest(packName); + LauncherLogger.info("installOrUpdatePack: manifest loaded version=" + manifest.getVersion() + " mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderType() + " files=" + (manifest.files == null ? "null" : manifest.files.size())); + + // 2. First install Minecraft + Loader via MinecraftLib + MinecraftLib lib = new MinecraftLib(instance); + + LauncherLogger.info("installOrUpdatePack: checking Minecraft install. currentMc=" + instance.getMinecraftVersion() + " targetMc=" + manifest.getMinecraftVersion()); + + boolean needsMinecraftInstall = instance.getMinecraftVersion() == null || + !instance.getMinecraftVersion().equals(manifest.getMinecraftVersion()); + + if (needsMinecraftInstall) { + LauncherLogger.info("installOrUpdatePack: needs Minecraft install. loader=" + manifest.getLoaderType() + " loaderVer=" + manifest.getLoaderVersion()); + reportProgress("Installing " + manifest.getMinecraftVersion() + " with " + manifest.getLoaderType() + "...", 10, "Installing pack files", 4, 5); + if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) { + LauncherLogger.info("installOrUpdatePack: installing Fabric mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion()); + reportProgress("Installing Fabric " + manifest.getLoaderVersion() + "...", 15, "Installing pack files", 4, 5); + boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); + LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success); + if (!success) { + LauncherLogger.error("[INSTALL] Failed to install Fabric"); + reportProgress("Failed to install Fabric", 0, "error", 0, 1); + return false; + } + } else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) { + LauncherLogger.info("installOrUpdatePack: installing NeoForge mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion()); + reportProgress("Installing NeoForge " + manifest.getLoaderVersion() + "...", 15, "Installing pack files", 4, 5); + boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); + LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success); + if (!success) { + LauncherLogger.error("[INSTALL] Failed to install NeoForge"); + reportProgress("Failed to install NeoForge", 0, "error", 0, 1); + return false; + } + } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { + LauncherLogger.info("installOrUpdatePack: installing Forge mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion()); + reportProgress("Installing Forge " + manifest.getLoaderVersion() + "...", 15, "Installing pack files", 4, 5); + boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); + LauncherLogger.info("installOrUpdatePack: Forge install result=" + success); + if (!success) { + LauncherLogger.error("[INSTALL] Failed to install Forge"); + reportProgress("Failed to install Forge", 0, "error", 0, 1); + return false; + } + } else { + LauncherLogger.info("installOrUpdatePack: installing Vanilla Minecraft " + manifest.getMinecraftVersion()); + reportProgress("Installing Vanilla Minecraft...", 15, "Installing pack files", 4, 5); + boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); + LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success); + if (!success) { + LauncherLogger.error("[INSTALL] Failed to install Vanilla Minecraft"); + reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1); + return false; + } + } + LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully"); + } else { + LauncherLogger.info("Minecraft already installed, skipping..."); + } + + reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5); + + // 3. Scan local files only if there are files to download + LauncherLogger.info("installOrUpdatePack: scanning local files..."); + Map localFiles = scanLocalFiles(); + LauncherLogger.info("installOrUpdatePack: scanLocalFiles returned " + localFiles.size() + " files. instancePath=" + instance.getPath()); + + // If pack has no files (vanilla/loader only), skip diff + if (manifest.files == null || manifest.files.isEmpty()) { + LauncherLogger.info("installOrUpdatePack: no files in manifest, skipping diff"); + LauncherLogger.info("Pack contains no additional files"); + reportProgress("Installing...", 50, "Installing pack files", 4, 5); + + saveUserFilesSnapshot(localFiles); + + instance.setServerPack(true); + instance.setServerPackName(packName); + instance.setServerVersion(manifest.getVersion()); + instance.setMinecraftVersion(manifest.getMinecraftVersion()); + instance.setLoaderType(manifest.getLoaderType()); + instance.setLoaderVersion(manifest.getLoaderVersion()); + instance.setAssetIndex(resolveAssetIndex(manifest)); + + reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5); + LauncherLogger.info("Pack installed successfully!"); + return true; + } + LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff"); + + // 4. Send diff request + LauncherLogger.info("installOrUpdatePack: requesting diff from server..."); + reportProgress("Checking pack files...", 40, "Installing pack files", 4, 5); + DiffResponse diff = null; + try { + diff = getDiff(packName, localFiles, preset); + LauncherLogger.info("installOrUpdatePack: diff received: toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " version=" + diff.getVersion()); + } catch (Exception e) { + LauncherLogger.error("installOrUpdatePack: getDiff failed: " + e.getClass().getName() + " - " + e.getMessage()); + e.printStackTrace(); + throw e; + } + + // 5. Detect user-modified files + Set protectedFiles = getUserModifiedFiles(localFiles); + LauncherLogger.info("installOrUpdatePack: user-modified files count=" + protectedFiles.size()); + + // 6. Apply changes + LauncherLogger.info("installOrUpdatePack: starting applyDiff..."); + reportProgress("Downloading pack files...", 50, "Installing pack files", 4, 5); + boolean success = false; + try { + success = applyDiff(diff, packName, protectedFiles); + LauncherLogger.info("installOrUpdatePack: applyDiff returned=" + success); + } catch (Exception e) { + LauncherLogger.error("installOrUpdatePack: applyDiff threw exception: " + e.getClass().getName() + " - " + e.getMessage()); + e.printStackTrace(); + throw e; + } + + if (success) { + LauncherLogger.info("installOrUpdatePack: saving user files snapshot..."); + Map updatedHashes = scanLocalFiles(); + saveUserFilesSnapshot(updatedHashes); + + LauncherLogger.info("installOrUpdatePack: updating instance metadata..."); + instance.setServerPack(true); + instance.setServerPackName(packName); + instance.setServerVersion(manifest.getVersion()); + instance.setMinecraftVersion(manifest.getMinecraftVersion()); + instance.setLoaderType(manifest.getLoaderType()); + instance.setLoaderVersion(manifest.getLoaderVersion()); + instance.setAssetIndex(resolveAssetIndex(manifest)); + + LauncherLogger.info("Pack installed successfully!"); + LauncherLogger.info("installOrUpdatePack: SUCCESS"); + } else { + LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception"); + } + + return success; + } + + /** + * Check for server pack updates + */ + public boolean checkForUpdates(String packName) throws Exception { + if (!instance.isServerPack()) return false; + + PackManifest manifest = getPackManifest(packName); + int serverVersion = manifest.getVersion(); + int localVersion = instance.getServerVersion(); + + return serverVersion > localVersion; + } + + /** + * Update an existing server pack + */ + public boolean updatePack(String packName) throws Exception { + return updatePack(packName, instance.getPresetId()); + } + + public boolean updatePack(String packName, String preset) throws Exception { + LauncherLogger.info("Checking updates for " + instance.getName() + "..."); + + PackManifest manifest = getPackManifest(packName); + int serverVersion = manifest.getVersion(); + + if (serverVersion <= instance.getServerVersion()) { + LauncherLogger.info("Pack is already up to date (v" + instance.getServerVersion() + ")"); + return true; + } + + LauncherLogger.info("Update available: v" + instance.getServerVersion() + " -> v" + serverVersion); + + // Scan local files + Map localFiles = scanLocalFiles(); + + // Detect user-modified files (skip overwriting) + Set protectedFiles = getUserModifiedFiles(localFiles); + if (!protectedFiles.isEmpty()) { + LauncherLogger.info("[DEBUG] Found " + protectedFiles.size() + " user-modified files (will not overwrite)"); + LauncherLogger.info("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten"); + } + + // Get diff + DiffResponse diff = getDiff(packName, localFiles, preset); + + // Apply changes + boolean success = applyDiff(diff, packName, protectedFiles, preset); + + if (success) { + // Save new user files snapshot + Map updatedHashes = scanLocalFiles(); + saveUserFilesSnapshot(updatedHashes); + + instance.setServerVersion(serverVersion); + LauncherLogger.info("Pack updated to v" + serverVersion); + } + + return success; + } + + /** + * Scan local files and compute hashes + */ + private Map scanLocalFiles() throws IOException { + Map files = new HashMap<>(); + Path instancePath = instance.getPath(); + + // Игнорируемые директории + Set ignoredDirs = Set.of( + "resourcepacks", "shaderpacks", "saves", "logs", + "crash-reports", "screenshots", "journeymap", + "natives", "assets", "libraries", "versions", "cache" + ); + + if (!Files.exists(instancePath)) { + return files; + } + + Files.walk(instancePath) + .filter(Files::isRegularFile) + .forEach(file -> { + Path relative = instancePath.relativize(file); + String path = relative.toString().replace("\\", "/"); + + // Skip user files snapshot and ignored directories + if (path.equals(USER_FILES_FILE)) return; + for (String ignored : ignoredDirs) { + if (path.startsWith(ignored + "/") || path.startsWith(ignored + "\\")) { + return; + } + } + + try { + String hash = calculateHash(file); + files.put(path, hash); + } catch (Exception e) { + // Пропускаем файлы, которые не можем прочитать + } + }); + + return files; + } + + /** + * Send diff request to server + */ + private DiffResponse getDiff(String packName, Map localFiles) throws Exception { + return getDiff(packName, localFiles, null); + } + + private DiffResponse getDiff(String packName, Map localFiles, String preset) throws Exception { + String json = gson.toJson(localFiles); + + // Get auth token + String accessToken = AuthManager.getAccessToken(); + if (accessToken == null) { + throw new IOException("Not authenticated. Active pass required to download packs."); + } + if (!AuthManager.canDownloadPacks()) { + throw new IOException("Active pass required to download packs"); + } + + String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff"; + if (preset != null && !preset.isEmpty()) { + url += "?preset=" + java.net.URLEncoder.encode(preset, "UTF-8"); + } + LauncherLogger.info("getDiff: url=" + url + " bodySize=" + json.length() + " authToken=" + (accessToken != null ? "present" : "null") + " localFilesCount=" + json.length()); + + // Use HttpURLConnection for full control + java.net.HttpURLConnection connection = null; + try { + java.net.URL urlObj = new java.net.URL(url); + connection = (java.net.HttpURLConnection) urlObj.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + accessToken); + connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length)); + connection.setDoOutput(true); + connection.setConnectTimeout(30000); + connection.setReadTimeout(30000); + + // Send JSON + try (java.io.OutputStream os = connection.getOutputStream()) { + byte[] input = json.getBytes("UTF-8"); + os.write(input, 0, input.length); + os.flush(); + } + + int responseCode = connection.getResponseCode(); + LauncherLogger.info("getDiff: responseCode=" + responseCode + " packName=" + packName); + + // Read response + StringBuilder response = new StringBuilder(); + try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); + java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + + String responseBody = response.toString(); + LauncherLogger.info("[DEBUG] getDiff response body: " + responseBody.substring(0, Math.min(responseBody.length(), 500))); + + if (responseCode == 403) { + throw new IOException("Active pass required to download packs. Contact the administrator."); + } + + if (responseCode != 200) { + throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody)); + } + + DiffResponse result = gson.fromJson(responseBody, DiffResponse.class); + LauncherLogger.info("[DEBUG] getDiff parsed: toDownload=" + result.getToDownload().size() + " toDelete=" + result.getToDelete().size()); + return result; + + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private String extractErrorFromResponse(String body) { + try { + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + if (json.has("detail")) { + return json.get("detail").getAsString(); + } + } catch (Exception ignored) {} + return body.length() > 200 ? body.substring(0, 200) + "..." : body; + } + + /** + * Apply diff (download new files, delete old ones) + */ + private boolean applyDiff(DiffResponse diff, String packName) { + return applyDiff(diff, packName, new HashSet<>(), null); + } + + private boolean applyDiff(DiffResponse diff, String packName, Set protectedFiles) { + return applyDiff(diff, packName, protectedFiles, null); + } + + private boolean applyDiff(DiffResponse diff, String packName, Set protectedFiles, String preset) { + LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size()); + LauncherLogger.info("Apply changes: Download " + diff.getToDownload().size() + " files, Delete " + diff.getToDelete().size() + " files" + + (!protectedFiles.isEmpty() ? ", Protected " + protectedFiles.size() + " user-modified files (skipped)" : "")); + + // Create directories if needed + try { + Files.createDirectories(instance.getPath()); + } catch (IOException e) { + LauncherLogger.error("Error creating directories: " + e.getMessage()); + return false; + } + + // Delete files (skip protected) + for (String filePath : diff.getToDelete()) { + if (protectedFiles.contains(filePath)) { + LauncherLogger.info("Skipped delete (user-modified): " + filePath); + continue; + } + Path fullPath = instance.getPath().resolve(filePath); + try { + if (Files.deleteIfExists(fullPath)) { + LauncherLogger.info("Deleted: " + filePath); + } + } catch (IOException e) { + LauncherLogger.error("Error deleting " + filePath + ": " + e.getMessage()); + } + } + + // Download files (skip protected) + AtomicInteger downloaded = new AtomicInteger(0); + int total = diff.getToDownload().size(); + int skipped = 0; + long totalBytes = 0; + for (FileInfo f : diff.getToDownload()) totalBytes += f.getSize(); + AtomicLong downloadedBytes = new AtomicLong(0); + long downloadStartTime = System.currentTimeMillis(); + LauncherLogger.info("applyDiff: downloading " + total + " files, total size=" + totalBytes + " bytes"); + + for (FileInfo file : diff.getToDownload()) { + String path = file.getPath(); + + if (protectedFiles.contains(path)) { + skipped++; + LauncherLogger.info("Skipped (user-modified): " + path); + continue; + } + + Path fullPath = instance.getPath().resolve(path); + + try { + // Create directories + Files.createDirectories(fullPath.getParent()); + + // Download file + String fileUrl = ZHttpClient.getBaseUrl() + file.getUrl(); + if (preset != null && !preset.isEmpty() && !file.getUrl().contains("preset=")) { + String sep = file.getUrl().contains("?") ? "&" : "?"; + fileUrl = fileUrl + sep + "preset=" + java.net.URLEncoder.encode(preset, "UTF-8"); + } + LauncherLogger.info("applyDiff: downloading file=" + path + " url=" + fileUrl + " size=" + file.getSize() + " hash=" + file.getHash()); + downloadFile(file, fullPath); + + // Verify hash + String actualHash = calculateHash(fullPath); + boolean hashMatch = actualHash.equals(file.getHash()); + LauncherLogger.info("applyDiff: downloaded file=" + path + " hashMatch=" + hashMatch + " expected=" + file.getHash() + " got=" + actualHash); + if (!hashMatch) { + LauncherLogger.warn("applyDiff: hash mismatch for " + path + ", retrying once..."); + downloadFile(file, fullPath); + actualHash = calculateHash(fullPath); + hashMatch = actualHash.equals(file.getHash()); + LauncherLogger.info("applyDiff: retry result for " + path + " hashMatch=" + hashMatch); + } + if (!hashMatch) { + LauncherLogger.warn("applyDiff: skipping file " + path + " due to hash mismatch (expected=" + file.getHash() + " got=" + actualHash + ")"); + skipped++; + continue; + } + + downloaded.incrementAndGet(); + downloadedBytes.addAndGet(file.getSize()); + if (total > 0) { + ProgressBar.show("Download", downloaded.get(), total, "files"); + long elapsed = System.currentTimeMillis() - downloadStartTime; + long bytesSoFar = downloadedBytes.get(); + long remainingBytes = totalBytes - bytesSoFar; + int pct = totalBytes > 0 ? (int)(bytesSoFar * 45 / totalBytes) : (downloaded.get() * 45 / total); + pct = Math.min(pct, 45); + String speedStr = ""; + String etaStr = ""; + if (elapsed > 1000) { + long speedBps = bytesSoFar * 1000 / elapsed; + if (speedBps > 1_000_000) { + speedStr = String.format("%.1f MB/s", speedBps / 1_000_000.0); + } else if (speedBps > 1_000) { + speedStr = String.format("%.0f KB/s", speedBps / 1_000.0); + } else { + speedStr = speedBps + " B/s"; + } + if (speedBps > 0 && remainingBytes > 0) { + long etaSec = remainingBytes / speedBps; + if (etaSec > 3600) { + etaStr = String.format("%dh %dm", etaSec / 3600, (etaSec % 3600) / 60); + } else if (etaSec > 60) { + etaStr = String.format("%dm %ds", etaSec / 60, etaSec % 60); + } else { + etaStr = etaSec + "s"; + } + } + } + String label = "Downloading " + downloaded.get() + "/" + total + " files"; + if (!speedStr.isEmpty()) label += " (" + speedStr; + if (!etaStr.isEmpty()) label += " ETA: " + etaStr; + if (!speedStr.isEmpty()) label += ")"; + reportProgress(label, 50 + pct, "Installing pack files", 4, 5); + } + + } catch (Exception e) { + LauncherLogger.error("applyDiff: download FAILED for path=" + path + " error=" + e.getClass().getName() + ": " + e.getMessage()); + LauncherLogger.error("Download error " + path + ": " + e.getMessage() + " - skipping file"); + skipped++; + continue; + } + } + + if (total > 0) { + ProgressBar.finish("Download"); + } + if (skipped > 0) { + LauncherLogger.info("Skipped " + skipped + " user-modified files"); + } + + return true; + } + + /** + * Скачать один файл с сервера через временный файл для атомарности. + * Предотвращает появление битых файлов при обрыве соединения. + */ + private void downloadFile(FileInfo file, Path destination) throws Exception { + Path tmpFile = destination.resolveSibling(destination.getFileName() + ".tmp"); + try { + String url = ZHttpClient.getBaseUrl() + file.getUrl(); + ZHttpClient.downloadFileWithSmartProxy(url, tmpFile); + Files.move(tmpFile, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + try { Files.deleteIfExists(tmpFile); } catch (Exception ignored) {} + throw e; + } + } + + /** + * Вычисление SHA256 хеша файла + */ + private String calculateHash(Path file) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + try (InputStream in = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + digest.update(buffer, 0, bytesRead); + } + } + + byte[] hashBytes = digest.digest(); + StringBuilder sb = new StringBuilder(); + for (byte b : hashBytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + // ====================== User Files Protection ====================== + // Keeps a snapshot of file hashes after each install/update. + // On next update, files modified by the user are not overwritten. + + private Path getUserFilesPath() { + return instance.getPath().resolve(USER_FILES_FILE); + } + + private void saveUserFilesSnapshot(Map fileHashes) { + try { + Path path = getUserFilesPath(); + Files.createDirectories(path.getParent()); + Files.writeString(path, gson.toJson(fileHashes)); + } catch (Exception e) { + LauncherLogger.warn("Failed to save user files snapshot: " + e.getMessage()); + } + } + + private Map loadUserFilesSnapshot() { + Path path = getUserFilesPath(); + if (!Files.exists(path)) return new HashMap<>(); + try { + String content = Files.readString(path); + Map raw = gson.fromJson(content, Map.class); + Map result = new HashMap<>(); + if (raw != null) { + for (Map.Entry e : raw.entrySet()) { + result.put(e.getKey(), e.getValue() != null ? e.getValue().toString() : ""); + } + } + return result; + } catch (Exception e) { + LauncherLogger.warn("Failed to load user files snapshot: " + e.getMessage()); + return new HashMap<>(); + } + } + + private Set getUserModifiedFiles(Map currentHashes) { + Map snapshot = loadUserFilesSnapshot(); + if (snapshot.isEmpty()) return new HashSet<>(); + + Set modified = new HashSet<>(); + for (Map.Entry e : currentHashes.entrySet()) { + String path = e.getKey(); + String currentHash = e.getValue(); + String snapshotHash = snapshot.get(path); + if (snapshotHash != null && !snapshotHash.equals(currentHash)) { + modified.add(path); + } + } + return modified; + } + + // ====================== Вложенные классы ====================== + + public static class PackManifest { + private String pack_name; + private int version; + private String minecraft_version; + private String loader_type; + private String loader_version; + private String asset_index; + private Map files; + + public String getPackName() { return pack_name; } + public int getVersion() { return version; } + public String getMinecraftVersion() { return minecraft_version; } + public String getLoaderType() { return loader_type; } + public String getLoaderVersion() { return loader_version; } + public String getAssetIndex() { return asset_index != null ? asset_index : minecraft_version; } + public Map getFiles() { return files; } + public boolean isEmpty() { return files == null || files.isEmpty(); } + } + + /** + * Resolve the correct asset index for the installed Minecraft version. + * The manifest/instance.json asset_index value may be wrong (e.g. Zern-OBT + * shipped "1.20.1" while the real index for MC 1.20.1 is "5"). The source of + * truth is the local Mojang version JSON downloaded during install + * (versions/<mc>/<mc>.json -> assetIndex.id). We only trust it when the + * corresponding index file actually exists on disk; otherwise fall back to + * the manifest value. + */ + private String resolveAssetIndex(PackManifest manifest) { + String manifestIndex = manifest.getAssetIndex(); + if (manifest == null || manifest.getMinecraftVersion() == null) { + return manifestIndex; + } + try { + Path versionJson = instance.getPath().resolve("versions") + .resolve(manifest.getMinecraftVersion()) + .resolve(manifest.getMinecraftVersion() + ".json"); + if (Files.exists(versionJson)) { + JsonObject versionData = JsonParser.parseString(Files.readString(versionJson)).getAsJsonObject(); + String realIndex = null; + if (versionData.has("assetIndex") && versionData.get("assetIndex").isJsonObject() + && versionData.getAsJsonObject("assetIndex").has("id")) { + realIndex = versionData.getAsJsonObject("assetIndex").get("id").getAsString(); + } else if (versionData.has("assets") && !versionData.get("assets").isJsonNull()) { + realIndex = versionData.get("assets").getAsString(); + } + if (realIndex != null && !realIndex.isEmpty()) { + Path indexPath = instance.getPath().resolve("assets").resolve("indexes") + .resolve(realIndex + ".json"); + if (Files.exists(indexPath)) { + if (!realIndex.equals(manifestIndex)) { + LauncherLogger.warn("resolveAssetIndex: manifest asset_index=" + manifestIndex + + " but real index=" + realIndex + " exists on disk, using real index"); + } + return realIndex; + } else { + LauncherLogger.warn("resolveAssetIndex: real index=" + realIndex + + " from version JSON, but " + indexPath + " does not exist; falling back to manifest=" + manifestIndex); + } + } + } else { + LauncherLogger.warn("resolveAssetIndex: local version JSON not found: " + versionJson); + } + } catch (Exception e) { + LauncherLogger.warn("resolveAssetIndex: failed to resolve: " + e.getClass().getSimpleName() + " - " + e.getMessage()); + } + return manifestIndex; + } + + public static class DiffResponse { + private int version; + private List to_download; + private List to_delete; + private List to_update; + + public int getVersion() { return version; } + public List getToDownload() { return to_download != null ? to_download : new ArrayList<>(); } + public List getToDelete() { return to_delete != null ? to_delete : new ArrayList<>(); } + public List getToUpdate() { return to_update != null ? to_update : new ArrayList<>(); } + } + + public static class FileInfo { + private String path; + private String url; + private long size; + private String hash; + + public String getPath() { return path; } + public String getUrl() { return url; } + public long getSize() { return size; } + public String getHash() { return hash; } + } +} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/ServerPack.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/ServerPack.java similarity index 100% rename from launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/ServerPack.java rename to launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/ServerPack.java diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java new file mode 100644 index 0000000..faf4d27 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java @@ -0,0 +1,249 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.utils.JavaResolver; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +public class FabricInstaller { + + private final Instance instance; + + public FabricInstaller(Instance instance) { + this.instance = instance; + } + + public boolean install(String minecraftVersion, String loaderVersion) throws Exception { + LauncherLogger.info("Installing Fabric " + loaderVersion + " for Minecraft " + minecraftVersion); + + Path instancePath = instance.getPath(); + cleanOldFabricLoaders(); + + VersionInstaller versionInstaller = new VersionInstaller(instancePath); + String assetIndex = versionInstaller.install(minecraftVersion); + + LauncherLogger.info("Asset index obtained: " + assetIndex); + + instance.setAssetIndex(assetIndex); + instance.setMinecraftVersion(minecraftVersion); + + ProgressBar.showIndeterminate("Preparing Fabric installer"); + + String installerVersion = getLatestInstallerVersion(); + String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/" + + installerVersion + "/fabric-installer-" + installerVersion + ".jar"; + + Path installerJar = instancePath.resolve("fabric-installer.jar"); + + if (!Files.exists(installerJar)) { + ProgressBar.showIndeterminate("Downloading Fabric Installer"); + downloadFileWithFallback(installerUrl, installerJar); + ProgressBar.finish("Fabric Installer downloaded"); + } + + preDownloadFabricLibraries(minecraftVersion, loaderVersion); + + LauncherLogger.info("Running Fabric Installer..."); + ProgressBar.showIndeterminate("Running Fabric Installer (may take a few minutes)"); + + String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion; + + ProcessBuilder pb = new ProcessBuilder( + JavaResolver.getJavaExecutable(), "-jar", installerJar.toAbsolutePath().toString(), + "client", + "-dir", instancePath.toAbsolutePath().toString(), + "-mcversion", minecraftVersion, + "-loader", loaderVersion, + "-noprofile" + ); + + pb.redirectErrorStream(true); + + Process process = pb.start(); + + boolean hasErrors = false; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.contains("Downloading") || line.contains("Extracting") + || line.contains("Installing") || line.contains("Creating") + || line.contains("SUCCESS") || line.contains("successfully")) { + ProgressBar.showIndeterminate("Fabric Installer: " + ProgressBar.cleanLine(line)); + } + + if (line.contains("Downloading") || line.contains("Extracting")) { + LauncherLogger.info(" -> " + line); + } else if (line.contains("SUCCESS") || line.contains("successfully")) { + LauncherLogger.info(" + " + line); + } else if (line.contains("WARNING") || line.contains("warning")) { + LauncherLogger.warn(" ! " + line); + } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { + LauncherLogger.error(" X " + line); + if (line.contains("timed out") || line.contains("failed to download")) { + hasErrors = true; + } + } else if (!line.isBlank()) { + LauncherLogger.info(" " + line); + } + } + } + + int exitCode; + if (hasErrors) { + process.destroyForcibly(); + exitCode = 1; + } else { + if (!process.waitFor(10, TimeUnit.MINUTES)) { + process.destroyForcibly(); + LauncherLogger.error("Fabric Installer timed out after 10 minutes"); + return false; + } + exitCode = process.exitValue(); + } + + if (exitCode != 0) { + LauncherLogger.error("Fabric Installer failed (code " + exitCode + ")"); + return false; + } + + Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId); + + if (Files.exists(fabricVersionDir)) { + LauncherLogger.info("Fabric installed successfully!"); + + instance.setLoaderType("fabric"); + instance.setLoaderVersion(loaderVersion); + instance.setFabricVersionId(fabricVersionId); + + ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex); + + ZHttpClient.repairMissingLibraries(instancePath); + + return true; + } else { + LauncherLogger.error("Fabric Installer ran, but version not found."); + return false; + } + } + + private void downloadFileWithFallback(String url, Path target) throws Exception { + try { + ZHttpClient.downloadFile(url, target); + } catch (Exception e) { + LauncherLogger.warn("Failed to download Fabric Installer: " + e.getMessage()); + throw e; + } + } + + /** + * Pre-downloads the Fabric loader jar and its dependency manifest through the smart proxy + * so the installer subprocess finds them already on disk and skips direct downloads. + * Non-fatal: if this fails the subprocess will still try to download itself. + */ + private void preDownloadFabricLibraries(String minecraftVersion, String loaderVersion) { + try { + Path libRoot = instance.getPath().resolve("libraries"); + String loaderVersionPath = "net/fabricmc/fabric-loader/" + loaderVersion; + + Path loaderJson = libRoot.resolve(loaderVersionPath).resolve("fabric-loader-" + loaderVersion + ".json"); + if (!Files.exists(loaderJson)) { + Files.createDirectories(loaderJson.getParent()); + ZHttpClient.downloadFileWithSmartProxy( + "https://maven.fabricmc.net/" + loaderVersionPath + "/fabric-loader-" + loaderVersion + ".json", + loaderJson); + } + + Path loaderJar = libRoot.resolve(loaderVersionPath).resolve("fabric-loader-" + loaderVersion + ".jar"); + if (!Files.exists(loaderJar)) { + ZHttpClient.downloadFileWithSmartProxy( + "https://maven.fabricmc.net/" + loaderVersionPath + "/fabric-loader-" + loaderVersion + ".jar", + loaderJar); + } + + ZHttpClient.repairLibrariesFromJson(loaderJson, instance.getPath()); + } catch (Exception e) { + LauncherLogger.warn("Pre-download of Fabric libraries incomplete: " + e.getMessage()); + } + } + + private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException { + Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json"); + + if (!Files.exists(versionJson)) { + LauncherLogger.warn("Version JSON file not found: " + versionJson); + return; + } + + String content = Files.readString(versionJson); + + // Check and fix asset index + if (!content.contains("\"assets\":\"" + assetIndex + "\"")) { + LauncherLogger.warn("Fixing asset index in version JSON file..."); + + // Replace assets with correct value + content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\""); + + // Also check assetIndex + if (content.contains("\"assetIndex\"")) { + content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\""); + } + + Files.writeString(versionJson, content); + LauncherLogger.info("Asset index fixed to: " + assetIndex); + } else { + LauncherLogger.info("Asset index in version JSON is correct: " + assetIndex); + } + } + + private void cleanOldFabricLoaders() throws IOException { + Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader"); + if (!Files.exists(librariesDir)) return; + + LauncherLogger.warn("Cleaning old Fabric Loader versions..."); + + try (var stream = Files.walk(librariesDir)) { + stream.filter(Files::isDirectory) + .filter(dir -> dir.getFileName().toString().matches("\\d+\\.\\d+\\.\\d+.*")) + .forEach(dir -> { + try { + Files.walk(dir) + .sorted((a,b) -> b.compareTo(a)) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException ignored) {} + }); + } catch (IOException ignored) {} + }); + } + } + + private String getLatestInstallerVersion() throws Exception { + try { + // Use ZHttpClient with smart proxy + String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml"); + int start = xml.indexOf("") + 8; + int end = xml.indexOf("", start); + return xml.substring(start, end).trim(); + } catch (Exception e) { + LauncherLogger.warn("Error getting Fabric Installer version: " + e.getMessage()); + throw new Exception("Failed to get Fabric Installer version", e); + } + } + + private String downloadString(String url) throws Exception { + return ZHttpClient.getWithSmartProxy(url); + } + + private void downloadFile(String url, Path target) throws Exception { + ZHttpClient.downloadFileWithSmartProxy(url, target); + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java new file mode 100644 index 0000000..b015493 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java @@ -0,0 +1,219 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.utils.JavaResolver; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.concurrent.TimeUnit; + +public class ForgeInstaller { + + private final Instance instance; + + public ForgeInstaller(Instance instance) { + this.instance = instance; + } + + public boolean install(String mcVersion, String forgeVersion) throws Exception { + LauncherLogger.info("Installing Forge " + forgeVersion + " for Minecraft " + mcVersion); + + // Step 1: Install vanilla and get real assetIndex + LauncherLogger.info("Installing base Minecraft version " + mcVersion + "..."); + VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); + + String assetIndex = vanillaInstaller.install(mcVersion); + + if (assetIndex == null || assetIndex.isEmpty()) { + LauncherLogger.error("Failed to install base Minecraft version"); + return false; + } + + instance.setAssetIndex(assetIndex); + + // Step 2: Create launcher_profiles.json + createLauncherProfile(); + + // Step 3: Download Forge Installer with progress bar + String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/" + + mcVersion + "-" + forgeVersion + + "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar"; + + Path installerJar = instance.getPath().resolve("forge-installer.jar"); + + LauncherLogger.info("Downloading Forge Installer..."); + downloadFileWithProgress(installerUrl, installerJar); + + // Step 4: Pre-download the libraries the installer subprocess would fetch from + // maven.minecraftforge.net directly. On blocked networks that subprocess cannot + // reach the maven host, so we seed every library (version.json + install_profile.json) + // through the launcher smart proxy first. The installer reuses files whose checksums + // validate ("File exists: Checksum validated"), so no direct downloads are needed. + LauncherLogger.info("Prefetching Forge libraries via smart proxy..."); + int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath()); + LauncherLogger.info("Prefetched " + prefetched + " Forge libraries"); + + // Step 5: Run Forge Installer and show its output + LauncherLogger.info("Running Forge Installer..."); + LauncherLogger.info("This may take a few minutes. Please wait..."); + + boolean success = runForgeInstaller(installerJar); + + // After successful Forge install, before saving metadata + if (success) { + // Download missing libraries + try { + downloadMissingLibraries(mcVersion, forgeVersion); + } catch (Exception e) { + LauncherLogger.warn("Warning: could not download some libraries: " + e.getMessage()); + } + + LauncherLogger.info("\nForge " + forgeVersion + " installed successfully!"); + instance.setMinecraftVersion(mcVersion); + instance.setLoaderType("forge"); + instance.setLoaderVersion(forgeVersion); + + // Clean up temporary installer file + Files.deleteIfExists(installerJar); + return true; + } else { + LauncherLogger.error("\nError installing Forge!"); + return false; + } + } + + private void createLauncherProfile() throws IOException { + Path profilePath = instance.getPath().resolve("launcher_profiles.json"); + if (Files.exists(profilePath)) return; + + String minimalProfile = """ + { + "profiles": {}, + "selectedProfile": "Default" + } + """; + Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + LauncherLogger.info("Created launcher_profiles.json"); + } + + private void downloadFileWithProgress(String url, Path target) throws Exception { + ProgressBar.show("Downloading Forge Installer", 0, 100, "%"); + ZHttpClient.downloadFileWithSmartProxy(url, target); + ProgressBar.finish("Forge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")"); + } + + private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException { + // Try up to 3 times with different options + int maxRetries = 3; + int attempt = 1; + + while (attempt <= maxRetries) { + LauncherLogger.info("Attempt " + attempt + " of " + maxRetries); + + // Re-seed libraries each attempt: the installer may have left partial files, + // and prefetch deletes any with a bad checksum before re-downloading via proxy. + int seeded = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath()); + if (seeded > 0) { + LauncherLogger.info(" Prefetched " + seeded + " libraries for this attempt"); + } + + ProcessBuilder pb = new ProcessBuilder( + JavaResolver.getJavaExecutable(), + "-jar", + installerJar.toAbsolutePath().toString(), + "--installClient", + instance.getPath().toAbsolutePath().toString() + ); + + // Add JVM args for increased timeouts + pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); + + pb.directory(instance.getPath().toFile()); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + // Read output in real time + StringBuilder output = new StringBuilder(); + boolean hasErrors = false; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + + // Format Forge Installer output + if (line.contains("Downloading") || line.contains("Extracting")) { + LauncherLogger.info(" -> " + line); + } else if (line.contains("SUCCESS") || line.contains("successfully")) { + LauncherLogger.info(" + " + line); + } else if (line.contains("WARNING") || line.contains("warning")) { + LauncherLogger.warn(" ! " + line); + } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { + LauncherLogger.error(" X " + line); + if (line.contains("timed out") || line.contains("failed to download")) { + hasErrors = true; + } + } else if (!line.isBlank()) { + LauncherLogger.info(" " + line); + } + } + } + + int exitCode; + if (hasErrors) { + process.destroyForcibly(); + exitCode = 1; + } else { + if (!process.waitFor(10, TimeUnit.MINUTES)) { + process.destroyForcibly(); + LauncherLogger.error("Forge Installer timed out after 10 minutes"); + if (attempt < maxRetries) { + attempt++; + Thread.sleep(5000); + continue; + } + return false; + } + exitCode = process.exitValue(); + } + + // If successful or no download errors + if (exitCode == 0 && !hasErrors) { + return true; + } + + // If error and not last attempt + if (attempt < maxRetries) { + LauncherLogger.warn("Install error. Retrying in 5 seconds..."); + Thread.sleep(5000); + // Prefetch at the top of the next attempt already re-seeds any library that + // was left partial or corrupted (SHA-1 mismatch -> delete + re-download via proxy). + } else { + LauncherLogger.error("Forge Installer exited with error code: " + exitCode); + + // Show possible solution + if (output.toString().contains("timed out")) { + LauncherLogger.warn("\nPossible solutions:"); + LauncherLogger.warn("1. Check your internet connection"); + LauncherLogger.warn("2. Run the launcher as administrator"); + LauncherLogger.warn("3. Temporarily disable antivirus/firewall"); + LauncherLogger.warn("4. Try installing a different Forge version"); + } + } + + attempt++; + } + + return false; + } + + private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception { + LauncherLogger.info("Checking and downloading missing libraries..."); + ZHttpClient.repairMissingLibraries(instance.getPath()); + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java new file mode 100644 index 0000000..7a877ab --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java @@ -0,0 +1,234 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.utils.JavaResolver; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.*; +import java.util.concurrent.TimeUnit; + +public class ModLoaderInstaller { + + public enum LoaderType { + FORGE("forge", "net.minecraftforge", "forge", "https://maven.minecraftforge.net"), + NEOFORGE("neoforge", "net.neoforged", null, "https://maven.neoforged.net/releases"); + + final String loaderName; + final String mavenGroup; + final String fixedArtifact; + final String mavenBase; + + LoaderType(String loaderName, String mavenGroup, String fixedArtifact, String mavenBase) { + this.loaderName = loaderName; + this.mavenGroup = mavenGroup; + this.fixedArtifact = fixedArtifact; + this.mavenBase = mavenBase; + } + + public String artifact(String mcVersion) { + if (this == NEOFORGE && !"1.20.1".equals(mcVersion)) return "neoforge"; + return "forge"; + } + } + + private final Instance instance; + + public ModLoaderInstaller(Instance instance) { + this.instance = instance; + } + + public boolean install(String mcVersion, String loaderVersion, LoaderType type) throws Exception { + LauncherLogger.info("Installing " + type.loaderName + " " + loaderVersion + " for Minecraft " + mcVersion); + + LauncherLogger.info("Installing base Minecraft version " + mcVersion + "..."); + VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); + String assetIndex = vanillaInstaller.install(mcVersion); + + if (assetIndex == null || assetIndex.isEmpty()) { + LauncherLogger.error("Failed to install base Minecraft version"); + return false; + } + + instance.setAssetIndex(assetIndex); + createLauncherProfile(); + + ProgressBar.showIndeterminate("Preparing " + type.loaderName + " installer"); + + String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type); + String jarName = type.loaderName + "-installer.jar"; + Path installerJar = instance.getPath().resolve(jarName); + + LauncherLogger.info("Downloading " + type.loaderName + " Installer..."); + ProgressBar.showIndeterminate("Downloading " + type.loaderName + " Installer"); + ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar); + + LauncherLogger.info("Running " + type.loaderName + " Installer..."); + LauncherLogger.info("This may take a few minutes. Please wait..."); + ProgressBar.showIndeterminate("Running " + type.loaderName + " Installer (may take a few minutes)"); + + boolean success = runInstaller(installerJar, type); + + if (success) { + try { + downloadMissingLibraries(type); + } catch (Exception e) { + LauncherLogger.warn("Warning: could not download some libraries: " + e.getMessage()); + } + + LauncherLogger.info("\n" + type.loaderName + " " + loaderVersion + " installed successfully!"); + instance.setMinecraftVersion(mcVersion); + instance.setLoaderType(type.loaderName); + instance.setLoaderVersion(loaderVersion); + + Files.deleteIfExists(installerJar); + return true; + } else { + LauncherLogger.error("\nError installing " + type.loaderName + "!"); + return false; + } + } + + private String buildInstallerUrl(String mcVersion, String loaderVersion, LoaderType type) { + if (type == LoaderType.FORGE) { + return type.mavenBase + "/" + type.mavenGroup.replace('.', '/') + + "/" + type.fixedArtifact + "/" + mcVersion + "-" + loaderVersion + + "/" + type.fixedArtifact + "-" + mcVersion + "-" + loaderVersion + "-installer.jar"; + } + + String artifact = type.artifact(mcVersion); + return type.mavenBase + "/" + type.mavenGroup.replace('.', '/') + + "/" + artifact + "/" + loaderVersion + + "/" + artifact + "-" + loaderVersion + "-installer.jar"; + } + + private void createLauncherProfile() throws IOException { + Path profilePath = instance.getPath().resolve("launcher_profiles.json"); + if (Files.exists(profilePath)) return; + + String minimalProfile = """ + { + "profiles": {}, + "selectedProfile": "Default" + } + """; + Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + LauncherLogger.info("Created launcher_profiles.json"); + } + + private boolean runInstaller(Path installerJar, LoaderType type) throws IOException, InterruptedException { + int maxRetries = 3; + int attempt = 1; + + while (attempt <= maxRetries) { + LauncherLogger.info("Attempt " + attempt + " of " + maxRetries); + + ProcessBuilder pb = new ProcessBuilder( + JavaResolver.getJavaExecutable(), + "-jar", + installerJar.toAbsolutePath().toString(), + "--installClient", + instance.getPath().toAbsolutePath().toString() + ); + + pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); + pb.directory(instance.getPath().toFile()); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + StringBuilder output = new StringBuilder(); + boolean hasErrors = false; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + + if (line.contains("Downloading") || line.contains("Extracting") + || line.contains("Installing") || line.contains("Creating") + || line.contains("SUCCESS") || line.contains("successfully")) { + ProgressBar.showIndeterminate(type.loaderName + " Installer: " + ProgressBar.cleanLine(line)); + } + if (line.contains("Downloading") || line.contains("Extracting")) { + LauncherLogger.info(" -> " + line); + } else if (line.contains("SUCCESS") || line.contains("successfully")) { + LauncherLogger.info(" + " + line); + } else if (line.contains("WARNING") || line.contains("warning")) { + LauncherLogger.warn(" ! " + line); + } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { + LauncherLogger.error(" X " + line); + if (line.contains("timed out") || line.contains("failed to download")) { + hasErrors = true; + } + } else if (!line.isBlank()) { + LauncherLogger.info(" " + line); + } + } + } + + int exitCode; + if (hasErrors) { + process.destroyForcibly(); + exitCode = 1; + } else { + if (!process.waitFor(10, TimeUnit.MINUTES)) { + process.destroyForcibly(); + LauncherLogger.error(type.loaderName + " Installer timed out after 10 minutes"); + if (attempt < maxRetries) { + attempt++; + Thread.sleep(5000); + continue; + } + return false; + } + exitCode = process.exitValue(); + } + + if (exitCode == 0) { + return true; + } + + if (attempt < maxRetries) { + LauncherLogger.warn("Install error. Retrying in 5 seconds..."); + Thread.sleep(5000); + + Path librariesDir = instance.getPath().resolve("libraries"); + if (Files.exists(librariesDir)) { + try (var stream = Files.walk(librariesDir)) { + stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar")) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException e) { /* ignore */ } + }); + } + } + } else { + LauncherLogger.error(type.loaderName + " Installer exited with error code: " + exitCode); + + if (output.toString().contains("timed out")) { + LauncherLogger.warn("\nPossible solutions:"); + LauncherLogger.warn("1. Check your internet connection"); + LauncherLogger.warn("2. Run the launcher as administrator"); + LauncherLogger.warn("3. Temporarily disable antivirus/firewall"); + LauncherLogger.warn("4. Try installing a different version"); + } + } + + attempt++; + } + + return false; + } + + private void downloadMissingLibraries(LoaderType type) throws Exception { + LauncherLogger.info("Checking and downloading missing libraries..."); + ProgressBar.showIndeterminate("Downloading missing libraries"); + ZHttpClient.repairMissingLibraries(instance.getPath()); + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java new file mode 100644 index 0000000..70d89d7 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java @@ -0,0 +1,208 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.utils.JavaResolver; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.concurrent.TimeUnit; + +public class NeoForgeInstaller { + + private final Instance instance; + + public NeoForgeInstaller(Instance instance) { + this.instance = instance; + } + + public boolean install(String mcVersion, String neoForgeVersion) throws Exception { + LauncherLogger.info("Installing NeoForge " + neoForgeVersion + " for Minecraft " + mcVersion); + + LauncherLogger.info("Installing base Minecraft version " + mcVersion + "..."); + VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); + String assetIndex = vanillaInstaller.install(mcVersion); + + if (assetIndex == null || assetIndex.isEmpty()) { + LauncherLogger.error("Failed to install base Minecraft version"); + return false; + } + + instance.setAssetIndex(assetIndex); + createLauncherProfile(); + + String mavenGroup = getMavenGroup(mcVersion); + String mavenArtifact = getMavenArtifact(mcVersion); + + String installerUrl = "https://maven.neoforged.net/releases/" + + mavenGroup.replace('.', '/') + "/" + + mavenArtifact + "/" + + neoForgeVersion + + "/" + mavenArtifact + "-" + neoForgeVersion + "-installer.jar"; + + Path installerJar = instance.getPath().resolve("neoforge-installer.jar"); + + LauncherLogger.info("Downloading NeoForge Installer..."); + downloadFileWithProgress(installerUrl, installerJar); + + LauncherLogger.info("Prefetching NeoForge libraries via smart proxy..."); + int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath()); + LauncherLogger.info("Prefetched " + prefetched + " NeoForge libraries"); + + LauncherLogger.info("Running NeoForge Installer..."); + LauncherLogger.info("This may take a few minutes. Please wait..."); + + boolean success = runNeoForgeInstaller(installerJar); + + if (success) { + try { + downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact); + } catch (Exception e) { + LauncherLogger.warn("Warning: could not download some libraries: " + e.getMessage()); + } + + LauncherLogger.info("\nNeoForge " + neoForgeVersion + " installed successfully!"); + instance.setMinecraftVersion(mcVersion); + instance.setLoaderType("neoforge"); + instance.setLoaderVersion(neoForgeVersion); + + Files.deleteIfExists(installerJar); + return true; + } else { + LauncherLogger.error("\nError installing NeoForge!"); + return false; + } + } + + private String getMavenGroup(String mcVersion) { + if (mcVersion.equals("1.20.1")) { + return "net.neoforged"; + } + return "net.neoforged"; + } + + private String getMavenArtifact(String mcVersion) { + if (mcVersion.equals("1.20.1")) { + return "forge"; + } + return "neoforge"; + } + + private void createLauncherProfile() throws IOException { + Path profilePath = instance.getPath().resolve("launcher_profiles.json"); + if (Files.exists(profilePath)) return; + + String minimalProfile = """ + { + "profiles": {}, + "selectedProfile": "Default" + } + """; + Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + LauncherLogger.info("Created launcher_profiles.json"); + } + + private void downloadFileWithProgress(String url, Path target) throws Exception { + ProgressBar.show("Downloading NeoForge Installer", 0, 100, "%"); + ZHttpClient.downloadFileWithSmartProxy(url, target); + ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")"); + } + + private boolean runNeoForgeInstaller(Path installerJar) throws IOException, InterruptedException { + int maxRetries = 3; + int attempt = 1; + + while (attempt <= maxRetries) { + LauncherLogger.info("Attempt " + attempt + " of " + maxRetries); + + ProcessBuilder pb = new ProcessBuilder( + JavaResolver.getJavaExecutable(), + "-jar", + installerJar.toAbsolutePath().toString(), + "--installClient", + instance.getPath().toAbsolutePath().toString() + ); + + pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); + pb.directory(instance.getPath().toFile()); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + StringBuilder output = new StringBuilder(); + boolean hasErrors = false; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + + if (line.contains("Downloading") || line.contains("Extracting")) { + LauncherLogger.info(" -> " + line); + } else if (line.contains("SUCCESS") || line.contains("successfully")) { + LauncherLogger.info(" + " + line); + } else if (line.contains("WARNING") || line.contains("warning")) { + LauncherLogger.warn(" ! " + line); + } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { + LauncherLogger.error(" X " + line); + if (line.contains("timed out") || line.contains("failed to download")) { + hasErrors = true; + } + } else if (!line.isBlank()) { + LauncherLogger.info(" " + line); + } + } + } + + if (!process.waitFor(10, TimeUnit.MINUTES)) { + process.destroyForcibly(); + LauncherLogger.error("NeoForge Installer timed out after 10 minutes"); + return false; + } + int exitCode = process.exitValue(); + + if (exitCode == 0 && !hasErrors) { + return true; + } + + if (attempt < maxRetries) { + LauncherLogger.warn("Install error. Retrying in 5 seconds..."); + Thread.sleep(5000); + + Path librariesDir = instance.getPath().resolve("libraries"); + if (Files.exists(librariesDir)) { + try (var stream = Files.walk(librariesDir)) { + stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar")) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException e) { /* ignore */ } + }); + } + } + } else { + LauncherLogger.error("NeoForge Installer exited with error code: " + exitCode); + + if (output.toString().contains("timed out")) { + LauncherLogger.warn("\nPossible solutions:"); + LauncherLogger.warn("1. Check your internet connection"); + LauncherLogger.warn("2. Run the launcher as administrator"); + LauncherLogger.warn("3. Temporarily disable antivirus/firewall"); + LauncherLogger.warn("4. Try installing a different NeoForge version"); + } + } + + attempt++; + } + + return false; + } + + private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception { + LauncherLogger.info("Checking and downloading missing libraries..."); + ZHttpClient.repairMissingLibraries(instance.getPath()); + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java new file mode 100644 index 0000000..1826b8f --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java @@ -0,0 +1,370 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.InputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +public class VersionInstaller { + + private final Path minecraftDir; + private final ExecutorService executor = Executors.newFixedThreadPool(32); + + public VersionInstaller(Path minecraftDir) { + this.minecraftDir = minecraftDir; + } + + public List getAvailableVersions() throws Exception { + // Prefers Zern server Mojang proxy (reachable + cached), falls back to direct piston-meta + JSONObject root = ZHttpClient.getMojangVersionManifest(); + JSONArray versionsArray = root.getJSONArray("versions"); + + List versions = new ArrayList<>(); + + for (int i = 0; i < versionsArray.length(); i++) { + JSONObject v = versionsArray.getJSONObject(i); + String id = v.getString("id"); + String type = v.getString("type"); + String releaseTimeStr = v.getString("releaseTime").replace("Z", "").replace("+00:00", ""); + String url = v.getString("url"); + + LocalDateTime releaseTime = LocalDateTime.parse(releaseTimeStr); + versions.add(new MinecraftVersion(id, type, releaseTime, url)); + } + + versions.sort((a, b) -> b.getReleaseTime().compareTo(a.getReleaseTime())); + return versions; + } + + public String install(String versionId) throws Exception { + LauncherLogger.info("Full install of Minecraft " + versionId + "..."); + ProgressBar.setStage("Version", 0, 5); + Path versionDir = minecraftDir.resolve("versions").resolve(versionId); + Files.createDirectories(versionDir); + + boolean alreadyInstalled = Files.exists(versionDir.resolve(versionId + ".json")) + && Files.exists(versionDir.resolve(versionId + ".jar")) + && isValidJar(versionDir.resolve(versionId + ".jar")); + if (alreadyInstalled) { + LauncherLogger.info("Minecraft " + versionId + " already installed, skipping"); + String existing = Files.readString(versionDir.resolve(versionId + ".json")); + JSONObject existingData = new JSONObject(existing); + return existingData.has("assetIndex") + ? existingData.getJSONObject("assetIndex").getString("id") + : existingData.getString("assets"); + } + // A stale/partial client.jar (e.g. aborted by the download timeout) must be + // removed, otherwise it would be skipped as "already installed" and stay corrupt. + try { + Files.deleteIfExists(versionDir.resolve(versionId + ".jar")); + } catch (IOException e) { + LauncherLogger.warn("Could not remove stale client.jar: " + e.getMessage()); + } + + ProgressBar.show("Fetching version info", 0, 1, "files"); + String versionJson; + try { + // Prefers Zern server Mojang proxy, falls back to direct piston-meta + LauncherLogger.info("[VERSION] Fetching version json for " + versionId + " (server proxy, then direct piston-meta)"); + versionJson = ZHttpClient.getMojangVersionJson(versionId).toString(); + LauncherLogger.info("[VERSION] Version json fetched for " + versionId); + } catch (Exception e) { + LauncherLogger.error("[VERSION] Failed to fetch version info: " + e.getMessage()); + throw e; + } + try { + Files.writeString(versionDir.resolve(versionId + ".json"), versionJson); + } catch (Exception e) { + LauncherLogger.error("[VERSION] Failed to write version info: " + e.getMessage()); + throw e; + } + ProgressBar.show("Version info", 1, 1, "files"); + + JSONObject versionData = new JSONObject(versionJson); + + // client.jar + ProgressBar.show("Downloading client.jar", 0, 1, "files"); + LauncherLogger.info("[VERSION] Downloading client.jar for " + versionId + "..."); + Path clientJar = versionDir.resolve(versionId + ".jar"); + if (!Files.exists(clientJar)) { + try { + ZHttpClient.downloadFileWithSmartProxy( + versionData.getJSONObject("downloads").getJSONObject("client").getString("url"), + clientJar); + LauncherLogger.info("[VERSION] client.jar downloaded for " + versionId); + } catch (Exception e) { + LauncherLogger.error("[VERSION] Failed to download client.jar: " + e.getMessage()); + throw e; + } + } else { + LauncherLogger.info("[VERSION] client.jar already present for " + versionId); + } + ProgressBar.show("Client.jar", 1, 1, "files"); + + // Libraries + ProgressBar.setStage("Libraries", 1, 5); + LauncherLogger.info("Downloading libraries..."); + downloadLibraries(versionData.getJSONArray("libraries")); + + // Natives + ProgressBar.setStage("Natives", 2, 5); + LauncherLogger.info("Extracting natives..."); + extractNatives(versionData.getJSONArray("libraries")); + + String assetIndex; + if (versionData.has("assetIndex")) { + assetIndex = versionData.getJSONObject("assetIndex").getString("id"); + } else { + assetIndex = versionData.getString("assets"); + } + + LauncherLogger.info("Asset index: " + assetIndex); + + // Download assets using correct index + ProgressBar.setStage("Assets", 3, 5); + LauncherLogger.info("Downloading assets..."); + downloadAssets(versionData, assetIndex); + + ProgressBar.setStage("Done", 4, 5); + LauncherLogger.info("\nMinecraft " + versionId + " fully installed!"); + return assetIndex; + } + + private void extractNatives(JSONArray libraries) throws Exception { + Path nativesDir = minecraftDir.resolve("natives"); + Files.createDirectories(nativesDir); + + // Determine OS classifier + String osName = System.getProperty("os.name").toLowerCase(); + String classifier; + if (osName.contains("win")) { + classifier = "natives-windows"; + } else if (osName.contains("mac")) { + classifier = "natives-macos"; + } else { + classifier = "natives-linux"; + } + + int total = libraries.length(); + int extracted = 0; + int failed = 0; + + for (int i = 0; i < total; i++) { + JSONObject lib = libraries.getJSONObject(i); + if (!lib.has("downloads") || !lib.getJSONObject("downloads").has("classifiers")) continue; + + JSONObject classifiers = lib.getJSONObject("downloads").getJSONObject("classifiers"); + if (!classifiers.has(classifier)) continue; + + JSONObject nativeArtifact = classifiers.getJSONObject(classifier); + String url = nativeArtifact.getString("url"); + String path = nativeArtifact.getString("path"); + + Path libJar = minecraftDir.resolve("libraries").resolve(path); + + // Download native library if not already present + if (!Files.exists(libJar)) { + try { + Files.createDirectories(libJar.getParent()); + ZHttpClient.downloadFileWithSmartProxy(url, libJar); + } catch (Exception e) { + failed++; + continue; + } + } + + // Extract .so/.dll/.dylib from the native jar + try (JarFile jar = new JarFile(libJar.toFile())) { + var entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String name = entry.getName(); + if (name.endsWith(".so") || name.endsWith(".dll") || name.endsWith(".dylib") || name.endsWith(".jnilib")) { + Path outFile = nativesDir.resolve(name); + if (!entry.isDirectory()) { + Files.createDirectories(outFile.getParent()); + try (InputStream is = jar.getInputStream(entry)) { + Files.copy(is, outFile, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + } catch (Exception e) { + failed++; + } + extracted++; + ProgressBar.show("Natives", extracted, total, "files"); + } + + if (extracted > 0) { + ProgressBar.finish("Natives extracted (" + extracted + " ok, " + failed + " failed)"); + } else { + LauncherLogger.warn("No natives to extract for " + classifier); + } + } + + private void downloadLibraries(JSONArray libraries) throws Exception { + int total = libraries.length(); + int count = 0; + + for (int i = 0; i < total; i++) { + JSONObject lib = libraries.getJSONObject(i); + if (lib.has("downloads") && lib.getJSONObject("downloads").has("artifact")) { + JSONObject art = lib.getJSONObject("downloads").getJSONObject("artifact"); + String url = art.getString("url"); + String path = art.getString("path"); + + Path target = minecraftDir.resolve("libraries").resolve(path); + Files.createDirectories(target.getParent()); + + if (Files.exists(target)) { + count++; + ProgressBar.show("Libraries", count, total, "files"); + continue; + } + + try { + ZHttpClient.downloadFileWithSmartProxy(url, target); + } catch (Exception e) { + LauncherLogger.warn("[LIB] Failed to download " + path + ": " + e.getMessage()); + } + } + count++; + ProgressBar.show("Libraries", count, total, "files"); + } + ProgressBar.finish("Libraries downloaded"); + } + + private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception { + // Find URL for asset index + JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex"); + String indexUrl = assetIndexInfo.getString("url"); + + Path indexesDir = minecraftDir.resolve("assets/indexes"); + Files.createDirectories(indexesDir); + Path indexPath = indexesDir.resolve(assetIndex + ".json"); + + if (!Files.exists(indexPath)) { + LauncherLogger.info("Downloading asset index (" + assetIndex + ")..."); + ZHttpClient.downloadFileWithSmartProxy(indexUrl, indexPath); + } else { + LauncherLogger.info("Asset index already present (" + assetIndex + ")"); + } + + String jsonContent = Files.readString(indexPath); + JSONObject root = new JSONObject(jsonContent); + JSONObject objects = root.getJSONObject("objects"); + + LauncherLogger.info("Downloading " + objects.length() + " asset objects (index: " + assetIndex + ")..."); + + int total = objects.length(); + int[] success = {0}; + int[] failed = {0}; + + List> futures = new ArrayList<>(); + + for (String key : objects.keySet()) { + JSONObject asset = objects.getJSONObject(key); + String hash = asset.getString("hash"); + + String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; + Path target = minecraftDir.resolve("assets/objects") + .resolve(hash.substring(0, 2)) + .resolve(hash); + + Files.createDirectories(target.getParent()); + + CompletableFuture future = CompletableFuture.runAsync(() -> { + try { + if (Files.exists(target) && Files.size(target) > 0) { + synchronized (this) { + success[0]++; + ProgressBar.show("Assets", success[0], total, "files"); + } + return; + } + } catch (IOException ignored) {} + boolean downloaded = false; + for (int attempt = 1; attempt <= 3; attempt++) { + try { + ZHttpClient.downloadFileWithSmartProxy(url, target); + synchronized (this) { + success[0]++; + ProgressBar.show("Assets", success[0], total, "files"); + } + downloaded = true; + break; + } catch (Exception e) { + if (attempt == 3) { + synchronized (this) { + failed[0]++; + } + LauncherLogger.error("[VERSION] Failed to download asset " + hash); + } else { + try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {} + } + } + } + }, executor); + + futures.add(future); + } + + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .get(10, TimeUnit.MINUTES); + } catch (TimeoutException e) { + futures.forEach(f -> f.cancel(true)); + LauncherLogger.error("[VERSION] Asset download timed out after 10 minutes"); + } catch (CancellationException e) { + // one of the futures was cancelled + } catch (CompletionException e) { + // one of the futures failed exceptionally + } + + ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)"); + + if (failed[0] > 0) { + LauncherLogger.warn("Warning: " + failed[0] + " asset files could not be downloaded."); + LauncherLogger.warn("The game will launch, but some textures/sounds may be missing."); + } + } + + private boolean isValidJar(Path path) { + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(path.toFile())) { + return true; + } catch (Exception e) { + return false; + } + } + + public String getAssetIndexId(String versionId) throws Exception { + JSONObject versionData = ZHttpClient.getMojangVersionJson(versionId); + + if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) { + return versionData.getJSONObject("assetIndex").getString("id"); + } + return versionData.getString("assets"); + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java new file mode 100644 index 0000000..9477e12 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java @@ -0,0 +1,1054 @@ +package me.sashegdev.zernmc.launcher.minecraft.launch; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import org.json.JSONObject; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class LaunchCommandBuilder { + + private static final List tempFiles = new ArrayList<>(); + + static { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + for (Path p : tempFiles) { + try { Files.deleteIfExists(p); } catch (Exception ignored) {} + } + })); + } + + private final Instance instance; + + public LaunchCommandBuilder(Instance instance) { + this.instance = instance; + } + + public List build(LaunchOptions options) throws Exception { + LauncherLogger.info(ZAnsi.cyan("Generating launch command for " + instance.getName() + "...")); + + List command = new ArrayList<>(); + + String javaPath = options.getJavaPath() != null && !options.getJavaPath().isEmpty() + ? options.getJavaPath() : "java"; + command.add(javaPath); + + Path nativesDir = instance.getPath().resolve("natives"); + if (!Files.exists(nativesDir)) { + Files.createDirectories(nativesDir); + } + + String loaderType = instance.getLoaderType().toLowerCase(); + VersionManifest manifest = resolveVersionManifest(); + + if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) { + // Forge/NeoForge: Pass version.json JVM args and game args through + // with placeholder substitution. Do NOT manually add -p, -cp, + // --add-modules, --add-opens, or -DignoreList. The version.json + // defines all of these. This matches AstralRinth's approach. + LauncherLogger.info(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution")); + + // Ensure version jar exists at versions//.jar + // so securejarhandler creates module "minecraft" (from Automatic-Module-Name) + // instead of "_1._20._1" (automatic module from filename 1.20.1.jar). + ensureVersionJarForForge(); + + // Parse version.json JVM args (child only) and game args (merged with parent). + // Forge/NeoForge defines its own -p, --add-modules, --add-opens. + // -Djava.library.path is NOT in child args, so it's added manually below. + List allJvmArgs = manifest != null ? manifest.getJvmArguments() : new ArrayList<>(); + List allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>(); + + // Build variable map for placeholder substitution (${library_directory}, + // ${classpath_separator}, ${version_name}, ${natives_directory}, ...) + Map vars = buildVariableMap(options); + + // Real Forge/NeoForge version.json (1.17+ / 47.x) declares a fixed -p + // module path of bootstrap jars and references ${library_directory} / + // ${classpath_separator} / ${version_name} — but it does NOT reference + // ${classpath} and does NOT contain -cp. To let the JVM find + // cpw.mods.bootstraplauncher.BootstrapLauncher we must supply -cp + // ourselves (version jar + all libraries, exactly like the official + // launcher). If a version.json DOES reference ${classpath}, we substitute + // the same full classpath into it first. + String fullClasspath; + if (manifest != null) { + fullClasspath = buildClasspathFromManifest(manifest, true); + } else { + fullClasspath = buildVanillaClasspath(); + } + + boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs); + if (referencesClasspath) { + vars.put("classpath", writeClasspathFile(fullClasspath)); + LauncherLogger.info(ZAnsi.green(" ${classpath} referenced, substituted full classpath")); + } else { + LauncherLogger.info(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting")); + } + + boolean hasCpInJson = containsCpArg(allJvmArgs); + boolean hasLibraryPathInJson = containsLibraryPathArg(allJvmArgs); + + if (!allJvmArgs.isEmpty()) { + for (String arg : allJvmArgs) { + String resolved = resolveVariable(arg, vars); + if (!resolved.isEmpty()) { + command.add(resolved); + } + } + LauncherLogger.info(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json")); + } else { + LauncherLogger.info(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback")); + command.addAll(getJvmArguments(options)); + } + + // Forge/NeoForge child version.json usually doesn't include + // -Djava.library.path (47.3.0 doesn't) — add it unless already present. + if (!hasLibraryPathInJson) { + command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); + } + + // Append memory/GC args (always after version.json args, like AstralRinth) + int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096; + command.add("-Xmx" + ramMB + "M"); + command.add("-Xms" + Math.max(512, ramMB / 2) + "M"); + command.add("-XX:+UseG1GC"); + command.add("-XX:+UnlockExperimentalVMOptions"); + command.add("-XX:G1NewSizePercent=20"); + command.add("-XX:G1ReservePercent=20"); + command.add("-XX:MaxGCPauseMillis=50"); + command.add("-XX:G1HeapRegionSize=32M"); + + // Append custom user JVM args + if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) { + command.addAll(options.getExtraJvmArgs()); + } + + // Real Forge/NeoForge version.json has no -cp, so the JVM would not find the + // main class. Add -cp with version jar + all libraries (official-launcher + // behaviour); skip only if the version.json itself declares -cp. + if (!hasCpInJson) { + command.add("-cp"); + command.add(writeClasspathFile(fullClasspath)); + LauncherLogger.info(ZAnsi.green(" Added -cp classpath for main class lookup")); + } + + // Main class from version.json + String mainClass = null; + if (manifest != null) { + mainClass = manifest.getMainClass(); + } + if (mainClass == null || mainClass.isEmpty()) { + mainClass = "cpw.mods.bootstraplauncher.BootstrapLauncher"; + LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass)); + } else { + LauncherLogger.info(ZAnsi.green(" Main class from manifest: " + mainClass)); + } + command.add(mainClass); + + // Parse version.json game args (merged with parent) with placeholder + // substitution. Parent provides --username, --version, etc.; + // child provides --launchTarget, --fml.*. + if (!allGameArgs.isEmpty()) { + for (String arg : allGameArgs) { + String resolved = resolveVariable(arg, vars); + if (!resolved.isEmpty()) { + command.add(resolved); + } + } + LauncherLogger.info(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json")); + } else { + LauncherLogger.info(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback")); + command.addAll(getVanillaGameArguments(options)); + command.addAll(getModloaderLaunchArgs()); + } + + // NOTE: --width/--height are NOT appended here because version.json + // game args already include them via ${resolution_width}/${resolution_height} + // placeholders. Appending them again causes MultipleArgumentsForOptionException. + } else { + // Fabric, vanilla, and other loaders: use manual JVM args + command.addAll(getJvmArguments(options)); + command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); + + if ("fabric".equals(loaderType)) { + LauncherLogger.info(ZAnsi.cyan(" Fabric: using vanilla classpath")); + command.add("-cp"); + command.add(writeClasspathFile(buildVanillaClasspath())); + String mainClass = null; + if (manifest != null) { + mainClass = manifest.getMainClass(); + LauncherLogger.info(ZAnsi.cyan(" Main class from manifest: " + mainClass)); + } + if (mainClass == null || mainClass.isEmpty()) { + mainClass = getVanillaMainClass(); + LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass)); + } + command.add(mainClass); + command.addAll(getVanillaGameArguments(options)); + } else if (manifest != null) { + String classpath = buildClasspathFromManifest(manifest, true); + if (classpath.isEmpty()) { + LauncherLogger.info(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath")); + command.add("-cp"); + command.add(writeClasspathFile(buildVanillaClasspath())); + command.add(getVanillaMainClass()); + command.addAll(getVanillaGameArguments(options)); + } else { + command.add("-cp"); + command.add(writeClasspathFile(classpath)); + String mainClass = resolveMainClass(manifest); + command.add(mainClass); + command.addAll(resolveGameArguments(manifest, options)); + } + } else { + command.add("-cp"); + command.add(writeClasspathFile(buildVanillaClasspath())); + command.add(getVanillaMainClass()); + command.addAll(getVanillaGameArguments(options)); + } + } + + return command; + } + + private VersionManifest resolveVersionManifest() { + try { + Path versionJson = findVersionJson(); + if (versionJson != null && Files.exists(versionJson)) { + String content = Files.readString(versionJson); + JSONObject json = new JSONObject(content); + LauncherLogger.info(ZAnsi.green("Found version.json: " + versionJson.getFileName())); + VersionManifest manifest = new VersionManifest(json); + manifest.resolveParent(instance.getPath().resolve("versions")); + if (manifest.getInheritsFrom() != null) { + LauncherLogger.info(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom())); + } + return manifest; + } else { + LauncherLogger.info(ZAnsi.yellow("version.json not found for " + instance.getName())); + LauncherLogger.info(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion())); + LauncherLogger.info(ZAnsi.yellow(" path=" + instance.getPath())); + } + } catch (Exception e) { + LauncherLogger.info(ZAnsi.yellow("Failed to load version.json: " + e.getMessage())); + } + return null; + } + + private Path findVersionJson() { + Path versionsDir = instance.getPath().resolve("versions"); + String loaderType = instance.getLoaderType().toLowerCase(); + String mcVersion = instance.getMinecraftVersion(); + String loaderVersion = instance.getLoaderVersion(); + + if ("fabric".equals(loaderType)) { + String versionId = getVersionId(); + // Try fabric version ID first + Path jsonPath = versionsDir.resolve(versionId).resolve(versionId + ".json"); + if (Files.exists(jsonPath)) { + return jsonPath; + } + // Try instance's fabricVersionId if available + String fabricId = instance.getFabricVersionId(); + if (fabricId != null && !fabricId.isEmpty()) { + Path fabricPath = versionsDir.resolve(fabricId).resolve(fabricId + ".json"); + if (Files.exists(fabricPath)) { + return fabricPath; + } + } + // Try generic fabric pattern + try { + if (Files.exists(versionsDir)) { + try (var stream = Files.list(versionsDir)) { + return stream + .filter(Files::isDirectory) + .filter(dir -> dir.getFileName().toString().contains("fabric")) + .filter(dir -> dir.getFileName().toString().contains(mcVersion)) + .findFirst() + .map(dir -> dir.resolve(dir.getFileName().toString() + ".json")) + .filter(Files::exists) + .orElse(null); + } + } + } catch (Exception ignored) {} + } + + if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) { + String[] candidates = { + getVersionId(), + mcVersion + "-" + loaderType + "-" + loaderVersion, + loaderType + "-" + loaderVersion, + mcVersion + "-" + loaderVersion, + mcVersion + }; + for (String candidate : candidates) { + Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json"); + if (Files.exists(jsonPath)) { + return jsonPath; + } + } + + try { + if (Files.exists(versionsDir)) { + try (var stream = Files.list(versionsDir)) { + return stream + .filter(Files::isDirectory) + .filter(dir -> dir.getFileName().toString().contains("forge") || + dir.getFileName().toString().contains("neoforge")) + .filter(dir -> dir.getFileName().toString().contains(mcVersion)) + .findFirst() + .map(dir -> dir.resolve(dir.getFileName().toString() + ".json")) + .filter(Files::exists) + .orElse(null); + } + } + } catch (Exception ignored) {} + } + + Path fallback = versionsDir.resolve(mcVersion).resolve(mcVersion + ".json"); + if (Files.exists(fallback)) { + return fallback; + } + + return null; + } + + private String getVersionId() { + String loaderType = instance.getLoaderType().toLowerCase(); + String mcVersion = instance.getMinecraftVersion(); + String loaderVer = instance.getLoaderVersion(); + + if ("vanilla".equals(loaderType)) { + return mcVersion; + } + else if ("fabric".equals(loaderType)) { + String fabricId = instance.getFabricVersionId(); + if (fabricId != null && !fabricId.isEmpty()) { + return fabricId; + } + return "fabric-loader-" + loaderVer + "-" + mcVersion; + } + else if ("forge".equals(loaderType)) { + return mcVersion + "-forge-" + loaderVer; + } + else if ("neoforge".equals(loaderType)) { + if (mcVersion.equals("1.20.1")) { + return mcVersion + "-neoforge-" + loaderVer; + } + return "neoforge-" + loaderVer; + } + + return mcVersion; + } + + private String resolveMainClass(VersionManifest manifest) { + return manifest.getMainClass(); + } + + private String getVanillaMainClass() { + String loaderType = instance.getLoaderType().toLowerCase(); + if ("fabric".equals(loaderType)) { + return "net.fabricmc.loader.impl.launch.knot.KnotClient"; + } else if ("forge".equals(loaderType)) { + return "cpw.mods.bootstraplauncher.BootstrapLauncher"; + } else if ("neoforge".equals(loaderType)) { + return "cpw.mods.bootstraplauncher.BootstrapLauncher"; + } + return "net.minecraft.client.main.Main"; + } + + private List getModloaderLaunchArgs() { + List args = new ArrayList<>(); + String loaderType = instance.getLoaderType().toLowerCase(); + String mcVer = instance.getMinecraftVersion(); + String loaderVer = instance.getLoaderVersion(); + + if ("forge".equals(loaderType)) { + args.add("--launchTarget"); + args.add("forgeclient"); + if (loaderVer != null && !loaderVer.isEmpty()) { + args.add("--fml.forgeVersion"); + args.add(loaderVer); + } + if (mcVer != null && !mcVer.isEmpty()) { + args.add("--fml.mcVersion"); + args.add(mcVer); + } + args.add("--fml.forgeGroup"); + args.add("net.minecraftforge"); + // Read mcpVersion from version.json if possible, fallback to empty + String mcpVer = getMcpVersion(mcVer); + if (mcpVer != null && !mcpVer.isEmpty()) { + args.add("--fml.mcpVersion"); + args.add(mcpVer); + } + } else if ("neoforge".equals(loaderType)) { + args.add("--launchTarget"); + args.add("neoforgeclient"); + if (loaderVer != null && !loaderVer.isEmpty()) { + args.add("--fml.neoForgeVersion"); + args.add(loaderVer); + } + if (mcVer != null && !mcVer.isEmpty()) { + args.add("--fml.mcVersion"); + args.add(mcVer); + } + args.add("--fml.neoForgeGroup"); + args.add("net.neoforged"); + } + + return args; + } + + private String getMcpVersion(String mcVersion) { + if (mcVersion == null) return null; + Path versionsDir = instance.getPath().resolve("versions"); + String loaderType = instance.getLoaderType().toLowerCase(); + String loaderVer = instance.getLoaderVersion(); + // Try the same candidate paths as findVersionJson() + String[] candidates = { + getVersionId(), + mcVersion + "-" + loaderType + "-" + loaderVer, + loaderType + "-" + loaderVer, + mcVersion + "-" + loaderVer, + mcVersion + }; + for (String candidate : candidates) { + try { + Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json"); + if (Files.exists(jsonPath)) { + String content = Files.readString(jsonPath); + JSONObject json = new JSONObject(content); + if (json.has("+mcpVersion")) { + return json.getString("+mcpVersion"); + } + if (json.has("mcpVersion")) { + return json.getString("mcpVersion"); + } + } + } catch (Exception ignored) {} + } + // Fallback: known MCP versions for common MC releases + switch (mcVersion) { + case "1.21": return "20240808.143909"; + case "1.20.6": return "20240429.145301"; + case "1.20.4": return "20231214.122116"; + case "1.20.3": return "20231214.122116"; + case "1.20.2": return "20231119.165538"; + case "1.20.1": return "20231207.220116"; + case "1.20": return "20231013.142340"; + case "1.19.4": return "20230316.153408"; + case "1.19.3": return "20221207.173059"; + case "1.19.2": return "20221124.190222"; + case "1.19.1": return "20220810.141647"; + case "1.19": return "20220617.144144"; + case "1.18.2": return "20220404.170452"; + case "1.18.1": return "20220116.232031"; + case "1.18": return "20211214.190725"; + case "1.17.1": return "20210727.130541"; + case "1.17": return "20210701.125435"; + default: return null; + } + } + + private List resolveGameArguments(VersionManifest manifest, LaunchOptions options) { + List args = new ArrayList<>(); + Map vars = buildVariableMap(options); + + for (String raw : manifest.getGameArguments()) { + args.add(resolveVariable(raw, vars)); + } + + if (options.getWidth() > 0) { + args.add("--width"); + args.add(String.valueOf(options.getWidth())); + } + if (options.getHeight() > 0) { + args.add("--height"); + args.add(String.valueOf(options.getHeight())); + } + + return args; + } + + private List getVanillaGameArguments(LaunchOptions options) { + List args = new ArrayList<>(); + + args.add("--version"); + args.add(getVersionId()); + args.add("--gameDir"); + args.add(instance.getPath().toAbsolutePath().toString()); + args.add("--assetsDir"); + args.add(instance.getPath().resolve("assets").toAbsolutePath().toString()); + args.add("--assetIndex"); + String assetIndex = instance.getAssetIndex(); + if (assetIndex == null || assetIndex.isEmpty()) { + assetIndex = instance.getMinecraftVersion(); + LauncherLogger.info(ZAnsi.yellow("Asset index not found, using version: " + assetIndex)); + } else { + // Validate that the index file exists. A wrong/stale asset index + // (e.g. the Zern-OBT "1.20.1" bug, real index is "5") makes the game + // fail to load the base resource pack (grey panorama, no sounds). + Path indexPath = instance.getPath().resolve("assets").resolve("indexes").resolve(assetIndex + ".json"); + if (!Files.exists(indexPath)) { + LauncherLogger.info(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index...")); + try (java.util.stream.Stream stream = Files.list(instance.getPath().resolve("assets").resolve("indexes"))) { + java.util.List existing = stream + .filter(p -> p.getFileName().toString().endsWith(".json")) + .sorted() + .collect(java.util.stream.Collectors.toList()); + if (!existing.isEmpty()) { + String found = existing.get(existing.size() - 1).getFileName().toString() + .replace(".json", ""); + LauncherLogger.info(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")")); + assetIndex = found; + } else { + LauncherLogger.info(ZAnsi.yellow("No asset index files found in assets/indexes/")); + } + } catch (Exception e) { + LauncherLogger.info(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage())); + } + } + LauncherLogger.info(ZAnsi.green("Using asset index: " + assetIndex)); + } + args.add(assetIndex); + args.add("--username"); + args.add(options.getUsername() != null ? options.getUsername() : "Player"); + args.add("--accessToken"); + args.add(options.getAccessToken() != null ? options.getAccessToken() : "0"); + args.add("--uuid"); + args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000"); + args.add("--userType"); + args.add("legacy"); + + return args; + } + + private Map buildVariableMap(LaunchOptions options) { + Map vars = new HashMap<>(); + + Path gameDir = instance.getPath().toAbsolutePath(); + Path assetsDir = gameDir.resolve("assets"); + Path nativesDir = gameDir.resolve("natives"); + Path librariesDir = gameDir.resolve("libraries"); + + vars.put("version_name", getVersionId()); + vars.put("game_directory", gameDir.toString()); + vars.put("assets_root", assetsDir.toString()); + vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion()); + vars.put("auth_player_name", options.getUsername() != null ? options.getUsername() : "Player"); + vars.put("auth_access_token", options.getAccessToken() != null ? options.getAccessToken() : "0"); + vars.put("auth_uuid", options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000"); + vars.put("auth_xuid", "0"); + vars.put("user_type", "legacy"); + vars.put("version_type", "release"); + vars.put("natives_directory", nativesDir.toString()); + vars.put("library_directory", librariesDir.toString()); + vars.put("launcher_name", "ZernMC"); + vars.put("launcher_version", "1.0"); + vars.put("classpath_separator", System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"); + vars.put("resolution_width", String.valueOf(options.getWidth() > 0 ? options.getWidth() : 1920)); + vars.put("resolution_height", String.valueOf(options.getHeight() > 0 ? options.getHeight() : 1080)); + vars.put("game_directory", gameDir.toString()); + + String loaderType = instance.getLoaderType().toLowerCase(); + if ("forge".equals(loaderType)) { + vars.put("forge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : ""); + } else if ("neoforge".equals(loaderType)) { + vars.put("neoforge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : ""); + vars.put("fml.neoForgeVersion", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : ""); + vars.put("fml.neoForgeGroup", "net.neoforged"); + } + + return vars; + } + + private String resolveVariable(String raw, Map vars) { + if (!raw.contains("${")) return raw; + String result = raw; + for (Map.Entry entry : vars.entrySet()) { + result = result.replace("${" + entry.getKey() + "}", entry.getValue()); + } + return result; + } + + private boolean referencesClasspath(List args) { + if (args == null) return false; + for (String arg : args) { + if (arg != null && arg.contains("${classpath}")) return true; + } + return false; + } + + private boolean containsCpArg(List args) { + if (args == null) return false; + for (String arg : args) { + if (arg != null && (arg.equals("-cp") || arg.equals("-classpath"))) return true; + } + return false; + } + + private boolean containsLibraryPathArg(List args) { + if (args == null) return false; + for (String arg : args) { + if (arg != null && arg.startsWith("-Djava.library.path=")) return true; + } + return false; + } + + private String buildClasspathFromManifest(VersionManifest manifest, boolean includeVersionJar) throws Exception { + List paths = new ArrayList<>(); + Path librariesDir = instance.getPath().resolve("libraries"); + + LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)")); + + String loaderType = instance.getLoaderType().toLowerCase(); + boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType); + + for (VersionManifest.Library lib : manifest.getAllLibraries()) { + // For Forge/NeoForge: include ALL libraries including net.minecraft:* + // in the classpath string. Forge version.json uses "-p ${classpath}" to + // set BOTH classpath and module path to the same string. The Minecraft + // client JAR must be on the module path so securejarhandler can load it + // as a proper module. Skipping it causes ClassNotFoundException. + Path libPath = lib.artifactPath != null ? librariesDir.resolve(lib.artifactPath) : null; + if (libPath != null && Files.exists(libPath)) { + if (isValidJar(libPath)) { + paths.add(libPath.toAbsolutePath().toString()); + } else { + LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name)); + Files.delete(libPath); + } + } else { + String mavenPath = mavenToPath(lib.name); + Path fallbackPath = librariesDir.resolve(mavenPath); + if (Files.exists(fallbackPath)) { + if (isValidJar(fallbackPath)) { + paths.add(fallbackPath.toAbsolutePath().toString()); + } else { + LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name)); + Files.delete(fallbackPath); + } + } else { + // Last resort: scan libraries dir for a JAR matching this artifact + String artifactName = lib.name.split(":")[1]; + Path found = scanForJar(librariesDir, artifactName); + if (found != null) { + if (isValidJar(found)) { + LauncherLogger.info(ZAnsi.green(" Found by scan: " + lib.name + " → " + librariesDir.relativize(found))); + paths.add(found.toAbsolutePath().toString()); + } else { + LauncherLogger.info(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName())); + Files.delete(found); + } + } else { + LauncherLogger.info(ZAnsi.yellow(" Library not found (even after scan): " + lib.name)); + } + } + } + } + + LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath")); + if (isForgeLike) { + LauncherLogger.info(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")")); + LauncherLogger.info(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions"))); + } + + if (includeVersionJar) { + Path versionJar = findVersionJar(); + if (versionJar != null) { + if (isValidJar(versionJar)) { + paths.add(0, versionJar.toAbsolutePath().toString()); + LauncherLogger.info(ZAnsi.green(" Added version jar: " + versionJar.getFileName())); + } else { + LauncherLogger.info(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName())); + Files.delete(versionJar); + } + } + } + + String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; + return String.join(separator, paths); + } + + private Path scanForJar(Path dir, String artifactName) throws IOException { + if (!Files.exists(dir)) return null; + try (var stream = Files.walk(dir)) { + return stream + .filter(p -> p.toString().endsWith(".jar")) + .filter(p -> { + String name = p.getFileName().toString(); + // Match JARs like "artifactName-version.jar" but not "artifactName-other-version.jar" + return name.startsWith(artifactName + "-") || name.equals(artifactName + ".jar"); + }) + .findFirst() + .orElse(null); + } + } + + private String buildVanillaClasspath() throws Exception { + List paths = new ArrayList<>(); + String versionId = getVersionId(); + Path versionJar = instance.getPath() + .resolve("versions") + .resolve(versionId) + .resolve(versionId + ".jar"); + + if (Files.exists(versionJar)) { + paths.add(versionJar.toAbsolutePath().toString()); + } else { + String mcVersion = instance.getMinecraftVersion(); + Path fallbackJar = instance.getPath() + .resolve("versions") + .resolve(mcVersion) + .resolve(mcVersion + ".jar"); + if (Files.exists(fallbackJar)) { + paths.add(fallbackJar.toAbsolutePath().toString()); + } + } + + Path librariesDir = instance.getPath().resolve("libraries"); + if (Files.exists(librariesDir)) { + try (var stream = Files.walk(librariesDir)) { + stream.filter(p -> p.toString().endsWith(".jar")) + .map(p -> p.toAbsolutePath().toString()) + .forEach(paths::add); + } + } + + String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; + return String.join(separator, paths); + } + + private Path findVersionJar() { + String versionId = getVersionId(); + Path versionsDir = instance.getPath().resolve("versions"); + + Path[] candidates = { + versionsDir.resolve(versionId).resolve(versionId + ".jar"), + versionsDir.resolve(instance.getMinecraftVersion()).resolve(instance.getMinecraftVersion() + ".jar") + }; + + for (Path candidate : candidates) { + if (Files.exists(candidate)) { + return candidate; + } + } + + try { + if (Files.exists(versionsDir)) { + try (var stream = Files.list(versionsDir)) { + return stream + .filter(Files::isDirectory) + .filter(dir -> dir.getFileName().toString().contains("forge") || + dir.getFileName().toString().contains("neoforge")) + .filter(dir -> dir.getFileName().toString().contains(instance.getMinecraftVersion())) + .findFirst() + .map(dir -> dir.resolve(dir.getFileName().toString() + ".jar")) + .filter(Files::exists) + .orElse(null); + } + } + } catch (Exception ignored) {} + + return null; + } + + /** + * Ensure the version jar exists at versions//.jar + * so that securejarhandler creates module "minecraft" (from + * Automatic-Module-Name: minecraft) instead of "_1._20._1" (automatic + * module derived from filename 1.20.1.jar). + * + * Without this, findVersionJar() falls back to versions/1.20.1/1.20.1.jar + * which produces module _1._20._1, causing split-package conflicts with + * the minecraft module. + */ + private void ensureVersionJarForForge() throws IOException { + String versionId = getVersionId(); + String mcVersion = instance.getMinecraftVersion(); + Path versionsDir = instance.getPath().resolve("versions"); + + Path targetDir = versionsDir.resolve(versionId); + Path targetJar = targetDir.resolve(versionId + ".jar"); + + if (Files.exists(targetJar) && isValidJar(targetJar)) { + LauncherLogger.info(ZAnsi.green(" Version jar already exists: " + targetJar)); + return; + } + + Files.createDirectories(targetDir); + + // Copy vanilla client jar — it has Automatic-Module-Name: minecraft + Path vanillaJar = versionsDir.resolve(mcVersion).resolve(mcVersion + ".jar"); + if (Files.exists(vanillaJar) && isValidJar(vanillaJar)) { + Files.copy(vanillaJar, targetJar); + LauncherLogger.info(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + " → " + targetJar)); + return; + } + + LauncherLogger.info(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar)); + } + + /** + * Find the SRG-mapped client jar in Forge libraries. + * Path: libraries/net/minecraftforge/client/-/client---srg.jar + * This jar contains SRG-mapped classes and has Automatic-Module-Name: minecraft. + */ + private Path findSrgClientJar() { + Path librariesDir = instance.getPath().resolve("libraries"); + if (!Files.exists(librariesDir)) return null; + + String mcVersion = instance.getMinecraftVersion(); + String[] searchDirs = { + "net/minecraftforge/client", + "net/minecraft/client", + "net/minecraftforge/forge" + }; + + for (String subdir : searchDirs) { + Path dir = librariesDir.resolve(subdir); + if (!Files.exists(dir)) continue; + try (var stream = Files.list(dir)) { + Path found = stream + .filter(Files::isDirectory) + .filter(d -> d.getFileName().toString().startsWith(mcVersion + "-")) + .flatMap(d -> { + try { return Files.list(d); } catch (Exception e) { return java.util.stream.Stream.empty(); } + }) + .filter(p -> p.getFileName().toString().endsWith("-srg.jar")) + .filter(this::isValidJar) + .findFirst() + .orElse(null); + if (found != null) { + LauncherLogger.info(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")")); + return found; + } + } catch (Exception e) { /* continue */ } + } + + // Fallback: scan libraries root for any *-srg.jar matching mcVersion + try { + Path found = java.nio.file.Files.walk(librariesDir, 5) + .filter(p -> p.toString().endsWith("-srg.jar")) + .filter(p -> p.toString().contains(mcVersion)) + .filter(this::isValidJar) + .findFirst() + .orElse(null); + if (found != null) { + LauncherLogger.info(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")")); + return found; + } + } catch (Exception e) { /* continue */ } + + return null; + } + + private String getModuleName(Path jar) { + try (var jarFile = new java.util.jar.JarFile(jar.toFile())) { + var manifest = jarFile.getManifest(); + if (manifest == null) return "null manifest"; + String name = manifest.getMainAttributes().getValue("Automatic-Module-Name"); + return name != null ? name : "not set"; + } catch (Exception e) { + return "error: " + e.getMessage(); + } + } + + private boolean hasMinecraftModuleName(Path jar) { + try (var jarFile = new java.util.jar.JarFile(jar.toFile())) { + var manifest = jarFile.getManifest(); + if (manifest == null) return false; + String moduleName = manifest.getMainAttributes().getValue("Automatic-Module-Name"); + return "minecraft".equals(moduleName); + } catch (Exception e) { + return false; + } + } + + /** + * Find the patched Forge client jar in libraries/. + * The Forge installer creates it via binarypatcher processor: + * libraries/net/minecraftforge/forge//forge--client.jar + * This jar contains all vanilla MC classes + Forge patches. + * Using it instead of vanilla 1.20.1.jar avoids the _1._20._1 vs minecraft + * split-package conflict, because the filename produces a different automatic + * module name. + */ + private Path findPatchedForgeJar() { + Path librariesDir = instance.getPath().resolve("libraries"); + String mcVersion = instance.getMinecraftVersion(); + String loaderVer = instance.getLoaderVersion(); + String fullVersion = mcVersion + "-" + loaderVer; + + String[] candidates = { + "net/minecraftforge/forge/" + fullVersion + "/forge-" + fullVersion + "-client.jar", + "net/minecraftforge/forge/" + fullVersion + "/forge-" + fullVersion + "-universal.jar", + "net/neoforged/neoforge/" + fullVersion + "/neoforge-" + fullVersion + "-client.jar", + "net/neoforged/neoforge/" + fullVersion + "/neoforge-" + fullVersion + "-universal.jar" + }; + + for (String candidate : candidates) { + Path jarPath = librariesDir.resolve(candidate); + if (Files.exists(jarPath) && isValidJar(jarPath)) { + LauncherLogger.info(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName())); + return jarPath; + } + } + + LauncherLogger.info(ZAnsi.yellow(" No Forge client/universal jar found!")); + return null; + } + + private String mavenToPath(String mavenName) { + String[] parts = mavenName.split(":"); + if (parts.length < 3) return mavenName; + + String group = parts[0].replace('.', '/'); + String artifact = parts[1]; + String version = parts[2]; + + if (parts.length == 4) { + String classifier = parts[3]; + return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + "-" + classifier + ".jar"; + } + + return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + ".jar"; + } + + private List getJvmArguments(LaunchOptions options) { + List jvmArgs = new ArrayList<>(); + + int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096; + jvmArgs.add("-Xmx" + ramMB + "M"); + jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M"); + + jvmArgs.add("-XX:+UseG1GC"); + jvmArgs.add("-XX:+UnlockExperimentalVMOptions"); + jvmArgs.add("-XX:G1NewSizePercent=20"); + jvmArgs.add("-XX:G1ReservePercent=20"); + jvmArgs.add("-XX:MaxGCPauseMillis=50"); + jvmArgs.add("-XX:G1HeapRegionSize=32M"); + + String loaderType = instance.getLoaderType().toLowerCase(); + + if ("fabric".equals(loaderType)) { + jvmArgs.add("--add-modules=ALL-MODULE-PATH"); + jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); + jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED"); + } else if ("forge".equals(loaderType)) { + jvmArgs.add("--add-modules=ALL-MODULE-PATH"); + jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); + jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED"); + } else if ("neoforge".equals(loaderType)) { + jvmArgs.add("--add-modules=ALL-MODULE-PATH"); + jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); + jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED"); + } + + if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) { + jvmArgs.addAll(options.getExtraJvmArgs()); + } + + return jvmArgs; + } + + private boolean isValidJar(Path path) { + if (path == null || !Files.exists(path)) return false; + try (ZipFile zf = new ZipFile(path.toFile())) { + return true; + } catch (ZipException e) { + return false; + } catch (IOException e) { + return false; + } + } + + /** + * Removes classpath entries that are already listed on the module path + * (from -p or --module-path in manifest JVM arguments). Prevents split-package + * conflicts when --add-modules=ALL-MODULE-PATH resolves classpath JARs + * as automatic modules. + */ + private String filterClasspathAgainstModulePath(String classpath, List manifestJvmArgs) { + if (classpath == null || classpath.isEmpty() || manifestJvmArgs == null || manifestJvmArgs.isEmpty()) { + return classpath; + } + + Set modulePathFilenames = new HashSet<>(); + + // Parse -p / --module-path values from manifest JVM arguments + for (int i = 0; i < manifestJvmArgs.size(); i++) { + String arg = manifestJvmArgs.get(i); + if (("-p".equals(arg) || "--module-path".equals(arg)) && i + 1 < manifestJvmArgs.size()) { + String modulePathValue = manifestJvmArgs.get(i + 1); + String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; + for (String entry : modulePathValue.split(sep)) { + int lastSep = Math.max(entry.lastIndexOf('/'), entry.lastIndexOf('\\')); + modulePathFilenames.add(lastSep >= 0 ? entry.substring(lastSep + 1) : entry); + } + } + } + + if (modulePathFilenames.isEmpty()) return classpath; + + String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; + StringBuilder filtered = new StringBuilder(); + for (String entry : classpath.split(separator)) { + if (entry.isEmpty()) continue; + int lastSep = Math.max(entry.lastIndexOf('/'), entry.lastIndexOf('\\')); + String filename = lastSep >= 0 ? entry.substring(lastSep + 1) : entry; + if (!modulePathFilenames.contains(filename)) { + if (filtered.length() > 0) filtered.append(separator); + filtered.append(entry); + } + } + + String result = filtered.toString(); + return result.isEmpty() ? classpath : result; + } + + // For very long classpaths (Windows cmd truncation), write to a temp argfile + private String writeClasspathFile(String classpath) throws IOException { + if (classpath.length() < 8000) return classpath; + Path tempFile = Files.createTempFile("zernmc-cp-", ".txt"); + Files.writeString(tempFile, classpath); + tempFiles.add(tempFile); + LauncherLogger.info(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile)); + return "@" + tempFile.toAbsolutePath(); + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java new file mode 100644 index 0000000..bae1878 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java @@ -0,0 +1,235 @@ +package me.sashegdev.zernmc.launcher.minecraft.launch; + +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class VersionManifest { + + private final String id; + private final String mainClass; + private final String assetIndexId; + private final String inheritsFrom; + private final List jvmArguments; + private final List gameArguments; + private final List libraries; + private VersionManifest parent; + + public VersionManifest(JSONObject json) { + this.id = json.getString("id"); + this.mainClass = json.optString("mainClass", "net.minecraft.client.main.Main"); + + if (json.has("assetIndex")) { + JSONObject ai = json.getJSONObject("assetIndex"); + this.assetIndexId = ai.has("id") ? ai.getString("id") : "unknown"; + } else { + this.assetIndexId = "unknown"; + } + + this.inheritsFrom = json.optString("inheritsFrom", null); + this.jvmArguments = parseArguments(json, "jvm"); + this.gameArguments = parseArguments(json, "game"); + this.libraries = parseLibraries(json); + } + + public String getId() { return id; } + public String getMainClass() { return mainClass; } + public String getAssetIndexId() { return assetIndexId; } + public String getInheritsFrom() { return inheritsFrom; } + public List getJvmArguments() { return jvmArguments; } + public List getGameArguments() { return gameArguments; } + public List getLibraries() { return libraries; } + + public List getAllJvmArguments() { + List all = new ArrayList<>(); + if (parent != null) { + all.addAll(parent.getAllJvmArguments()); + } + all.addAll(jvmArguments); + return all; + } + + public List getAllGameArguments() { + List all = new ArrayList<>(); + if (parent != null) { + all.addAll(parent.getAllGameArguments()); + } + all.addAll(gameArguments); + return all; + } + + public void resolveParent(Path versionsDir) { + if (inheritsFrom == null || parent != null) return; + Path parentDir = versionsDir.resolve(inheritsFrom); + Path parentJson = parentDir.resolve(inheritsFrom + ".json"); + if (Files.exists(parentJson)) { + try { + String content = Files.readString(parentJson); + JSONObject json = new JSONObject(content); + this.parent = new VersionManifest(json); + this.parent.resolveParent(versionsDir); + } catch (Exception e) { + LauncherLogger.warn("Failed to load parent version " + inheritsFrom + ": " + e.getMessage()); + } + } + } + + public List getAllLibraries() { + List all = new ArrayList<>(); + if (parent != null) { + all.addAll(parent.getAllLibraries()); + } + for (Library lib : libraries) { + if (!containsLibrary(all, lib.name)) { + all.add(lib); + } + } + return all; + } + + private boolean containsLibrary(List list, String name) { + for (Library l : list) { + if (l.name.equals(name)) return true; + } + return false; + } + + private List parseArguments(JSONObject json, String type) { + List args = new ArrayList<>(); + if (!json.has("arguments")) return args; + + JSONObject arguments = json.getJSONObject("arguments"); + if (!arguments.has(type)) return args; + + JSONArray arr = arguments.getJSONArray(type); + for (int i = 0; i < arr.length(); i++) { + Object item = arr.get(i); + if (item instanceof String) { + args.add((String) item); + } else if (item instanceof JSONObject) { + JSONObject ruleObj = (JSONObject) item; + if (ruleMatches(ruleObj)) { + Object value = ruleObj.get("value"); + if (value instanceof String) { + args.add((String) value); + } else if (value instanceof JSONArray) { + JSONArray valArr = (JSONArray) value; + for (int j = 0; j < valArr.length(); j++) { + args.add(valArr.getString(j)); + } + } + } + } + } + return args; + } + + private boolean ruleMatches(JSONObject ruleObj) { + JSONArray rules = ruleObj.getJSONArray("rules"); + boolean result = false; + for (int i = 0; i < rules.length(); i++) { + JSONObject rule = rules.getJSONObject(i); + String action = rule.getString("action"); + boolean matches = true; + + if (rule.has("os")) { + JSONObject os = rule.getJSONObject("os"); + String osName = System.getProperty("os.name").toLowerCase(); + if (os.has("name")) { + String reqName = os.getString("name").toLowerCase(); + if (reqName.equals("windows") && !osName.contains("win")) matches = false; + else if (reqName.equals("linux") && !osName.contains("linux") && !osName.contains("nix")) matches = false; + else if (reqName.equals("osx") && !osName.contains("mac")) matches = false; + } + if (os.has("arch")) { + String reqArch = os.getString("arch"); + String osArch = System.getProperty("os.arch"); + if (!reqArch.equals(osArch)) matches = false; + } + } + + if (rule.has("features")) { + JSONObject features = rule.getJSONObject("features"); + for (String key : features.keySet()) { + if (key.startsWith("has_custom_resolution")) { + continue; // Лаунчер сам обрабатывает разрешение + } + if (key.startsWith("is_demo_user")) { + // Лаунчер не использует demo режим, считаем фичу false + matches = false; + break; + } + // Неизвестная фича — считаем false + matches = false; + break; + } + } + + if ("allow".equals(action) && matches) { + result = true; + } else if ("disallow".equals(action) && matches) { + return false; + } + } + return result; + } + + private List parseLibraries(JSONObject json) { + List libs = new ArrayList<>(); + if (!json.has("libraries")) return libs; + + JSONArray arr = json.getJSONArray("libraries"); + for (int i = 0; i < arr.length(); i++) { + JSONObject libJson = arr.getJSONObject(i); + String name = libJson.getString("name"); + String artifactPath = null; + if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) { + artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path"); + } + Library lib = new Library(name, artifactPath); + + if (libJson.has("natives")) { + JSONObject natives = libJson.getJSONObject("natives"); + for (String key : natives.keySet()) { + String osKey = key.toLowerCase(); + lib.natives.put(osKey, natives.getString(key)); + } + } + + boolean allowed = true; + if (libJson.has("rules")) { + JSONObject dummyObj = new JSONObject(); + dummyObj.put("rules", libJson.getJSONArray("rules")); + dummyObj.put("value", ""); + allowed = ruleMatches(dummyObj); + } + if (allowed) { + libs.add(lib); + } + } + return libs; + } + + public static class Library { + public final String name; + public final String artifactPath; + public final Map natives = new HashMap<>(); + + public Library(String name, String artifactPath) { + this.name = name; + this.artifactPath = artifactPath; + } + + public String getSimpleName() { + return name.substring(name.indexOf(':') + 1); + } + } +} diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java similarity index 92% rename from launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java rename to launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java index ea150b6..801a638 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java @@ -37,5 +37,7 @@ public class LaunchOptions { public void setExtraJvmArgs(List extraJvmArgs) { this.extraJvmArgs = extraJvmArgs; } public int getWidth() { return width; } + public void setWidth(int width) { this.width = width; } public int getHeight() { return height; } + public void setHeight(int height) { this.height = height; } } \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/MinecraftVersion.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/model/MinecraftVersion.java similarity index 100% rename from launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/MinecraftVersion.java rename to launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/model/MinecraftVersion.java diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/ArrowMenu.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/ArrowMenu.java new file mode 100644 index 0000000..866214e --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/ArrowMenu.java @@ -0,0 +1,133 @@ +package me.sashegdev.zernmc.launcher.ui; + +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.jline.utils.InfoCmp; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +public class ArrowMenu { + + private final String title; + private final List options; + private int selected = 0; + private final Terminal terminal; + private final InputStream rawInput; + + private static final int VISIBLE_ITEMS = 7; + + public ArrowMenu(String title, List options) throws IOException { + this.title = title; + this.options = options; + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows"); + System.setProperty("jline.terminal", isWindows ? "win" : "unsupported"); + this.terminal = TerminalBuilder.builder() + .system(true) + .jna(isWindows) + .jansi(true) + .encoding(StandardCharsets.UTF_8) + .build(); + this.rawInput = terminal.input(); + } + + public int show() throws IOException { + terminal.enterRawMode(); + terminal.puts(InfoCmp.Capability.clear_screen); + terminal.puts(InfoCmp.Capability.cursor_invisible); + + try { + while (true) { + printPagedMenu(); + int b = rawInput.read(); + if (b == -1) continue; + + // w/W/k/K or ц (0xD1 0x86) = up + // s/S/j/J or ы (0xD1 0x8B) = down + if (b == 'w' || b == 'W' || b == 'k' || b == 'K') { + selected = (selected - 1 + options.size()) % options.size(); + } + else if (b == 's' || b == 'S' || b == 'j' || b == 'J') { + selected = (selected + 1) % options.size(); + } + // ESC sequences: arrows + cyrillic start byte + else if (b == 0x1B) { + int next = nonBlockingRead(); + if (next == -1) { + return -1; + } + if (next == 0x5B || next == 0x4F) { // '[' (CSI) or 'O' (SS3) + int arrow = nonBlockingRead(); + if (arrow == 0x41) { // Up + selected = (selected - 1 + options.size()) % options.size(); + } else if (arrow == 0x42) { // Down + selected = (selected + 1) % options.size(); + } + } + } + else if (b == 0xD1) { + int second = nonBlockingRead(); + if (second == 0x86) { // ц + selected = (selected - 1 + options.size()) % options.size(); + } else if (second == 0x8B) { // ы + selected = (selected + 1) % options.size(); + } + } + else if (b == 13 || b == 10) { + return selected; + } + } + } finally { + terminal.puts(InfoCmp.Capability.cursor_visible); + terminal.close(); + } + } + + private int nonBlockingRead() throws IOException { + long startTime = System.currentTimeMillis(); + while (System.currentTimeMillis() - startTime < 100) { + if (rawInput.available() > 0) { + return rawInput.read(); + } + try { + Thread.sleep(2); + } catch (InterruptedException e) { + return -1; + } + } + return -1; + } + + private void printPagedMenu() { + StringBuilder sb = new StringBuilder(); + sb.append("\033[H\033[2J"); + + sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n"); + sb.append(ZAnsi.yellow(title)).append("\n\n"); + + int start = Math.max(0, selected - (VISIBLE_ITEMS / 2)); + int end = Math.min(options.size(), start + VISIBLE_ITEMS); + + if (end - start < VISIBLE_ITEMS && start > 0) { + start = Math.max(0, end - VISIBLE_ITEMS); + } + + for (int i = start; i < end; i++) { + String line = options.get(i); + if (i == selected) { + sb.append(ZAnsi.selected(line)).append("\n"); + } else { + sb.append(ZAnsi.white(" " + line)).append("\n"); + } + } + + sb.append("\n") + .append(ZAnsi.white("W/S or \u2191/\u2193 - navigate | Enter - select | Esc - back")); + + System.out.print(sb); + System.out.flush(); + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXBridge.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXBridge.java new file mode 100644 index 0000000..c6f4c31 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXBridge.java @@ -0,0 +1,101 @@ +package me.sashegdev.zernmc.launcher.ui.jfx; + +/** + * Safe reflection bridge to JFXLauncher for use from non-JFX code (CLI, menus, + * installers). + * + * IMPORTANT: JFXLauncher extends javafx.application.Application. In CLI mode + * JavaFX is NOT on the module path, so loading JFXLauncher (or any class that + * references JavaFX) throws NoClassDefFoundError (an Error, not an Exception). + * Therefore the bridge must catch Throwable and never let JFX-launcher errors + * leak into CLI/game-launch code paths. + * + * Rule: any call into JFX UI state from non-JFX context MUST go through this + * bridge. Direct JFXLauncher.x() calls are only allowed inside JFXLauncher.java. + */ +public final class JFXBridge { + + private JFXBridge() {} + + private static final String JFX_CLASS = "me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher"; + + private static volatile java.lang.reflect.Method appendGameLogMethod; + private static volatile java.lang.reflect.Method appendLauncherLogFromLoggerMethod; + private static volatile java.lang.reflect.Method setInstallProgressMethod; + private static volatile java.lang.reflect.Method setInstallStageMethod; + + private static java.lang.reflect.Method method(String name, Class... params) { + java.lang.reflect.Method m = null; + try { + Class clazz = Class.forName(JFX_CLASS); + m = clazz.getMethod(name, params); + } catch (Throwable t) { + // JavaFX not available (CLI mode) or JFXLauncher not initialized. + } + return m; + } + + /** Forward a game log line to the JFX UI. Safe no-op in CLI mode. */ + public static void appendGameLog(String msg) { + if (appendGameLogMethod == null) { + appendGameLogMethod = method("appendGameLog", String.class); + } + if (appendGameLogMethod == null) { + return; + } + try { + appendGameLogMethod.invoke(null, msg); + } catch (Throwable t) { + appendGameLogMethod = null; + } + } + + /** + * Forward a launcher log line to the JFX UI without re-logging it through + * LauncherLogger. Called from LauncherLogger.write so all logged lines + * (including installer output) become visible in the UI. Safe no-op in CLI mode. + */ + public static void appendLauncherLogFromLogger(String msg) { + if (appendLauncherLogFromLoggerMethod == null) { + appendLauncherLogFromLoggerMethod = method("appendLauncherLogFromLogger", String.class); + } + if (appendLauncherLogFromLoggerMethod == null) { + return; + } + try { + appendLauncherLogFromLoggerMethod.invoke(null, msg); + } catch (Throwable t) { + appendLauncherLogFromLoggerMethod = null; + } + } + + /** Forward install progress to the JFX UI. Safe no-op in CLI mode. */ + public static void setInstallProgress(String label, int current, int total) { + if (setInstallProgressMethod == null) { + setInstallProgressMethod = method("setInstallProgress", String.class, int.class, int.class); + } + if (setInstallProgressMethod == null) { + return; + } + try { + setInstallProgressMethod.invoke(null, label, current, total); + } catch (Throwable t) { + setInstallProgressMethod = null; + } + } + + /** Forward install stage to the JFX UI. Safe no-op in CLI mode. */ + public static void setInstallStage(String stageName, int stageIndex, int stageCount) { + if (setInstallStageMethod == null) { + setInstallStageMethod = method("setInstallStage", String.class, int.class, int.class); + } + if (setInstallStageMethod == null) { + return; + } + try { + setInstallStageMethod.invoke(null, stageName, stageIndex, stageCount); + } catch (Throwable t) { + setInstallStageMethod = null; + } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java new file mode 100644 index 0000000..06029e9 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -0,0 +1,1887 @@ +package me.sashegdev.zernmc.launcher.ui.jfx; + +import javafx.application.Application; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.web.WebView; +import javafx.scene.web.WebEngine; +import javafx.stage.Stage; +import javafx.concurrent.Worker; +import com.google.gson.Gson; +import me.sashegdev.zernmc.launcher.api.LauncherAPI; +import me.sashegdev.zernmc.launcher.api.launch.LaunchService; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; +import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib; +import me.sashegdev.zernmc.launcher.minecraft.PackDownloader; +import me.sashegdev.zernmc.launcher.minecraft.ServerPack; +import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.DomainSelector; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.Version; + +import java.awt.Desktop; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; +import java.net.URL; +import java.net.URLDecoder; +import java.util.Base64; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.Headers; + +public class JFXLauncher extends Application { + private static int PORT = 8080; + private static final String APP_TITLE = "ZernMC Launcher"; + private static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "https://api.zernmc.ru"); + private final LauncherAPI api = new LauncherAPI(); + private final Gson gson = new Gson(); + private HttpServer server; + private static StringBuilder launcherLogBuffer = new StringBuilder(); + private static StringBuilder gameLogBuffer = new StringBuilder(); + private static Path gameLogFile; + private static Path launcherLogFile; + private Stage mainStage; + + private static volatile String installProgressLabel = ""; + private static volatile int installProgressCurrent = 0; + private static volatile int installProgressTotal = 100; + private static final AtomicBoolean installInProgress = new AtomicBoolean(false); + private static volatile String installStageName = ""; + private static volatile int installStageIndex = 0; + private static volatile int installStageCount = 1; + + private static final java.util.concurrent.CopyOnWriteArrayList logConsumers = new java.util.concurrent.CopyOnWriteArrayList<>(); + private static final java.util.concurrent.CopyOnWriteArrayList gameLogConsumers = new java.util.concurrent.CopyOnWriteArrayList<>(); + + public interface LogConsumer { + void onLog(String line); + } + + public static void addLogConsumer(LogConsumer consumer) { + logConsumers.add(consumer); + } + + public static void removeLogConsumer(LogConsumer consumer) { + logConsumers.remove(consumer); + } + + public static void addGameLogConsumer(LogConsumer consumer) { + gameLogConsumers.add(consumer); + } + + public static void removeGameLogConsumer(LogConsumer consumer) { + gameLogConsumers.remove(consumer); + } + + public static void setInstallProgress(String label, int current, int total) { + installProgressLabel = label; + installProgressCurrent = current; + installProgressTotal = total; + } + + public static void setInstallStage(String stageName, int stageIndex, int stageCount) { + installStageName = stageName; + installStageIndex = stageIndex; + installStageCount = stageCount; + } + + public static void setInstallProgressWithStage(String label, int current, int total, String stageName, int stageIndex, int stageCount) { + installProgressLabel = label; + installProgressCurrent = current; + installProgressTotal = total; + installStageName = stageName; + installStageIndex = stageIndex; + installStageCount = stageCount; + } + + public static void setInstallInProgress(boolean inProgress) { + installInProgress.set(inProgress); + } + + public static boolean isInstallInProgress() { + return installInProgress.get(); + } + + public static void appendLauncherLog(String msg) { + synchronized (launcherLogBuffer) { + launcherLogBuffer.append(msg).append("\n"); + } + LauncherLogger.info("[EXT] " + msg); + for (LogConsumer consumer : logConsumers) { + try { consumer.onLog(msg); } catch (Exception ignored) {} + } + } + + /** + * Adds a line to the UI log buffer without re-logging it through + * LauncherLogger (avoids recursion when called from LauncherLogger.write). + */ + public static void appendLauncherLogFromLogger(String msg) { + synchronized (launcherLogBuffer) { + launcherLogBuffer.append(msg).append("\n"); + } + for (LogConsumer consumer : logConsumers) { + try { consumer.onLog(msg); } catch (Exception ignored) {} + } + } + + public static void appendGameLog(String msg) { + synchronized (gameLogBuffer) { + gameLogBuffer.append(msg).append("\n"); + + if (gameLogFile != null) { + try { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")); + Files.writeString(gameLogFile, "[" + timestamp + "] " + msg + "\n", + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (Exception ignored) {} + } + } + LauncherLogger.info("[GAME] " + msg); + for (LogConsumer consumer : gameLogConsumers) { + try { consumer.onLog(msg); } catch (Exception ignored) {} + } + } + + public static void initGameLog(Path instanceDir) { + synchronized (gameLogBuffer) { + gameLogBuffer.setLength(0); + } + try { + Path logsDir = instanceDir.resolve("logs"); + Files.createDirectories(logsDir); + gameLogFile = logsDir.resolve("game.log"); + Files.writeString(gameLogFile, "=== Game Log " + LocalDateTime.now() + " ===\n", + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (Exception ignored) {} + } + + public static void clearGameLog() { + synchronized (gameLogBuffer) { + gameLogBuffer.setLength(0); + } + } + + public static String getGameLogs() { + synchronized (gameLogBuffer) { + return gameLogBuffer.toString(); + } + } + + public static String getLauncherLogs() { + synchronized (launcherLogBuffer) { + return launcherLogBuffer.toString(); + } + } + + public static void main(String[] args) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + LauncherLogger.info("[JFX] Shutdown signal received..."); + LaunchService.killAllProcesses(); + })); + launch(args); + } + + private static final byte[] PNG_SIGNATURE = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + + private static javafx.scene.image.Image loadWindowIcon() { + try { + byte[] ico = readAssetBytes("assets/ui/icons/zernmc.ico"); + if (ico != null) { + byte[] png = extractIcoPngFrame(ico); + if (png != null) { + return new javafx.scene.image.Image(new java.io.ByteArrayInputStream(png)); + } + } + byte[] png = readAssetBytes("assets/ui/icons/zernmc.png"); + if (png != null) { + return new javafx.scene.image.Image(new java.io.ByteArrayInputStream(png)); + } + } catch (Exception e) { + log("Window icon load error: " + e.getMessage()); + } + return null; + } + + private static byte[] readAssetBytes(String resourcePath) { + try (InputStream in = JFXLauncher.class.getResourceAsStream("/" + resourcePath)) { + if (in != null) { + return in.readAllBytes(); + } + } catch (Exception ignored) {} + try { + Path file = Paths.get(resourcePath); + if (Files.exists(file)) { + return Files.readAllBytes(file); + } + } catch (Exception ignored) {} + return null; + } + + private static byte[] extractIcoPngFrame(byte[] ico) { + if (ico == null || ico.length < 6 || ico[0] != 0 || ico[1] != 0) { + return null; + } + int count = (ico[4] & 0xFF) | ((ico[5] & 0xFF) << 8); + int bestSize = -1; + int bestOffset = -1; + int bestLength = -1; + for (int i = 0; i < count; i++) { + int off = 6 + i * 16; + if (off + 16 > ico.length) break; + int w = ico[off] & 0xFF; + int h = ico[off + 1] & 0xFF; + int size = (w == 0 ? 256 : w) * (h == 0 ? 256 : h); + int len = (ico[off + 8] & 0xFF) | ((ico[off + 9] & 0xFF) << 8) + | ((ico[off + 10] & 0xFF) << 16) | ((ico[off + 11] & 0xFF) << 24); + int imgOff = (ico[off + 12] & 0xFF) | ((ico[off + 13] & 0xFF) << 8) + | ((ico[off + 14] & 0xFF) << 16) | ((ico[off + 15] & 0xFF) << 24); + if (imgOff < 0 || len <= 0 || imgOff + len > ico.length) continue; + boolean isPng = true; + for (int b = 0; b < PNG_SIGNATURE.length; b++) { + if (ico[imgOff + b] != PNG_SIGNATURE[b]) { isPng = false; break; } + } + if (isPng && size > bestSize) { + bestSize = size; + bestOffset = imgOff; + bestLength = len; + } + } + if (bestOffset < 0 || bestLength <= 0) return null; + byte[] png = new byte[bestLength]; + System.arraycopy(ico, bestOffset, png, 0, bestLength); + return png; + } + + private static void extractAssets() { + try { + Path assetsDir = Paths.get("assets"); + String currentVersion = Version.getCurrentVersion(); + Path versionFile = assetsDir.resolve(".version"); + + // Check if assets already match current version + if (Files.exists(assetsDir) && Files.exists(versionFile)) { + try { + String existingVersion = Files.readString(versionFile).trim(); + if (existingVersion.equals(currentVersion)) { + log("Assets up to date (v" + currentVersion + ")"); + return; + } + } catch (Exception ignored) {} + } + + // Clean old assets + if (Files.exists(assetsDir)) { + try { + Files.walk(assetsDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} }); + } catch (Exception ignored) {} + } + + String serverVersion = getServerVersion(); + if (serverVersion != null && !serverVersion.isEmpty()) { + log("Loading assets via meta for version " + serverVersion); + if (downloadAssetsFromMeta(serverVersion)) { + log("Assets loaded via meta"); + Files.writeString(versionFile, currentVersion); + return; + } + log("Meta unavailable, using fallback"); + } + + log("Extracting assets from JAR..."); + Files.createDirectories(assetsDir); + Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) { + try (JarFile jar = new JarFile(jarPath.toFile())) { + var entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.getName().startsWith("assets/")) { + Path outPath = assetsDir.resolve(entry.getName().substring(7)); + if (entry.isDirectory()) { + Files.createDirectories(outPath); + } else { + Files.createDirectories(outPath.getParent()); + try (InputStream is = jar.getInputStream(entry)) { + Files.copy(is, outPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + } + log("Assets extracted from JAR"); + Files.writeString(versionFile, currentVersion); + } + } catch (Exception e) { + log("Error extracting assets: " + e.getMessage()); + } + } + + private static String getServerVersion() { + try { + URL url = new URL(LAUNCHER_SERVER + "/launcher/version"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + 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); + org.json.JSONObject obj = new org.json.JSONObject(sb.toString()); + if (obj.has("version")) { + return obj.getString("version"); + } + } + } + } catch (Exception ignored) {} + return null; + } + + private static boolean downloadAssetsFromMeta(String version) { + try { + URL metaUrl = new URL(LAUNCHER_SERVER + "/launcher/meta/" + version); + HttpURLConnection conn = (HttpURLConnection) metaUrl.openConnection(); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + if (conn.getResponseCode() != 200) return false; + + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) sb.append(line); + org.json.JSONObject meta = new org.json.JSONObject(sb.toString()); + + Path assetsDir = Paths.get("assets"); + Files.createDirectories(assetsDir); + + for (Object fileObj : meta.getJSONArray("files")) { + org.json.JSONObject file = (org.json.JSONObject) fileObj; + String path = file.getString("path"); + if (path.startsWith("assets/")) { + String downloadUrl = LAUNCHER_SERVER + "/launcher/file/" + version + "/" + path; + Path outPath = assetsDir.resolve(path.substring(7)); + Files.createDirectories(outPath.getParent()); + + URL fileUrl = new URL(downloadUrl); + HttpURLConnection fileConn = (HttpURLConnection) fileUrl.openConnection(); + fileConn.setConnectTimeout(10000); + fileConn.setReadTimeout(30000); + if (fileConn.getResponseCode() == 200) { + try (InputStream is = fileConn.getInputStream()) { + Files.copy(is, outPath); + } + } + fileConn.disconnect(); + } + } + return true; + } + } catch (Exception e) { + log("Error loading assets via meta: " + e.getMessage()); + return false; + } + } + + @Override + public void start(Stage stage) { + this.mainStage = stage; + + try { + launcherLogFile = LauncherLogger.getLogFile(); + + extractAssets(); + log("Starting JFX UI..."); + log("Loading saved session..."); + boolean sessionLoaded = false; + try { + sessionLoaded = AuthManager.loadSavedSession(); + } catch (Exception e) { + log("Session load error: " + e.getMessage()); + } + if (sessionLoaded) { + log("Session loaded for: " + AuthManager.getUsername()); + log("Pass active: " + AuthManager.hasActivePass()); + log("Role: " + AuthManager.getRole() + " (" + AuthManager.getRoleName() + ")"); + } else { + log("No saved session found, will show login screen"); + } + log("Starting background network init..."); + Thread netInitThread = new Thread(() -> { + try { + DomainSelector.selectBest(); + ZHttpClient.checkAllServicesOnStartup(); + log("Network init complete"); + } catch (Exception e) { + log("Network init warning: " + e.getMessage()); + } + }, "network-init"); + netInitThread.setDaemon(true); + netInitThread.start(); + startServer(); + + WebView webView = new WebView(); + webView.setContextMenuEnabled(false); + WebEngine engine = webView.getEngine(); + engine.setJavaScriptEnabled(true); + + engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { + log("[UI] Load state: " + oldState + " -> " + newState); + if (newState == Worker.State.SUCCEEDED) { + log("Page loaded"); + } else if (newState == Worker.State.FAILED) { + log("[UI] Load FAILED: " + engine.getLoadWorker().getException()); + } + }); + + // Capture JS console.log/error via alert bridge + engine.setOnAlert(e -> { + String msg = e.getData(); + if (msg.startsWith("[LOG] ")) { + log("[JS] " + msg.substring(6)); + } else if (msg.startsWith("[ERR] ")) { + log("[JS ERROR] " + msg.substring(6)); + } else { + log("[UI] Alert: " + msg); + } + }); + + engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { + if (newState == Worker.State.SUCCEEDED) { + // Override console.log/error to use alert bridge + engine.executeScript( + "if (!window._consoleOverridden) {" + + " window._consoleOverridden = true;" + + " var _origLog = console.log;" + + " var _origErr = console.error;" + + " console.log = function() {" + + " var args = Array.prototype.slice.call(arguments).map(function(a){return typeof a==='object'?JSON.stringify(a):String(a)});" + + " alert('[LOG] ' + args.join(' '));" + + " _origLog.apply(console, arguments);" + + " };" + + " console.error = function() {" + + " var args = Array.prototype.slice.call(arguments).map(function(a){return typeof a==='object'?JSON.stringify(a):String(a)});" + + " alert('[ERR] ' + args.join(' '));" + + " _origErr.apply(console, arguments);" + + " };" + + "}" + ); + engine.executeScript( + "window.open = function(url, name, features) {" + + " var req = new XMLHttpRequest();" + + " req.open('POST', '/api/open-url', false);" + + " req.send(JSON.stringify({url: url}));" + + " return window;" + + "};" + + "document.addEventListener('click', function(e) {" + + " var target = e.target;" + + " while (target && target.tagName !== 'A') target = target.parentNode;" + + " if (target && target.href && target.href !== '#' && !target.href.startsWith('http://localhost:" + PORT + "/')) {" + + " e.preventDefault();" + + " var req = new XMLHttpRequest();" + + " req.open('POST', '/api/open-url', false);" + + " req.send(JSON.stringify({url: target.href}));" + + " }" + + "}, true);" + ); + } + }); + + String url = "http://localhost:" + PORT + "/assets/ui/index.html"; + engine.load(url); + + javafx.scene.image.Image windowIcon = loadWindowIcon(); + if (windowIcon != null) { + stage.getIcons().add(windowIcon); + } + + stage.setTitle(APP_TITLE); + int winW = Config.getWindowWidth(); + int winH = Config.getWindowHeight(); + stage.setWidth(Math.max(winW, 800)); + stage.setHeight(Math.max(winH, 600)); + stage.setMinWidth(800); + stage.setMinHeight(600); + stage.setScene(new Scene(webView)); + stage.show(); + + log("Window displayed"); + + stage.setOnCloseRequest(e -> { + log("Shutdown signal received..."); + shutdown(); + Platform.exit(); + Thread watchdog = new Thread(() -> { + try { + Thread.sleep(2000); + } catch (InterruptedException ignored) {} + System.exit(0); + }, "force-exit"); + watchdog.setDaemon(true); + watchdog.start(); + }); + + } catch (Exception e) { + log("Error: " + e.getMessage()); + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private void startServer() throws Exception { + int maxPortAttempts = 20; + for (int port = PORT; port < PORT + maxPortAttempts; port++) { + try { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + PORT = port; + break; + } catch (java.net.BindException e) { + if (port == PORT + maxPortAttempts - 1) throw e; + } + } + + server.createContext("/api/login", this::handleLogin); + server.createContext("/api/auto-login", this::handleAutoLogin); + server.createContext("/api/account", this::handleAccount); + server.createContext("/api/instances", this::handleInstances); + server.createContext("/api/launch", this::handleLaunch); + server.createContext("/api/offline/status", this::handleOfflineStatus); + server.createContext("/api/offline-launch", this::handleOfflineLaunch); + server.createContext("/api/install", this::handleInstall); + server.createContext("/api/install/progress", this::handleInstallProgress); + server.createContext("/api/network/status", this::handleNetworkStatus); + server.createContext("/api/logs", this::handleLogs); + server.createContext("/api/logs/stream", this::handleLogsStream); + server.createContext("/api/game-logs", this::handleGameLogs); + server.createContext("/api/game-logs/stream", this::handleGameLogsStream); + server.createContext("/api/mc-versions", this::handleMCVersions); + server.createContext("/api/loader-versions", this::handleLoaderVersions); + server.createContext("/api/packs", this::handlePacks); + server.createContext("/api/settings", this::handleSettings); + server.createContext("/api/activate-pass", this::handleActivatePass); + server.createContext("/api/register", this::handleRegister); + server.createContext("/api/pack-info", this::handlePackInfo); + server.createContext("/api/shutdown", this::handleShutdown); + server.createContext("/api/exit", this::handleExit); + server.createContext("/api/news", this::handleNews); + server.createContext("/api/open-url", this::handleOpenUrl); + server.createContext("/api/exit-parent", this::handleExitParent); + server.createContext("/api/system-info", this::handleSystemInfo); + server.createContext("/api/open-log-file", this::handleOpenLogFile); + server.createContext("/api/clipboard", this::handleClipboard); + server.createContext("/api/friends/list", this::handleFriendList); + server.createContext("/api/friends/add", this::handleFriendAdd); + server.createContext("/api/friends/remove", this::handleFriendRemove); + server.createContext("/api/friends/accept", this::handleFriendAccept); + server.createContext("/api/friends/requests", this::handleFriendRequests); + server.createContext("/api/friends/status", this::handleFriendStatus); + server.createContext("/api/check-updates", this::handleCheckUpdates); + server.createContext("/api/update", this::handleUpdate); + server.createContext("/api/playtime/sync", this::handlePlaytimeSync); + server.createContext("/api/playtime/stats", this::handlePlaytimeStats); + server.createContext("/api/whitelist/mods", this::handleWhitelistMods); + server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod); + server.createContext("/api/admin", this::handleAdmin); + server.createContext("/assets/", this::handleStatic); + + server.setExecutor(Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "http-server-" + server.getAddress().getPort()); + t.setDaemon(true); + return t; + })); + server.start(); + + log("HTTP server on port " + PORT); + } + + private void stopServer() { + if (server != null) server.stop(0); + } + + private void handleSystemInfo(HttpExchange exchange) { + try { + Map info = new HashMap<>(); + info.put("cpuCores", Config.getSystemCpuCores()); + info.put("totalRamMB", Config.getSystemTotalRamMB()); + info.put("systemJvmFlags", Config.getSystemJvmFlags()); + sendJson(exchange, Map.of("success", true, "data", info)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleOpenLogFile(HttpExchange exchange) { + try { + Path target = null; + if (gameLogFile != null && Files.exists(gameLogFile)) { + target = gameLogFile; + } else if (launcherLogFile != null && Files.exists(launcherLogFile)) { + target = launcherLogFile; + } + if (target != null) { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(target.toFile()); + sendJson(exchange, Map.of("success", true, "file", target.toString())); + } else { + sendJson(exchange, Map.of("success", false, "error", "Desktop not supported")); + } + } else { + sendJson(exchange, Map.of("success", false, "error", "No log file found")); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleClipboard(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String text = body != null ? body.get("text") : null; + if (text == null) { + sendJson(exchange, Map.of("success", false, "error", "Empty clipboard text")); + return; + } + java.awt.Toolkit.getDefaultToolkit().getSystemClipboard().setContents( + new java.awt.datatransfer.StringSelection(text), null); + sendJson(exchange, Map.of("success", true)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleOpenUrl(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String url = body.get("url"); + if (url != null && !url.isEmpty()) { + openInBrowser(url); + } + sendJson(exchange, Map.of("success", true)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLogin(HttpExchange exchange) { + try { + log("handleLogin: " + exchange.getRequestMethod() + " from " + exchange.getRemoteAddress()); + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, Map.of("success", false, "error", "Method not supported")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String username = body.get("username"); + String password = body.get("password"); + + var result = api.login(username, password); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("username", result.getData().getUsername()); + data.put("token", result.getData().getToken()); + data.put("passActive", AuthManager.hasActivePass()); + data.put("role", AuthManager.getRole()); + data.put("roleName", AuthManager.getRoleName()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Login success: " + username); + } else { + log("Login failed: " + result.getError()); + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + log("Login error: " + AuthManager.friendlyConnectionError(e)); + sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e))); + } + } + + private void handleAccount(HttpExchange exchange) { + try { + log("handleAccount: isLoggedIn=" + api.isLoggedIn()); + if (!api.isLoggedIn()) { + log("handleAccount: not authenticated"); + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + Map data = new HashMap<>(); + data.put("username", api.getCurrentUsername()); + data.put("passActive", AuthManager.hasActivePass()); + data.put("role", AuthManager.getRole()); + data.put("roleName", AuthManager.getRoleName()); + sendJson(exchange, Map.of("success", true, "data", data)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleAutoLogin(HttpExchange exchange) { + try { + boolean loggedIn = AuthManager.isLoggedIn(); + boolean authExists = AuthManager.authFileExists(); + log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists); + if (AuthManager.tryAutoLogin()) { + Map data = new HashMap<>(); + data.put("username", AuthManager.getUsername()); + data.put("passActive", AuthManager.hasActivePass()); + data.put("role", AuthManager.getRole()); + data.put("roleName", AuthManager.getRoleName()); + sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true)); + log("Auto-login performed: " + AuthManager.getUsername()); + } else { + log("handleAutoLogin: no valid session (authFile=" + authExists + ")"); + sendJson(exchange, Map.of("success", false, "autoLogin", false)); + } + } catch (Exception e) { + log("handleAutoLogin error: " + e.getMessage()); + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleInstances(HttpExchange exchange) { + try { + var result = api.getAllInstances(); + Map response = new HashMap<>(); + response.put("success", result.isSuccess()); + if (result.isSuccess()) { + response.put("data", result.getData()); + } else { + response.put("error", result.getError()); + } + sendJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLaunch(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Pack not found")); + return; + } + + log("handleLaunch: " + name + " isServerPack=" + instance.isServerPack() + " hasPass=" + AuthManager.hasActivePass() + " isLoggedIn=" + api.isLoggedIn()); + + if (instance.isServerPack() && !AuthManager.hasActivePass()) { + log("handleLaunch: server pack requires active pass"); + sendJson(exchange, Map.of("success", false, "error", "Server pack requires an active pass")); + return; + } + + var result = api.launch(name); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("pid", result.getData().getPid()); + data.put("status", result.getData().getStatus()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Launched: " + name + " pid=" + result.getData().getPid()); + } else { + log("Launch failed: " + result.getError()); + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + log("handleLaunch error: " + e.getMessage()); + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleOfflineStatus(HttpExchange exchange) { + try { + boolean offline = isOfflineMode(); + sendJson(exchange, Map.of("success", true, "offline", offline)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleOfflineLaunch(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + String nickname = body.get("nickname"); + + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Pack not found")); + return; + } + + var result = api.launch().launchOffline(name, nickname); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("pid", result.getData().getPid()); + data.put("status", result.getData().getStatus()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Offline launched: " + name + " pid=" + result.getData().getPid()); + } else { + log("Offline launch failed: " + result.getError()); + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + log("handleOfflineLaunch error: " + e.getMessage()); + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private boolean isOfflineMode() { + if ("true".equalsIgnoreCase(System.getProperty("zernmc.offline", "false"))) { + return true; + } + if (ZHttpClient.isNetworkInitialized() && !ZHttpClient.isZernServerHealthy()) { + return true; + } + return false; + } + + private void handleInstall(HttpExchange exchange) { + if (!installInProgress.compareAndSet(false, true)) { + sendJson(exchange, Map.of("success", false, "error", "Installation already in progress")); + return; + } + + try { + if (!api.isLoggedIn()) { + installInProgress.set(false); + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + String version = body.get("version"); + String loader = body.get("loader"); + String loaderVersion = body.get("loaderVersion"); + String preset = body.get("preset"); + + log("Install: " + name + " " + version + " " + loader + (loaderVersion != null ? " " + loaderVersion : "") + (preset != null ? " preset=" + preset : "")); + + var createResult = api.instances().createInstance(name); + if (!createResult.isSuccess()) { + installInProgress.set(false); + sendJson(exchange, Map.of("success", false, "error", createResult.getError())); + return; + } + + Instance instance = InstanceManager.getInstance(name); + if (instance != null) { + if (!"zernmc".equalsIgnoreCase(loader)) { + instance.setMinecraftVersion(version); + instance.setLoaderType(loader); + if (loaderVersion != null) { + instance.setLoaderVersion(loaderVersion); + } + } + + sendJson(exchange, Map.of("success", true, "message", "Installation started")); + + setInstallProgressWithStage("Preparing...", 0, 100, "Preparing", 0, 4); + + Thread installThread = new Thread(() -> { + try { + boolean success = false; + + if (!ZHttpClient.isNetworkInitialized()) { + log("Network init not finished, running full service check (CLI-style)..."); + ZHttpClient.checkAllServicesOnStartup(); + } + + if ("zernmc".equalsIgnoreCase(loader)) { + log("[DEBUG] Starting zernmc pack install for version=" + version); + setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5); + PackDownloader downloader = new PackDownloader(instance); + downloader.setProgressCallback((label, pct, stageName, stageIdx, stageCount) -> { + setInstallProgressWithStage(label, pct, 100, stageName, stageIdx, stageCount); + }); + log("[DEBUG] Getting available packs..."); + var packs = downloader.getAvailablePacks(); + log("[DEBUG] Available packs: " + (packs != null ? packs.size() : "null")); + ServerPack pack = null; + for (ServerPack p : packs) { + log("[DEBUG] Checking pack: " + p.getName() + " vs " + version); + if (p.getName().equals(version)) { + pack = p; + break; + } + } + log("[DEBUG] Selected pack: " + (pack != null ? pack.getName() : "null")); + if (pack != null) { + try { + log("[DEBUG] Calling installOrUpdatePack..." + (preset != null ? " preset=" + preset : "")); + if (preset != null && !preset.isEmpty()) { + instance.setPresetId(preset); + } + success = downloader.installOrUpdatePack(version, pack, preset); + log("[DEBUG] installOrUpdatePack returned: " + success); + } catch (Exception e) { + log("Pack install error: " + e.getMessage()); + e.printStackTrace(); + throw e; + } + if (success) { + setInstallProgressWithStage("Finalizing...", 95, 100, "Finalizing", 4, 5); + } else { + log("Install error: pack install returned false"); + } + } else { + log("Install error: pack not found on server: " + version + " (available: " + (packs != null ? packs.size() : 0) + ")"); + } + } else { + MinecraftLib lib = new MinecraftLib(instance); + setInstallProgressWithStage("Installing Minecraft...", 20, 100, "Installing Minecraft", 0, 3); + if ("vanilla".equalsIgnoreCase(loader)) { + success = lib.installMinecraft(version); + } else { + setInstallProgressWithStage("Installing loader...", 30, 100, "Installing loader", 1, 3); + success = lib.installPack(name, version, loader, loaderVersion != null ? loaderVersion : ""); + } + setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 2, 3); + } + + setInstallInProgress(false); + if (success) { + setInstallProgressWithStage("Installation complete!", 100, 100, "Done", 0, 1); + log("Installed: " + name); + } else { + setInstallProgressWithStage("Installation failed", 0, 100, "Failed", 0, 1); + log("Install error: " + name); + } + } catch (Throwable e) { + log("Install error: " + e.getMessage()); + setInstallInProgress(false); + setInstallProgressWithStage("Error: " + e.getMessage(), 0, 100, "Error", 0, 1); + } + }); + installThread.setDaemon(true); + installThread.start(); + } else { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + } + } catch (Exception e) { + log("Install error: " + e.getMessage()); + setInstallInProgress(false); + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleInstallProgress(HttpExchange exchange) { + try { + Map progress = new java.util.HashMap<>(); + progress.put("label", installProgressLabel); + progress.put("current", installProgressCurrent); + progress.put("total", installProgressTotal); + progress.put("inProgress", installInProgress.get()); + progress.put("stageName", installStageName); + progress.put("stageIndex", installStageIndex); + progress.put("stageCount", Math.max(installStageCount, 1)); + + if (installInProgress.get() && installProgressTotal > 0) { + progress.put("percent", (int) ((installProgressCurrent * 100.0) / installProgressTotal)); + } else { + progress.put("percent", installInProgress.get() ? 0 : 100); + } + + sendJson(exchange, Map.of("success", true, "data", progress)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleNetworkStatus(HttpExchange exchange) { + try { + sendJson(exchange, Map.of("success", true, "data", ZHttpClient.getNetworkStatus())); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLogs(HttpExchange exchange) { + sendJson(exchange, Map.of("success", true, "data", getLauncherLogs())); + } + + private void handleLogsStream(HttpExchange exchange) { + final LogConsumer[] consumerRef = new LogConsumer[1]; + try { + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.getResponseHeaders().set("Cache-Control", "no-cache"); + exchange.getResponseHeaders().set("Connection", "keep-alive"); + exchange.sendResponseHeaders(200, 0); + + final OutputStream os = exchange.getResponseBody(); + // Bounded queue: producers (LauncherLogger on the install thread) must + // never block on a slow/stalled SSE client. If the UI can't keep up, + // lines are dropped here - they still reach the log file and buffer. + final java.util.concurrent.BlockingQueue queue = + new java.util.concurrent.LinkedBlockingQueue<>(5000); + + LogConsumer consumer = new LogConsumer() { + @Override + public void onLog(String line) { + queue.offer(line); + } + }; + consumerRef[0] = consumer; + + addLogConsumer(consumer); + + Thread.currentThread().setName("sse-logs-stream"); + while (!Thread.currentThread().isInterrupted()) { + try { + String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS); + if (line != null) { + os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8)); + } else { + os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); + } + os.flush(); + } catch (InterruptedException e) { break; } catch (Exception e) { break; } + } + } catch (Exception ignored) {} finally { + if (consumerRef[0] != null) removeLogConsumer(consumerRef[0]); + try { exchange.getResponseBody().close(); } catch (Exception ignored) {} + } + } + + private void handleGameLogs(HttpExchange exchange) { + sendJson(exchange, Map.of("success", true, "data", getGameLogs())); + } + + private void handleGameLogsStream(HttpExchange exchange) { + final LogConsumer[] consumerRef = new LogConsumer[1]; + try { + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.getResponseHeaders().set("Cache-Control", "no-cache"); + exchange.getResponseHeaders().set("Connection", "keep-alive"); + exchange.sendResponseHeaders(200, 0); + + final OutputStream os = exchange.getResponseBody(); + // Bounded queue: producers must never block on a slow/stalled SSE client. + final java.util.concurrent.BlockingQueue queue = + new java.util.concurrent.LinkedBlockingQueue<>(5000); + + LogConsumer consumer = new LogConsumer() { + @Override + public void onLog(String line) { + queue.offer(line); + } + }; + consumerRef[0] = consumer; + + addGameLogConsumer(consumer); + + Thread.currentThread().setName("sse-game-logs-stream"); + while (!Thread.currentThread().isInterrupted()) { + try { + String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS); + if (line != null) { + os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8)); + } else { + os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); + } + os.flush(); + } catch (InterruptedException e) { break; } catch (Exception e) { break; } + } + } catch (Exception ignored) {} finally { + if (consumerRef[0] != null) removeGameLogConsumer(consumerRef[0]); + try { exchange.getResponseBody().close(); } catch (Exception ignored) {} + } + } + + private void handleMCVersions(HttpExchange exchange) { + try { + var versions = api.getMCVersions(); + if (versions.isSuccess()) { + sendJson(exchange, Map.of("success", true, "data", versions.getData())); + } else { + sendJson(exchange, Map.of("success", false, "error", versions.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLoaderVersions(HttpExchange exchange) { + try { + Map params = parseQuery(exchange.getRequestURI().getQuery()); + String mcVersion = params.get("mc"); + String loader = params.get("loader"); + + var versions = api.getLoaderVersions(mcVersion, loader); + if (versions.isSuccess()) { + sendJson(exchange, Map.of("success", true, "data", versions.getData())); + } else { + sendJson(exchange, Map.of("success", false, "error", versions.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handlePacks(HttpExchange exchange) { + try { + var result = api.getZernMCPacks(); + if (result.isSuccess()) { + sendJson(exchange, Map.of("success", true, "data", result.getData())); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleNews(HttpExchange exchange) { + try { + var url = new java.net.URL(ZHttpClient.getBaseUrl() + "/news"); + var conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + int code = conn.getResponseCode(); + if (code == 200) { + byte[] body = conn.getInputStream().readAllBytes(); + var raw = new String(body, java.nio.charset.StandardCharsets.UTF_8); + sendJson(exchange, Map.of("success", true, "data", raw)); + } else { + sendJson(exchange, Map.of("success", false, "error", "Server returned " + code)); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleActivatePass(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + Map body = parseJson(exchange.getRequestBody()); + String code = body.get("pass_code"); + if (code == null || code.isEmpty()) { + sendJson(exchange, Map.of("success", false, "error", "Enter pass code")); + return; + } + var result = api.activatePass(code); + if (result.isSuccess()) { + sendJson(exchange, Map.of("success", true, "message", "Pass activated!")); + log("Pass activated"); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleRegister(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String username = body.get("username"); + String password = body.get("password"); + if (username == null || password == null || username.isEmpty() || password.isEmpty()) { + sendJson(exchange, Map.of("success", false, "error", "Fill all fields")); + return; + } + var result = api.register(username, password); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("username", result.getData().getUsername()); + data.put("token", result.getData().getToken()); + data.put("passActive", AuthManager.hasActivePass()); + data.put("role", AuthManager.getRole()); + data.put("roleName", AuthManager.getRoleName()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Registered: " + username); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e))); + } + } + + private void handlePackInfo(HttpExchange exchange) { + try { + Map params = parseQuery(exchange.getRequestURI().getQuery()); + String name = params.get("name"); + if (name == null || name.isEmpty()) { + sendJson(exchange, Map.of("success", false, "error", "Missing pack name")); + return; + } + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Pack not found")); + return; + } + Path dir = instance.getPath(); + Map info = new HashMap<>(); + + int modsCount = 0; + Path modsDir = dir.resolve("mods"); + if (Files.exists(modsDir)) { + try (var files = Files.list(modsDir)) { + modsCount = (int) files.filter(p -> p.toString().endsWith(".jar")).count(); + } + } + info.put("modsCount", modsCount); + + List screenshots = new ArrayList<>(); + Path screenshotsDir = dir.resolve("screenshots"); + if (Files.exists(screenshotsDir)) { + try (var files = Files.list(screenshotsDir)) { + files.filter(p -> { + String n = p.getFileName().toString().toLowerCase(); + return n.endsWith(".png") || n.endsWith(".jpg"); + }).limit(20).forEach(p -> screenshots.add(p.getFileName().toString())); + } + } + info.put("screenshots", screenshots); + + List worlds = new ArrayList<>(); + Path savesDir = dir.resolve("saves"); + if (Files.exists(savesDir)) { + try (var dirs = Files.list(savesDir)) { + dirs.filter(Files::isDirectory).limit(20).forEach(p -> worlds.add(p.getFileName().toString())); + } + } + info.put("worlds", worlds); + + List recentLogs = new ArrayList<>(); + Path logsDir = dir.resolve("logs"); + if (Files.exists(logsDir)) { + try (var files = Files.list(logsDir)) { + files.filter(p -> p.toString().endsWith(".log") || p.toString().endsWith(".txt")) + .sorted((a, b) -> { + try { return Long.compare(Files.getLastModifiedTime(b).toMillis(), Files.getLastModifiedTime(a).toMillis()); } + catch (Exception e) { return 0; } + }) + .limit(5) + .forEach(p -> recentLogs.add(p.getFileName().toString())); + } + } + info.put("recentLogs", recentLogs); + + sendJson(exchange, Map.of("success", true, "data", info)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleSettings(HttpExchange exchange) { + try { + log("handleSettings: " + exchange.getRequestMethod()); + if ("POST".equals(exchange.getRequestMethod())) { + Map body = parseJson(exchange.getRequestBody()); + if (body.containsKey("maxMemory")) { + Config.setMaxMemory(Integer.parseInt(body.get("maxMemory"))); + } + if (body.containsKey("windowWidth")) { + Config.setWindowWidth(Integer.parseInt(body.get("windowWidth"))); + } + if (body.containsKey("windowHeight")) { + Config.setWindowHeight(Integer.parseInt(body.get("windowHeight"))); + } + if (body.containsKey("extraJvmArgs")) { + Config.setExtraJvmArgs(body.get("extraJvmArgs")); + } + if (body.containsKey("javaPath")) { + Config.setJavaPath(body.get("javaPath")); + } + if (body.containsKey("locale")) { + Config.setLocale(body.get("locale")); + } + if (body.containsKey("systemBasedJvm")) { + Config.setSystemBasedJvm(Boolean.parseBoolean(body.get("systemBasedJvm"))); + } + Map res = new HashMap<>(); + res.put("success", true); + res.put("maxMemory", Config.getMaxMemory()); + sendJson(exchange, res); + return; + } + Map data = new HashMap<>(); + data.put("maxMemory", Config.getMaxMemory()); + data.put("serverUrl", Config.getServerUrl()); + data.put("instancesDir", Config.getInstancesDir().toString()); + data.put("windowWidth", Config.getWindowWidth()); + data.put("windowHeight", Config.getWindowHeight()); + data.put("extraJvmArgs", Config.getExtraJvmArgs()); + data.put("javaPath", Config.getJavaPath()); + data.put("locale", Config.getLocale()); + data.put("systemBasedJvm", Config.isSystemBasedJvm()); + data.put("cpuCores", Config.getSystemCpuCores()); + data.put("totalRamMB", Config.getSystemTotalRamMB()); + data.put("systemJvmFlags", Config.getSystemJvmFlags()); + sendJson(exchange, Map.of("success", true, "data", data)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private Map parseQuery(String query) { + Map params = new HashMap<>(); + if (query != null && !query.isEmpty()) { + for (String pair : query.split("&")) { + String[] kv = pair.split("="); + if (kv.length == 2) { + try { + params.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), + URLDecoder.decode(kv[1], StandardCharsets.UTF_8)); + } catch (Exception ignored) {} + } + } + } + return params; + } + + private void handleShutdown(HttpExchange exchange) { + log("Shutdown request received..."); + shutdown(); + System.exit(0); + } + + private void handleExit(HttpExchange exchange) { + log("Exiting..."); + shutdown(); + System.exit(0); + } + + private void handleExitParent(HttpExchange exchange) { + log("Terminating parent process..."); + shutdown(); + System.exit(240); + } + + /** + * Releases launcher resources on shutdown: kills any running game + * processes and stops the embedded HTTP server. + */ + private void shutdown() { + LaunchService.killAllProcesses(); + if (server != null) { + server.stop(0); + java.util.concurrent.ExecutorService exec = + (java.util.concurrent.ExecutorService) server.getExecutor(); + if (exec != null) exec.shutdownNow(); + } + } + + @Override + public void stop() { + LauncherLogger.info("[JFX] Application stop() called, releasing resources..."); + shutdown(); + LauncherLogger.info("[JFX] Clean shutdown complete"); + } + + private void handleStatic(HttpExchange exchange) { + try { + String path = exchange.getRequestURI().getPath(); + log("[UI] Request: " + path); + + String relativePath = path.startsWith("/") ? path.substring(1) : path; + Path file = Paths.get(relativePath).toAbsolutePath().normalize(); + Path assetsDir = Paths.get("assets").toAbsolutePath().normalize(); + + if (!file.startsWith(assetsDir)) { + log("[UI] Forbidden: " + file); + exchange.sendResponseHeaders(403, 0); + exchange.close(); + return; + } + + byte[] content = null; + String source = null; + + if (Files.exists(file) && !Files.isDirectory(file)) { + content = Files.readAllBytes(file); + source = "disk"; + } else { + // Fallback to the UI bundled inside the launcher jar, so the + // UI never breaks when the on-disk assets are missing/stale. + try (InputStream in = getClass().getResourceAsStream("/" + relativePath)) { + if (in != null) { + content = in.readAllBytes(); + source = "jar"; + } + } + } + + if (content == null) { + log("[UI] File not found: " + file + " (not bundled in jar either)"); + exchange.sendResponseHeaders(404, 0); + exchange.close(); + return; + } + + log("[UI] Loaded " + content.length + " bytes from " + source + ": " + path); + String ct = getContentType(path); + + if (ct.startsWith("text/")) { + String text = new String(content, java.nio.charset.StandardCharsets.UTF_8); + text = text.replace("__LAUNCHER_VERSION__", me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion()); + content = text.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + exchange.getResponseHeaders().set("Content-Type", ct); + exchange.sendResponseHeaders(200, content.length); + exchange.getResponseBody().write(content); + exchange.close(); + } catch (Exception e) { + log("[UI] Error serving: " + e.getMessage()); + } + } + + private String getContentType(String path) { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + return "text/plain"; + } + + private Map parseJson(InputStream body) { + try { + byte[] rawBytes = body.readAllBytes(); + log("parseJson: " + rawBytes.length + " bytes"); + String raw = new String(rawBytes, StandardCharsets.UTF_8); + Map rawMap = gson.fromJson(raw, Map.class); + Map result = new HashMap<>(); + if (rawMap != null) { + for (Map.Entry entry : rawMap.entrySet()) { + String key = entry.getKey() != null ? entry.getKey().toString() : null; + String value = null; + Object v = entry.getValue(); + if (v instanceof Number) { + Number n = (Number) v; + if (n.doubleValue() == n.longValue()) { + value = String.valueOf(n.longValue()); + } else { + value = String.valueOf(n.doubleValue()); + } + } else { + value = v != null ? v.toString() : null; + } + if (key != null) result.put(key, value); + } + } + return result; + } catch (Exception e) { + log("parseJson error: " + e.getMessage()); + return new HashMap<>(); + } + } + + private void openInBrowser(String url) { + if (url == null || url.isEmpty()) return; + try { + java.awt.Desktop.getDesktop().browse(new java.net.URI(url)); + } catch (Exception e) { + try { + new ProcessBuilder("xdg-open", url).start(); + } catch (Exception e2) { + log("Failed to open browser: " + e2.getMessage()); + } + } + } + + private void sendJson(HttpExchange exchange, Map response) { + try { + String json = gson.toJson(response); + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } catch (Exception e) { + log("sendJson error: " + e.getMessage()); + } + } + + private void sendRawJson(HttpExchange exchange, String json) { + try { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } catch (Exception e) { + log("sendRawJson error: " + e.getMessage()); + } + } + + private String readBody(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + + private String remoteApiCall(String method, String path, String body) throws Exception { + URL url = new URL(LAUNCHER_SERVER + path); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod(method); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); + String token = me.sashegdev.zernmc.launcher.auth.AuthManager.getAccessToken(); + if (token != null && !token.isEmpty() && !token.equals("0")) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setConnectTimeout(10000); + conn.setReadTimeout(15000); + if (body != null && !body.isEmpty()) { + conn.setDoOutput(true); + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + int code = conn.getResponseCode(); + try (InputStream is = code >= 400 ? conn.getErrorStream() : conn.getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private void handleFriendList(HttpExchange exchange) { + try { + String response = remoteApiCall("GET", "/api/friends/list", null); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendAdd(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/friends/add", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendRemove(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/friends/remove", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendAccept(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/friends/accept", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendRequests(HttpExchange exchange) { + try { + String response = remoteApiCall("GET", "/api/friends/requests", null); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendStatus(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/friends/status", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handlePlaytimeSync(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/playtime/sync", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handlePlaytimeStats(HttpExchange exchange) { + try { + String response = remoteApiCall("GET", "/api/playtime/stats", null); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleCheckUpdates(HttpExchange exchange) { + try { + Map params = parseQuery(exchange.getRequestURI().getQuery()); + String name = params.get("name"); + if (name == null || name.isBlank()) { + sendJson(exchange, Map.of("success", false, "error", "Missing 'name' parameter")); + return; + } + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + return; + } + if (!instance.isServerPack()) { + sendJson(exchange, Map.of("success", false, "error", "Not a server pack")); + return; + } + PackDownloader downloader = new PackDownloader(instance); + String packName = instance.getServerPackName(); + var manifest = downloader.getPackManifest(packName); + if (manifest == null) { + sendJson(exchange, Map.of("success", false, "error", "Failed to fetch pack manifest")); + return; + } + int serverVersion = manifest.getVersion(); + int localVersion = instance.getServerVersion(); + sendJson(exchange, Map.of( + "success", true, + "data", Map.of( + "hasUpdate", serverVersion > localVersion, + "serverVersion", serverVersion, + "localVersion", localVersion + ) + )); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleUpdate(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + if (name == null || name.isBlank()) { + sendJson(exchange, Map.of("success", false, "error", "Missing 'name'")); + return; + } + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + return; + } + if (!instance.isServerPack()) { + sendJson(exchange, Map.of("success", false, "error", "Not a server pack")); + return; + } + PackDownloader downloader = new PackDownloader(instance); + String packName = instance.getServerPackName(); + String preset = instance.getPresetId(); + boolean success; + if (preset != null && !preset.isEmpty()) { + success = downloader.updatePack(packName, preset); + } else { + success = downloader.updatePack(packName); + } + if (success) { + sendJson(exchange, Map.of("success", true, "data", Map.of("version", instance.getServerVersion()))); + } else { + sendJson(exchange, Map.of("success", false, "error", "Update failed")); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleWhitelistMods(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + String response = ZHttpClient.get("/whitelist/mods"); + List> mods = new ArrayList<>(); + for (String line : response.split("\n")) { + line = line.trim(); + if (line.isEmpty()) continue; + String[] parts = line.split("::"); + if (parts.length >= 3) { + Map mod = new HashMap<>(); + mod.put("name", parts[0].trim()); + mod.put("size", Long.parseLong(parts[1].trim())); + mod.put("hash", parts[2].trim()); + mods.add(mod); + } + } + sendJson(exchange, Map.of("success", true, "data", mods)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleWhitelistInstallMod(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + Map body = parseJson(exchange.getRequestBody()); + String instanceName = body.get("instance"); + String modName = body.get("modName"); + String modHash = body.get("modHash"); + + if (instanceName == null || modName == null || modHash == null) { + sendJson(exchange, Map.of("success", false, "error", "Missing parameters")); + return; + } + + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + return; + } + + Path modsDir = instance.getPath().resolve("mods"); + Files.createDirectories(modsDir); + Path targetFile = modsDir.resolve(modName); + + // Download mod from server (through ZHttpClient so the read timeout is + // actually enforced — raw client.send() hangs forever on a stalled body) + String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName; + ZHttpClient.downloadFileWithSmartProxy(url, targetFile); + + sendJson(exchange, Map.of("success", true, "message", modName + " installed")); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + // ====================== ADMIN ====================== + + private void handleAdmin(HttpExchange exchange) { + try { + String path = exchange.getRequestURI().getPath(); + String method = exchange.getRequestMethod(); + byte[] reqBodyBytes = exchange.getRequestBody().readAllBytes(); + String body = reqBodyBytes.length > 0 ? new String(reqBodyBytes, StandardCharsets.UTF_8) : null; + + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + + String remotePath = path.replace("/api/admin", "/admin"); + + // Special handling for mod upload (base64 → multipart) + if ("POST".equals(method) && remotePath.equals("/admin/whitelist/mods") && body != null) { + handleAdminModUpload(exchange, body); + return; + } + + String apiMethod = method; + // Java HttpURLConnection doesn't support DELETE directly in remoteApiCall, + // so map DELETE to POST for the Python server + if ("DELETE".equals(method)) { + apiMethod = "POST"; + remotePath = remotePath + "/delete"; + } + + String response = remoteApiCall(apiMethod, remotePath, body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleAdminModUpload(HttpExchange exchange, String jsonBody) throws Exception { + @SuppressWarnings("unchecked") + Map data = gson.fromJson(jsonBody, Map.class); + String filename = data.get("filename"); + String base64Data = data.get("data"); + if (filename == null || base64Data == null) { + sendJson(exchange, Map.of("success", false, "error", "Missing filename or data")); + return; + } + + // Decode base64 + byte[] fileBytes = Base64.getDecoder().decode(base64Data); + + // Forward to Python API as multipart request + String boundary = "Boundary-" + System.currentTimeMillis(); + URL url = new URL(LAUNCHER_SERVER + "/admin/whitelist/mods"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + String token = AuthManager.getAccessToken(); + if (token != null && !token.isEmpty() && !token.equals("0")) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setConnectTimeout(30000); + conn.setReadTimeout(60000); + + String header = "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n" + + "Content-Type: application/java-archive\r\n\r\n"; + String footer = "\r\n--" + boundary + "--\r\n"; + + try (OutputStream os = conn.getOutputStream()) { + os.write(header.getBytes(StandardCharsets.UTF_8)); + os.write(fileBytes); + os.write(footer.getBytes(StandardCharsets.UTF_8)); + } + + int code = conn.getResponseCode(); + String respBody; + try (InputStream is = code >= 400 ? conn.getErrorStream() : conn.getInputStream()) { + respBody = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + + if (code >= 400) { + sendJson(exchange, Map.of("success", false, "error", "Upload failed: HTTP " + code + " - " + respBody)); + } else { + sendRawJson(exchange, respBody); + } + } + + private static void log(String msg) { + String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")); + String entry = "[" + timestamp + "] " + msg; + synchronized (launcherLogBuffer) { + launcherLogBuffer.append(entry).append("\n"); + } + LauncherLogger.info("[JFX] " + msg); + for (LogConsumer consumer : logConsumers) { + try { consumer.onLog(entry); } catch (Exception ignored) {} + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java new file mode 100644 index 0000000..e526529 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java @@ -0,0 +1,269 @@ +package me.sashegdev.zernmc.launcher.utils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +public class Config { + + private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc"); + private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties"); + + private static final Properties props = new Properties(); + + private static volatile int maxMemory = 4096; + private static volatile String serverUrl = "https://api.zern.cc"; + private static volatile String lastUsername = "Player"; + private static volatile int windowWidth = 1280; + private static volatile int windowHeight = 720; + private static volatile String extraJvmArgs = ""; + private static volatile String javaPath = "java"; + private static volatile boolean ramManuallySet = false; + private static volatile String locale = "en"; + private static volatile boolean systemBasedJvm = false; + + static { + load(); + if (!ramManuallySet) { + applySmartRamRecommendation(); + } + } + + private static void load() { + try { + Files.createDirectories(CONFIG_DIR); + if (Files.exists(CONFIG_FILE)) { + try (var is = Files.newInputStream(CONFIG_FILE)) { + props.load(is); + } + } + + try { + maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096")); + } catch (NumberFormatException e) { + System.err.println(ZAnsi.yellow("Config: invalid maxMemory value, using default")); + } + ramManuallySet = Boolean.parseBoolean(props.getProperty("ramManuallySet", "false")); + serverUrl = props.getProperty("serverUrl", serverUrl); + lastUsername = props.getProperty("lastUsername", lastUsername); + try { + windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280")); + } catch (NumberFormatException e) { + System.err.println(ZAnsi.yellow("Config: invalid windowWidth value, using default")); + } + try { + windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720")); + } catch (NumberFormatException e) { + System.err.println(ZAnsi.yellow("Config: invalid windowHeight value, using default")); + } + extraJvmArgs = props.getProperty("extraJvmArgs", ""); + javaPath = props.getProperty("javaPath", "java"); + locale = props.getProperty("locale", "en"); + systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false")); + + } catch (Exception e) { + System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage()); + } + } + + public static void save() { + try { + props.setProperty("maxMemory", String.valueOf(maxMemory)); + props.setProperty("ramManuallySet", String.valueOf(ramManuallySet)); + props.setProperty("serverUrl", serverUrl); + props.setProperty("lastUsername", lastUsername); + props.setProperty("windowWidth", String.valueOf(windowWidth)); + props.setProperty("windowHeight", String.valueOf(windowHeight)); + props.setProperty("extraJvmArgs", extraJvmArgs); + props.setProperty("javaPath", javaPath); + props.setProperty("locale", locale); + props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm)); + + try (var os = Files.newOutputStream(CONFIG_FILE)) { + props.store(os, "ZernMC Launcher Configuration"); + } + } catch (IOException e) { + System.err.println(ZAnsi.brightRed("Failed to save config: ") + e.getMessage()); + } + } + + private static void applySmartRamRecommendation() { + long totalRamMB = getTotalSystemRamMB(); + if (totalRamMB <= 0) return; + + long recommended; + if (totalRamMB <= 8192) { + recommended = 2560; + } else if (totalRamMB <= 12288) { + recommended = 3072; + } else if (totalRamMB <= 16384) { + recommended = 4096; + } else { + recommended = 5120; + } + + if (Math.abs(maxMemory - recommended) > 512) { + maxMemory = (int) recommended; + save(); + System.out.println(ZAnsi.cyan("Auto-recommended RAM: " + maxMemory + " MB")); + } + } + + public static void resetRamRecommendation() { + ramManuallySet = false; + applySmartRamRecommendation(); + } + + private static long getTotalSystemRamMB() { + try { + Class beanClass = Class.forName("com.sun.management.OperatingSystemMXBean"); + java.lang.management.OperatingSystemMXBean osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean(); + if (beanClass.isInstance(osBean)) { + Object totalBytes = beanClass.getMethod("getTotalMemorySize").invoke(osBean); + return ((Number) totalBytes).longValue() / (1024 * 1024); + } + } catch (Exception ignored) {} + return 0; + } + + public static int getMaxMemory() { + return maxMemory; + } + + public static void setMaxMemory(int memory) { + if (memory < 1024) memory = 1536; + if (memory > 32768) memory = 32768; + + maxMemory = memory; + ramManuallySet = true; + save(); + } + + public static String getServerUrl() { + return serverUrl; + } + + public static void setServerUrl(String url) { + serverUrl = url != null && !url.isBlank() ? url : serverUrl; + save(); + } + + public static String getLastUsername() { + return lastUsername; + } + + public static void setLastUsername(String username) { + lastUsername = username; + save(); + } + + public static Path getInstancesDir() { + return CONFIG_DIR.resolve("instances"); + } + + public static Path getJreDir() { + return CONFIG_DIR.resolve("jre"); + } + + public static Path getConfigDir() { + return CONFIG_DIR; + } + + public static int getWindowWidth() { + return windowWidth; + } + + public static void setWindowWidth(int width) { + windowWidth = Math.max(640, width); + save(); + } + + public static int getWindowHeight() { + return windowHeight; + } + + public static void setWindowHeight(int height) { + windowHeight = Math.max(480, height); + save(); + } + + public static String getExtraJvmArgs() { + return extraJvmArgs; + } + + public static void setExtraJvmArgs(String args) { + extraJvmArgs = args != null ? args : ""; + save(); + } + + public static String getJavaPath() { + return javaPath; + } + + public static void setJavaPath(String path) { + javaPath = path != null && !path.isEmpty() ? path : "java"; + save(); + } + + public static String getLocale() { + return locale; + } + + public static void setLocale(String lang) { + if (lang != null && (lang.equals("en") || lang.equals("ru"))) { + locale = lang; + save(); + } + } + + public static boolean isSystemBasedJvm() { + return systemBasedJvm; + } + + public static void setSystemBasedJvm(boolean enabled) { + systemBasedJvm = enabled; + save(); + } + + public static int getSystemCpuCores() { + return Runtime.getRuntime().availableProcessors(); + } + + public static long getSystemTotalRamMB() { + long totalMb = getTotalSystemRamMB(); + if (totalMb > 0) return totalMb; + try { + java.lang.management.OperatingSystemMXBean osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean(); + if (osBean instanceof com.sun.management.OperatingSystemMXBean sunBean) { + return sunBean.getTotalMemorySize() / (1024 * 1024); + } + } catch (Exception ignored) {} + long jvmMax = Runtime.getRuntime().maxMemory() / (1024 * 1024); + return Math.max(jvmMax, 4096); + } + + public static String getSystemJvmFlags() { + int cores = getSystemCpuCores(); + long ramMB = getSystemTotalRamMB(); + StringBuilder sb = new StringBuilder(); + sb.append("-XX:ParallelGCThreads=").append(Math.max(1, cores)); + sb.append(" -XX:ConcGCThreads=").append(Math.max(1, cores / 2)); + sb.append(" -XX:+AlwaysPreTouch"); + if (ramMB >= 8192) { + sb.append(" -XX:+UseZGC"); + sb.append(" -XX:ZAllocationSpikeTolerance=2.0"); + } else { + sb.append(" -XX:+UseG1GC"); + sb.append(" -XX:MaxGCPauseMillis=50"); + sb.append(" -XX:G1HeapRegionSize=16M"); + } + sb.append(" -Xss4M"); + return sb.toString(); + } + + public static String getRamInfo() { + long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); + return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB"; + } +} diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ConsoleUtils.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ConsoleUtils.java similarity index 87% rename from launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ConsoleUtils.java rename to launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ConsoleUtils.java index bde04d3..5e57b16 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ConsoleUtils.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ConsoleUtils.java @@ -10,10 +10,9 @@ public class ConsoleUtils { } public static void pause() { - System.out.print(ZAnsi.white("\nНажмите Enter для продолжения...")); + System.out.print(ZAnsi.white("\nPress Enter to continue...")); try { System.in.read(); - // Очищаем буфер ввода while (System.in.available() > 0) { System.in.read(); } @@ -36,4 +35,4 @@ public class ConsoleUtils { public static void separator() { System.out.println(ZAnsi.white("────────────────────────────────────────────────────────────")); } -} \ No newline at end of file +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java new file mode 100644 index 0000000..921850b --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java @@ -0,0 +1,133 @@ +package me.sashegdev.zernmc.launcher.utils; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Picks the fastest reachable ZernMC API domain at launcher startup. + * TSPU/SNI-обход: если основной api.zern.cc недоступен (заблокирован ТСПУ), + * пробиваем legacy-домены и гео-зеркала и переключаемся на первый живой/быстрый. + */ +public final class DomainSelector { + + private static final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(8)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + + private static final List FALLBACK_CANDIDATES = List.of( + "https://api.zern.cc", + "https://api.zernmc.ru", + "https://api.zernmc.online", + "https://api.pl.zern.cc", + "https://api.swe.zern.cc" + ); + + private static final String MIRRORS_PATH = "/launcher/mirrors"; + private static final String VERSION_PATH = "/launcher/version"; + + private DomainSelector() { + } + + /** + * @return выбранный base URL или null, если ни один домен не ответил. + */ + public static String selectBest() { + List candidates = new ArrayList<>(); + String configured = Config.getServerUrl(); + if (configured != null && !configured.isBlank() && !candidates.contains(configured)) { + candidates.add(configured); + } + for (String c : FALLBACK_CANDIDATES) { + if (!candidates.contains(c)) { + candidates.add(c); + } + } + candidates.addAll(fetchMirrors()); + + String best = null; + long bestMs = Long.MAX_VALUE; + for (String base : candidates) { + Probe r = probe(base); + if (r.ok && r.latencyMs >= 0 && r.latencyMs < bestMs) { + bestMs = r.latencyMs; + best = base; + } + } + + if (best != null) { + // Make every candidate (incl. geo mirrors) available as a fallback proxy base. + for (String c : candidates) { + ZHttpClient.registerProxyBase(c); + } + } + + if (best == null) { + // nothing reachable: keep whatever is configured + System.err.println(ZAnsi.brightRed("DomainSelector: no reachable API domain")); + return null; + } + + if (!best.equals(configured)) { + ZHttpClient.setBaseUrl(best); + Config.setServerUrl(best); + System.out.println(ZAnsi.cyan("Selected API domain: " + best) + + " (" + bestMs + " ms)"); + } + return best; + } + + private static List fetchMirrors() { + List mirrors = new ArrayList<>(); + for (String candidate : FALLBACK_CANDIDATES) { + Probe p = probe(candidate); + if (!p.ok || p.body == null) { + continue; + } + try { + org.json.JSONObject json = new org.json.JSONObject(p.body); + var arr = json.optJSONArray("mirrors"); + if (arr == null) { + continue; + } + for (int i = 0; i < arr.length(); i++) { + String url = arr.optJSONObject(i) != null + ? arr.optJSONObject(i).optString("url", null) : null; + if (url != null && !url.isBlank() && !mirrors.contains(url)) { + mirrors.add(url); + } + } + break; // первый живой источник списка достаточен + } catch (Exception ignored) { + // continue to next candidate + } + } + return mirrors; + } + + private static Probe probe(String baseUrl) { + long t0 = System.currentTimeMillis(); + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + MIRRORS_PATH)) + .timeout(Duration.ofSeconds(8)) + .GET() + .header("User-Agent", "ZernMC-Launcher/DomainSelector") + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + long ms = System.currentTimeMillis() - t0; + boolean ok = response.statusCode() == 200; + return new Probe(ok, ms, ok ? response.body() : null); + } catch (Exception e) { + return new Probe(false, System.currentTimeMillis() - t0, null); + } + } + + private record Probe(boolean ok, long latencyMs, String body) { + } +} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Input.java similarity index 58% rename from launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java rename to launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Input.java index 7ece5e6..ec9d9ba 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Input.java @@ -3,22 +3,20 @@ package me.sashegdev.zernmc.launcher.utils; import me.sashegdev.zernmc.launcher.ui.ArrowMenu; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Scanner; -/** - * Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu - */ public class Input { - // Используем UTF-8 явно — это помогает на Windows - private static final Scanner scanner = new Scanner(System.in, "UTF-8"); + private static final Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8); public static String readLine() { return scanner.nextLine().trim(); } public static String readLine(String prompt) { + flushInput(); System.out.print(prompt); return scanner.nextLine().trim(); } @@ -29,7 +27,7 @@ public class Input { System.out.print(prompt); return Integer.parseInt(scanner.nextLine().trim()); } catch (NumberFormatException e) { - System.out.println(ZAnsi.brightRed("Некорректное число. Попробуйте ещё раз.")); + System.out.println(ZAnsi.brightRed("Invalid number. Try again.")); } } } @@ -40,43 +38,41 @@ public class Input { if (value >= min && value <= max) { return value; } - System.out.println(ZAnsi.brightRed("Значение должно быть от " + min + " до " + max + ".")); + System.out.println(ZAnsi.brightRed("Value must be between " + min + " and " + max + ".")); } } - /** - * Новый confirm через ArrowMenu - * @throws IOException - */ public static boolean confirm(String question) throws IOException { - ConsoleUtils.clearScreen(); // опционально, можно убрать + ConsoleUtils.clearScreen(); List options = List.of( - "Да", - "Нет" + "Yes", + "No" ); ArrowMenu menu = new ArrowMenu(question, options); int choice = menu.show(); - return choice == 0; // 0 = "Да" + return choice == 0; } - /** - * Альтернативный confirm без очистки экрана - * @throws IOException - */ public static boolean confirmInline(String question) throws IOException { - List options = List.of("Да", "Нет"); + List options = List.of("Yes", "No"); ArrowMenu menu = new ArrowMenu(question, options); int choice = menu.show(); return choice == 0; } - /** - * Закрытие сканнера (вызывать при выходе из программы, если нужно) - */ public static void close() { scanner.close(); } -} \ No newline at end of file + + public static void flushInput() { + try { + while (System.in.available() > 0) { + System.in.read(); + } + } catch (IOException e) { + } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/JavaResolver.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/JavaResolver.java new file mode 100644 index 0000000..397fbb3 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/JavaResolver.java @@ -0,0 +1,42 @@ +package me.sashegdev.zernmc.launcher.utils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Resolves the java executable used to run the launcher itself, so child + * processes (Forge/NeoForge/Fabric installers) run on the bundled JRE + * instead of relying on a system java that may not exist or be too old. + * + * Order of preference: + * 1. The JVM currently running the launcher (bundled jre21 in lib/jre21). + * 2. A jre21 next to the working directory (dev layout). + * 3. System PATH lookup (last resort). + */ +public final class JavaResolver { + + private JavaResolver() {} + + public static String getJavaExecutable() { + String os = System.getProperty("os.name", "").toLowerCase(); + String javaExe = os.contains("win") ? "java.exe" : "java"; + + Path fromHome = Paths.get(System.getProperty("java.home"), "bin", javaExe); + if (Files.exists(fromHome)) { + return fromHome.toAbsolutePath().toString(); + } + + Path bundled = Paths.get("").toAbsolutePath().resolve("lib").resolve("jre21").resolve("bin").resolve(javaExe); + if (Files.exists(bundled)) { + return bundled.toString(); + } + + Path devJre = Paths.get("").toAbsolutePath().resolve("jre21").resolve("bin").resolve(javaExe); + if (Files.exists(devJre)) { + return devJre.toString(); + } + + return javaExe; + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java new file mode 100644 index 0000000..a8e7eaa --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java @@ -0,0 +1,209 @@ +package me.sashegdev.zernmc.launcher.utils; + +import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; + +public class LauncherLogger { + + private static Path logFile; + private static boolean initialized = false; + private static final ReentrantLock lock = new ReentrantLock(); + + private static final int STDOUT_QUEUE_CAPACITY = 2048; + private static final BlockingQueue stdoutQueue = new LinkedBlockingQueue<>(STDOUT_QUEUE_CAPACITY); + private static final Thread stdoutWriterThread; + private static final Pattern ANSI_PATTERN = Pattern.compile("\u001B\\[[0-9;]*[A-Za-z]"); + + static { + stdoutWriterThread = new Thread(() -> { + while (true) { + try { + String line = stdoutQueue.take(); + System.out.println(line); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (Exception ignored) { + // stdout may be a closed or full pipe; never let it kill the writer + } + } + }, "launcher-log-stdout"); + stdoutWriterThread.setDaemon(true); + stdoutWriterThread.start(); + } + + public static synchronized void init() { + if (initialized) return; + initialized = true; + + try { + Path logsDir = Paths.get("").toAbsolutePath().resolve("logs"); + Files.createDirectories(logsDir); + rotateLogs(logsDir); + logFile = logsDir.resolve("latest.log"); + + Files.writeString(logFile, sessionHeader(), StandardOpenOption.CREATE); + + System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath()); + } catch (Exception e) { + System.err.println("[LauncherLogger] init error: " + e.getMessage()); + e.printStackTrace(); + } + } + + private static String sessionHeader() { + String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + String os = System.getProperty("os.name") + " " + System.getProperty("os.version") + + " " + System.getProperty("os.arch"); + String java = System.getProperty("java.version") + " (" + System.getProperty("java.vendor") + ")"; + String mem = (Runtime.getRuntime().maxMemory() / (1024 * 1024)) + " MB"; + return "== ZernMC Launcher =======================================================\n" + + "== Started: " + ts + "\n" + + "== OS: " + os + "\n" + + "== Java: " + java + "\n" + + "== Max memory: " + mem + "\n" + + "====================================================================\n"; + } + + /** + * Minecraft-style rotation: compress the previous latest.log into a dated + * .log.gz archive (unlimited history), then remove the legacy launcher.log. + */ + private static void rotateLogs(Path logsDir) { + // Legacy one-instance log from older builds: delete it, it is replaced by latest.log. + try { + Files.deleteIfExists(logsDir.resolve("launcher.log")); + } catch (Exception ignored) { + } + + Path latest = logsDir.resolve("latest.log"); + try { + 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 = logsDir.resolve(ts + (n == 1 ? "" : "-" + n) + ".log.gz"); + n++; + } while (Files.exists(archive)); + gzip(logsDir, archive, latest); + Files.deleteIfExists(latest); + } + } catch (Exception e) { + System.err.println("[LauncherLogger] rotate error: " + e.getMessage()); + } + } + + private static void gzip(Path logsDir, Path archive, Path source) { + try (var in = Files.newInputStream(source); + var gz = new java.util.zip.GZIPOutputStream(Files.newOutputStream(archive))) { + byte[] buf = new byte[65536]; + int n; + while ((n = in.read(buf)) > 0) { + gz.write(buf, 0, n); + } + } catch (Exception e) { + System.err.println("[LauncherLogger] gzip error: " + e.getMessage()); + } + } + + public static Path getLogFile() { + return logFile; + } + + public static void info(String msg) { + write("INFO", msg, null); + } + + public static void warn(String msg) { + write("WARN", msg, null); + } + + public static void error(String msg) { + write("ERROR", msg, null); + } + + public static void error(String msg, Throwable t) { + write("ERROR", msg, t); + } + + public static void debug(String msg) { + write("DEBUG", msg, null); + } + + private static void write(String level, String msg, Throwable t) { + String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")); + String line = "[" + ts + "] [" + level + "] " + msg; + + // stdout goes through a dedicated writer thread with a bounded queue so a + // full/hung stdout pipe (e.g. javaw piped to the parent exe) can never + // block application threads. + offerStdout(line); + + // File and UI log never get ANSI escape codes; the console keeps them. + String plainLine = ANSI_PATTERN.matcher(line).replaceAll(""); + + // Forward to the JFX UI log so installer/progress output is visible + // in the GUI. The bridge is a safe no-op when JavaFX is unavailable. + JFXBridge.appendLauncherLogFromLogger(plainLine); + + if (t != null) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + t.printStackTrace(pw); + pw.flush(); + offerStdout(sw.toString()); + } + + if (logFile != null) { + // Never block indefinitely on the file write; give up after a short wait + // so a stuck writer cannot deadlock every logging thread. + boolean acquired = false; + try { + acquired = lock.tryLock(200, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + if (acquired) { + try { + Files.writeString(logFile, plainLine + "\n", StandardOpenOption.APPEND); + if (t != null) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + t.printStackTrace(pw); + pw.flush(); + Files.writeString(logFile, sw.toString(), StandardOpenOption.APPEND); + } + } catch (IOException e) { + System.err.println("[LauncherLogger] write error: " + e.getMessage()); + } finally { + lock.unlock(); + } + } + } + } + + private static void offerStdout(String line) { + try { + if (!stdoutQueue.offer(line, 10, TimeUnit.MILLISECONDS)) { + // Queue full; drop rather than block the caller. + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java new file mode 100644 index 0000000..fb29e53 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java @@ -0,0 +1,123 @@ +package me.sashegdev.zernmc.launcher.utils; + +import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge; + +import java.text.DecimalFormat; + +/** + * Progress reporting for installs/downloads. + * + * IMPORTANT: never writes directly to System.out/System.err. In JFX mode the + * launcher runs under javaw.exe whose stdout pipe is drained by the parent exe; + * once that pipe fills (or the exe stops reading), a synchronous + * System.out.write/flush blocks the calling (install/UI) thread forever. + * All console/progress output must go through LauncherLogger, which is + * non-blocking by design (bounded stdout queue + tryLock file write + SSE queue). + */ +public class ProgressBar { + + private static final DecimalFormat DF = new DecimalFormat("#.##"); + + private static String currentLabel = ""; + private static long currentTotal = 0; + + private static final long DOWNLOAD_LOG_INTERVAL_MS = 500; + private static volatile long lastDownloadLogMs = 0; + private static volatile long lastIndeterminateLogMs = 0; + + public static void show(String label, long current, long total, String unit) { + currentLabel = label; + currentTotal = total; + + JFXBridge.setInstallProgress(label, (int) current, (int) total); + + if (total <= 0) { + LauncherLogger.info(label + " ..."); + return; + } + int percent = (int) ((double) current / total * 100); + LauncherLogger.info(label + " " + percent + "% (" + current + "/" + total + " " + unit + ")"); + } + + public static void showDownload(String label, long downloaded, long totalBytes) { + currentLabel = label; + currentTotal = totalBytes; + + JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes); + + // Throttle console/file logging: called once per chunk during downloads. + long now = System.currentTimeMillis(); + if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) { + lastDownloadLogMs = now; + LauncherLogger.info(label + " " + formatBytes(downloaded) + + (totalBytes > 0 ? "/" + formatBytes(totalBytes) : "")); + } + } + + public static void showAnimated(String label, long current, long total, String unit) { + currentLabel = label; + currentTotal = total; + + JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100)); + + long now = System.currentTimeMillis(); + if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) { + lastDownloadLogMs = now; + LauncherLogger.info(label + ": " + formatBytes(current) + + (total > 0 ? "/" + formatBytes(total) + " " + unit : "")); + } + } + + public static void finish(String message) { + LauncherLogger.info(message + " done"); + } + + /** + * Shows a stage without a known total (e.g. running the Forge/NeoForge/Fabric + * installer subprocess). Always updates the UI label so the user sees what the + * launcher is doing right now; console/file logging is throttled to avoid spam. + */ + public static void showIndeterminate(String label) { + currentLabel = label; + currentTotal = 0; + + JFXBridge.setInstallProgress(label, 0, 0); + + long now = System.currentTimeMillis(); + if (now - lastIndeterminateLogMs >= DOWNLOAD_LOG_INTERVAL_MS) { + lastIndeterminateLogMs = now; + LauncherLogger.info(label + " ..."); + } + } + + /** + * Strips ANSI codes, indentation and [category] prefixes from a child-process + * output line so it can be used as a short, human-readable progress label. + */ + public static String cleanLine(String line) { + if (line == null) return ""; + String s = line.replaceAll("\\u001B\\[[;\\d]*m", "").trim(); + int bracket = s.indexOf("] "); + if (s.startsWith("[") && bracket > 0) { + s = s.substring(bracket + 2).trim(); + } + if (s.length() > 60) { + s = s.substring(0, 57) + "..."; + } + return s; + } + + public static void setStage(String stageName, int stageIndex, int stageCount) { + JFXBridge.setInstallStage(stageName, stageIndex, stageCount); + } + + public static void clearLine() { + // No-op: console progress now goes through LauncherLogger. + } + + public static String formatBytes(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB"; + return DF.format(bytes / (1024.0 * 1024)) + " MB"; + } +} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Version.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Version.java similarity index 59% rename from launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Version.java rename to launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Version.java index e72abab..eebf168 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Version.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Version.java @@ -33,24 +33,42 @@ public class Version { public static boolean isNewer(String current, String server) { if (current == null || server == null) return false; - - current = current.replace("-SNAPSHOT", "").trim(); - server = server.replace("-SNAPSHOT", "").trim(); - + + // Нормализуем версии - убираем суффиксы типа -any, -alpha, -beta, -SNAPSHOT + current = normalizeVersion(current); + server = normalizeVersion(server); + if (current.equals(server)) return false; - + String[] cParts = current.split("\\."); String[] sParts = server.split("\\."); - + int max = Math.max(cParts.length, sParts.length); - + for (int i = 0; i < max; i++) { - int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0; - int s = i < sParts.length ? Integer.parseInt(sParts[i]) : 0; - + int c = i < cParts.length ? parseVersionPart(cParts[i]) : 0; + int s = i < sParts.length ? parseVersionPart(sParts[i]) : 0; + if (s > c) return true; if (s < c) return false; } return false; } + + private static String normalizeVersion(String version) { + if (version == null) return "0.0.0"; + + // Убираем суффиксы: -any, -alpha1, -beta2, -SNAPSHOT, -rc1 и т.д. + return version.split("-")[0].split("\\+")[0].trim(); + } + + private static int parseVersionPart(String part) { + try { + // Убираем всё, что не является цифрой (на случай если суффикс остался) + String numeric = part.replaceAll("[^0-9]", ""); + return numeric.isEmpty() ? 0 : Integer.parseInt(numeric); + } catch (Exception e) { + return 0; + } + } } \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZAnsi.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZAnsi.java new file mode 100644 index 0000000..6575ca2 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZAnsi.java @@ -0,0 +1,182 @@ +package me.sashegdev.zernmc.launcher.utils; + +import org.fusesource.jansi.Ansi; +import org.fusesource.jansi.AnsiConsole; + +public class ZAnsi { + + //поддержка ANSI епта + public static void install() { + AnsiConsole.systemInstall(); + } + + public static void uninstall() { + AnsiConsole.systemUninstall(); + } + + // === Основные цвета === + public static String green(String text) { + return Ansi.ansi().fg(Ansi.Color.GREEN).a(text).reset().toString(); + } + + public static String brightGreen(String text) { + return Ansi.ansi().fgBright(Ansi.Color.GREEN).a(text).reset().toString(); + } + + public static String cyan(String text) { + return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString(); + } + + public static String brightCyan(String text) { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).a(text).reset().toString(); + } + + public static String yellow(String text) { + return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString(); + } + + public static String brightYellow(String text) { + return Ansi.ansi().fgBright(Ansi.Color.YELLOW).a(text).reset().toString(); + } + + public static String red(String text) { + return Ansi.ansi().fg(Ansi.Color.RED).a(text).reset().toString(); + } + + public static String brightRed(String text) { + return Ansi.ansi().fgBright(Ansi.Color.RED).a(text).reset().toString(); + } + + public static String blue(String text) { + return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString(); + } + + public static String brightBlue(String text) { + return Ansi.ansi().fgBright(Ansi.Color.BLUE).a(text).reset().toString(); + } + + public static String magenta(String text) { + return Ansi.ansi().fg(Ansi.Color.MAGENTA).a(text).reset().toString(); + } + + public static String brightMagenta(String text) { + return Ansi.ansi().fgBright(Ansi.Color.MAGENTA).a(text).reset().toString(); + } + + // Пурпурный как brightPurple (используем magenta) + public static String purple(String text) { + return brightMagenta(text); + } + + public static String brightPurple(String text) { + return brightMagenta(text); + } + + public static String white(String text) { + return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString(); + } + + public static String brightWhite(String text) { + return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString(); + } + + public static String black(String text) { + return Ansi.ansi().fg(Ansi.Color.BLACK).a(text).reset().toString(); + } + + // === Фоновые цвета === + public static String bgGreen(String text) { + return Ansi.ansi().bg(Ansi.Color.GREEN).a(text).reset().toString(); + } + + public static String bgRed(String text) { + return Ansi.ansi().bg(Ansi.Color.RED).a(text).reset().toString(); + } + + public static String bgYellow(String text) { + return Ansi.ansi().bg(Ansi.Color.YELLOW).a(text).reset().toString(); + } + + public static String bgBlue(String text) { + return Ansi.ansi().bg(Ansi.Color.BLUE).a(text).reset().toString(); + } + + // === Стили === + public static String bold(String text) { + return Ansi.ansi().bold().a(text).reset().toString(); + } + + public static String reset() { + return Ansi.ansi().reset().toString(); + } + + // === Комбинированные удобные методы === + public static String header(String text) { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString(); + } + + public static String success(String text) { + return Ansi.ansi().fgBright(Ansi.Color.GREEN).bold().a("[✓] " + text).reset().toString(); + } + + public static String error(String text) { + return Ansi.ansi().fgBright(Ansi.Color.RED).bold().a("[✗] " + text).reset().toString(); + } + + public static String warning(String text) { + return Ansi.ansi().fgBright(Ansi.Color.YELLOW).bold().a("[!] " + text).reset().toString(); + } + + public static String info(String text) { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a("[i] " + text).reset().toString(); + } + + public static String selected(String text) { + return Ansi.ansi() + .bgBright(Ansi.Color.WHITE) + .fg(Ansi.Color.BLACK) + .bold() + .a(" > " + text + " ") + .reset() + .toString(); + } + + public static String dim(String text) { + return Ansi.ansi().fgBright(Ansi.Color.BLACK).a(text).reset().toString(); + } + + // === Цветной текст для ролей === + public static String roleUser(String text) { + return white(text); + } + + public static String rolePassHolder(String text) { + return brightGreen(text); + } + + public static String roleModerator(String text) { + return brightBlue(text); + } + + public static String roleElder(String text) { + return brightPurple(text); + } + + public static String roleCreator(String text) { + return brightRed(text); + } + + // === Очистка экрана === + public static String clearScreen() { + return Ansi.ansi().eraseScreen().cursor(1, 1).toString(); + } + + // === Прогресс бар символы === + public static String progressChar() { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).a("█").reset().toString(); + } + + public static String progressEmpty() { + return Ansi.ansi().fg(Ansi.Color.BLACK).a("░").reset().toString(); + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java new file mode 100644 index 0000000..c47f6ce --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -0,0 +1,1027 @@ +package me.sashegdev.zernmc.launcher.utils; + +import org.json.JSONArray; +import org.json.JSONObject; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +public class ZHttpClient { + + /** + * Dedicated daemon executor for the shared HttpClient. sendAsync() work runs + * here (never the common ForkJoinPool, so a stalled download cannot starve + * JFX's pools), and daemon threads never keep the launcher JVM alive. + */ + private static final ExecutorService CLIENT_EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "zern-http-client"); + t.setDaemon(true); + return t; + }); + + private static final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .version(HttpClient.Version.HTTP_1_1) + .executor(CLIENT_EXECUTOR) + .build(); + + private static String BASE_URL = "https://api.zern.cc"; + + /** + * Alternate proxy endpoints (geo mirrors) used when BASE_URL's proxy fails with + * a connection error (e.g. CDN "Connection reset" from the current region). + * Stay in sync with LAUNCHER_MIRRORS in server/main.py. + */ + private static final List GEO_PROXY_BASES = List.of( + "https://api.zernmc.ru", + "https://api.zernmc.online", + "https://api.pl.zern.cc", + "https://api.swe.zern.cc" + ); + + /** + * Extra proxy bases discovered at runtime (e.g. mirrors fetched from + * /launcher/mirrors by DomainSelector). Kept in insertion order, deduplicated. + */ + private static final List extraProxyBases = new ArrayList<>(); + + private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); + private static final AtomicBoolean proxyTested = new AtomicBoolean(false); + private static final AtomicBoolean healthThreadStarted = new AtomicBoolean(false); + + public static void setBaseUrl(String url) { + BASE_URL = url; + } + + /** + * Registers an additional proxy base URL (mirror/geo domain) that the smart + * proxy fallback may rotate through. No-op if already present. + */ + public static void registerProxyBase(String url) { + if (url == null || url.isBlank()) return; + String normalized = url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + synchronized (extraProxyBases) { + if (!extraProxyBases.contains(normalized)) { + extraProxyBases.add(normalized); + } + } + } + + /** + * Ordered, deduplicated list of proxy endpoints to attempt: the primary + * BASE_URL first, then geo mirrors and any runtime-discovered mirrors. + */ + private static List getProxyBases() { + List bases = new ArrayList<>(); + bases.add(BASE_URL); + for (String geo : GEO_PROXY_BASES) { + if (!bases.contains(geo)) bases.add(geo); + } + synchronized (extraProxyBases) { + for (String m : extraProxyBases) { + if (!bases.contains(m)) bases.add(m); + } + } + return bases; + } + + public static String getBaseUrl() { + return BASE_URL; + } + + public enum ServiceType { + ZERN_SERVER("https://api.zern.cc", true), + FABRIC_META("https://meta.fabricmc.net", false), + FABRIC_MAVEN("https://maven.fabricmc.net", false), + MOJANG_META("https://piston-meta.mojang.com", false), + MOJANG_RESOURCES("https://resources.download.minecraft.net", false), + FORGE_MAVEN("https://maven.minecraftforge.net", false), + NEOFORGE_MAVEN("https://maven.neoforged.net", false), + GOOGLE("https://google.com", false), + CLOUDFLARE("https://cloudflare.com", false); + + private final String baseUrl; + private final boolean alwaysDirect; + + ServiceType(String baseUrl, boolean alwaysDirect) { + this.baseUrl = baseUrl; + this.alwaysDirect = alwaysDirect; + } + + public String getBaseUrl() { return baseUrl; } + public boolean isAlwaysDirect() { return alwaysDirect; } + } + + private static final Map serviceProxyMode = new ConcurrentHashMap<>(); + private static final Map serviceFailCount = new ConcurrentHashMap<>(); + private static final Map serviceLastCheckTime = new ConcurrentHashMap<>(); + private static final Map serviceHealthy = new ConcurrentHashMap<>(); + + private static final int MAX_FAILS_BEFORE_PROXY = 1; + private static final long HEALTH_CHECK_INTERVAL_MS = 60000; + private static final long CHECK_TIMEOUT_MS = 7000; + + private static int directSuccessCount = 0; + private static int proxySuccessCount = 0; + private static int directFailCount = 0; + + static { + for (ServiceType type : ServiceType.values()) { + serviceProxyMode.put(type, false); + serviceFailCount.put(type, 0); + serviceHealthy.put(type, false); + } + } + + public static void checkAllServicesOnStartup() { + checkAllServicesOnStartup(false); + } + + public static void checkAllServicesOnStartup(boolean verbose) { + if (proxyTested.get()) return; + + List servicesToCheck = List.of( + ServiceType.ZERN_SERVER, + ServiceType.GOOGLE, + ServiceType.FABRIC_META, + ServiceType.FABRIC_MAVEN, + ServiceType.MOJANG_META, + ServiceType.MOJANG_RESOURCES, + ServiceType.FORGE_MAVEN, + ServiceType.NEOFORGE_MAVEN + ); + + for (ServiceType service : servicesToCheck) { + boolean isHealthy = checkServiceHealth(service); + serviceHealthy.put(service, isHealthy); + + if (service.isAlwaysDirect()) { + if (verbose) { + System.out.println(isHealthy ? + ZAnsi.green(" " + service.name() + " - OK") : + ZAnsi.red(" " + service.name() + " - NOT ACCESSIBLE (critical!)")); + } + } else { + if (isHealthy) { + if (verbose) { + System.out.println(ZAnsi.green(" " + service.name() + " - direct connection works")); + } + } else { + if (verbose) { + System.out.println(ZAnsi.yellow(" " + service.name() + " - NOT ACCESSIBLE, proxy will be used")); + } + serviceProxyMode.put(service, true); + serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); + } + } + LauncherLogger.info("[NET] " + service.name() + ": " + + (isHealthy ? "OK" : "unavailable") + + (serviceProxyMode.get(service) ? " (proxy mode)" : " (direct)")); + } + + if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { + if (verbose) { + System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!")); + } + LauncherLogger.warn("[NET] Critical: Zern server unreachable"); + } else { + String ip = getClientPublicIp(); + LauncherLogger.info("[NET] Client public IP: " + (ip != null ? ip : "unknown")); + } + + proxyTested.set(true); + startHealthCheckThread(); + if (verbose) { + printStats(); + } + } + + /** + * Asks the Zern server what public IP it sees for this client (used to + * attribute user logs when login/registration fails). Returns null on failure. + */ + public static String getClientPublicIp() { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/launcher/ip")) + .timeout(Duration.ofMillis(15000)) + .GET() + .header("User-Agent", "ZernMC-Launcher/IPCheck") + .build(); + HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 15); + if (response.statusCode() == 200) { + JSONObject json = new JSONObject(response.body()); + return json.optString("ip", null); + } + } catch (Exception e) { + return null; + } + return null; + } + + public static void forceCheckMojangServices() { + System.out.println(ZAnsi.cyan("Forcing Mojang services check...")); + + for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) { + boolean healthy = checkServiceHealth(service); + serviceHealthy.put(service, healthy); + + if (healthy) { + System.out.println(ZAnsi.green(" " + service.name() + " accessible directly")); + serviceProxyMode.put(service, false); + serviceFailCount.put(service, 0); + } else { + System.out.println(ZAnsi.yellow(" " + service.name() + " not accessible -> proxy mode activated")); + serviceProxyMode.put(service, true); + serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); + } + } + } + + private static boolean checkServiceHealth(ServiceType service) { + if (service == ServiceType.ZERN_SERVER) { + return checkDirectConnection(BASE_URL); + } + return checkDirectConnection(service.getBaseUrl()); + } + + private static boolean checkDirectConnection(String baseUrl) { + String testUrl = baseUrl; + + if (baseUrl.contains("piston-meta.mojang.com") || baseUrl.contains("launchermeta.mojang.com")) { + testUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + } else if (baseUrl.contains("resources.download.minecraft.net")) { + testUrl = "https://resources.download.minecraft.net/00/0000000000000000000000000000000000000000"; + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(testUrl)) + .timeout(Duration.ofMillis(CHECK_TIMEOUT_MS)) + .GET() + .header("User-Agent", "ZernMC-Launcher/HealthCheck") + .build(); + + HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 10); + int code = response.statusCode(); + return code == 200 || code == 404; + } catch (Exception e) { + return false; + } + } + + private static void startHealthCheckThread() { + if (!healthThreadStarted.compareAndSet(false, true)) return; + + Thread healthThread = new Thread(() -> { + while (true) { + try { + Thread.sleep(HEALTH_CHECK_INTERVAL_MS); + performHealthCheck(); + } catch (InterruptedException e) { + break; + } + } + }); + healthThread.setDaemon(true); + healthThread.start(); + } + + private static void performHealthCheck() { + for (ServiceType service : ServiceType.values()) { + if (service.isAlwaysDirect()) continue; + + boolean isHealthy = checkServiceHealth(service); + serviceHealthy.put(service, isHealthy); + + if (isHealthy && serviceProxyMode.get(service)) { + serviceProxyMode.put(service, false); + serviceFailCount.put(service, 0); + LauncherLogger.info("[NET] " + service.name() + " restored, switched to direct connection"); + } else if (!isHealthy && !serviceProxyMode.get(service)) { + int fails = serviceFailCount.getOrDefault(service, 0) + 1; + serviceFailCount.put(service, fails); + serviceLastCheckTime.put(service, System.currentTimeMillis()); + + if (fails >= MAX_FAILS_BEFORE_PROXY) { + serviceProxyMode.put(service, true); + LauncherLogger.warn("[NET] " + service.name() + " unavailable, proxy mode enabled"); + } + } + } + } + + private static ServiceType detectService(String url) { + if (url.contains("meta.fabricmc.net")) return ServiceType.FABRIC_META; + if (url.contains("maven.fabricmc.net")) return ServiceType.FABRIC_MAVEN; + if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com")) + return ServiceType.MOJANG_META; + if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES; + if (url.contains("maven.minecraftforge.net") || url.contains("files.minecraftforge.net")) return ServiceType.FORGE_MAVEN; + if (url.contains("maven.neoforged.net")) return ServiceType.NEOFORGE_MAVEN; + if (url.contains("google.com")) return ServiceType.GOOGLE; + if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE; + if (url.contains("libraries.minecraft.net")) return ServiceType.MOJANG_RESOURCES; + if (url.contains("piston-data.mojang.com")) return ServiceType.MOJANG_META; + if (url.contains("repo1.maven.org") || url.contains("repo.maven.apache.org")) return ServiceType.MOJANG_RESOURCES; + return null; + } + + private static boolean shouldUseProxyForUrl(String url) { + if (useProxyMode.get()) return true; + + ServiceType service = detectService(url); + if (service == null || service.isAlwaysDirect()) return false; + + if (serviceProxyMode.getOrDefault(service, false)) return true; + + return serviceFailCount.getOrDefault(service, 0) >= MAX_FAILS_BEFORE_PROXY; + } + + private static boolean isConnectionError(Throwable e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + String msg = cause.getMessage() != null ? cause.getMessage().toLowerCase() : ""; + + return cause instanceof java.net.ConnectException || + cause instanceof java.net.UnknownHostException || + cause instanceof java.nio.channels.ClosedChannelException || + cause instanceof java.net.SocketException || + msg.contains("connection") || + msg.contains("timeout") || + msg.contains("refused") || + msg.contains("received within") || + msg.contains("closed") || + msg.contains("reset") || + msg.contains("abort"); + } + + /** + * Whether an error should be retried. Includes connection errors and transient + * server-side failures (5xx / 429) from the Zern proxy or upstream services. + */ + private static boolean isRetryableError(Throwable e) { + if (isConnectionError(e)) return true; + String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : ""; + return msg.contains("http 5") || msg.contains("http 429"); + } + + private static void markServiceAsBlocked(String url) { + ServiceType service = detectService(url); + if (service == null || service.isAlwaysDirect()) return; + + int fails = serviceFailCount.getOrDefault(service, 0) + 1; + serviceFailCount.put(service, fails); + serviceLastCheckTime.put(service, System.currentTimeMillis()); + + if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) { + serviceProxyMode.put(service, true); + LauncherLogger.warn("[NET] " + service.name() + " blocked, switching to proxy"); + } + } + + /** + * HTTP send with a hard, actually-enforced timeout. + * + * NB: HttpRequest.timeout() alone is NOT reliable for streaming body handlers + * (BodyHandlers.ofFile/ofString): the JDK cancels the request timer as soon as + * response headers arrive, so a download that stalls mid-body blocks + * client.send() forever (no exception, no retry, no proxy fallback — the + * exact "install hangs at 0%" bug). Therefore we drive the exchange through + * sendAsync() + future.get(timeout) and abort it with cancel() on timeout. + * + * The client runs on a dedicated daemon executor (see CLIENT_EXECUTOR), so + * this does not reintroduce the v1.0.14.2 JFX hang (ForkJoinPool starvation / + * non-daemon executor keeping the JVM alive) that forced the old sync-send + * implementation. + */ + private static HttpResponse sendBounded(HttpRequest request, HttpResponse.BodyHandler handler, long timeoutSeconds) throws IOException, InterruptedException { + HttpRequest timed = HttpRequest.newBuilder(request, (name, value) -> true) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .build(); + CompletableFuture> future = client.sendAsync(timed, handler); + try { + return future.get(timeoutSeconds, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + throw new HttpTimeoutException("Response not received within " + timeoutSeconds + "s: " + timed.uri()); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof IOException) throw (IOException) cause; + if (cause instanceof InterruptedException) throw (InterruptedException) cause; + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + throw new IOException("HTTP request failed: " + timed.uri(), cause); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw e; + } + } + + public static String getWithSmartProxy(String url) throws IOException, InterruptedException { + if (!shouldUseProxyForUrl(url)) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(25)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 30); + + if (response.statusCode() == 200) { + directSuccessCount++; + return response.body(); + } + } catch (Exception e) { + if (e instanceof IOException && isConnectionError(e)) { + directFailCount++; + markServiceAsBlocked(url); + } else if (!(e instanceof IOException)) { + throw e; + } + // any direct failure: the same resource is retried via the proxy below + } + } + + List proxyBases = getProxyBases(); + int maxRetries = 3; + for (int attempt = 1; attempt <= maxRetries; attempt++) { + // Rotate through every proxy base so one region's Connection Reset does not + // burn all retries on the same endpoint. + String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size()); + try { + String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); + String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(proxyUrl)) + .timeout(Duration.ofSeconds(40)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 45); + + if (response.statusCode() != 200) { + throw new IOException("Proxy HTTP " + response.statusCode()); + } + + proxySuccessCount++; + return response.body(); + + } catch (Exception e) { + if (attempt == maxRetries || !isRetryableError(e)) { + throw new IOException("Failed to fetch data directly or via proxy (" + proxyBase + "): " + e.getMessage(), e); + } + LauncherLogger.warn("[NET] Proxy fetch attempt " + attempt + "/" + maxRetries + + " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base"); + try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; } + } + } + + throw new IOException("Failed to fetch data: " + url); + } + + public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { + Path tmp = target.resolveSibling(target.getFileName() + ".part"); + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) {} + + if (!shouldUseProxyForUrl(url)) { + try { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(40)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET(); + + if (url.startsWith(BASE_URL)) { + String accessToken = AuthManager.getAccessToken(); + if (accessToken != null && !accessToken.equals("0")) { + requestBuilder.header("Authorization", "Bearer " + accessToken); + } + } + + HttpRequest request = requestBuilder.build(); + + HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 45); + + if (response.statusCode() == 200) { + moveIntoPlace(tmp, target); + directSuccessCount++; + return; + } + // non-200 response: fall through to proxy below + } catch (Exception e) { + if (isConnectionError(e)) { + directFailCount++; + markServiceAsBlocked(url); + } else { + throw e; + } + // any direct failure: the same resource is retried via the proxy below + } + } + + List proxyBases = getProxyBases(); + int maxRetries = 3; + for (int attempt = 1; attempt <= maxRetries; attempt++) { + // Rotate through every proxy base so one region's Connection Reset does not + // burn all retries on the same endpoint. + String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size()); + try { + String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); + String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl; + + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) {} + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(proxyUrl)) + .timeout(Duration.ofMinutes(5)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 300); + + if (response.statusCode() != 200) { + throw new IOException("Proxy download failed: HTTP " + response.statusCode()); + } + + moveIntoPlace(tmp, target); + proxySuccessCount++; + return; + + } catch (Exception e) { + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) {} + if (attempt == maxRetries || !isRetryableError(e)) { + throw new IOException("Proxy download failed (" + proxyBase + "): " + e.getMessage(), e); + } + LauncherLogger.warn("[NET] Proxy download attempt " + attempt + "/" + maxRetries + + " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base"); + try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; } + } + } + } + + /** + * Atomically replaces the destination with the fully-downloaded temp file. + * Prevents a Connection Reset mid-transfer from leaving a corrupt partial + * file at the final path that later "file exists" checks would treat as installed. + */ + private static void moveIntoPlace(Path tmp, Path target) throws IOException { + Files.createDirectories(target.getParent()); + try { + Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + + /** + * Retries a request against the Zern server. The server proxies are slow on cold cache + * and may return transient 502/5xx when upstreams are flaky, so a single failure should + * not immediately force a direct fallback. + */ + public static String getRetry(String endpoint, int maxAttempts) throws IOException, InterruptedException { + IOException last = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return get(endpoint); + } catch (IOException e) { + last = e; + if (!isRetryableError(e)) throw e; + if (attempt < maxAttempts) { + LauncherLogger.warn("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint + " (" + e.getMessage() + ")"); + try { Thread.sleep(500L * attempt); } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw ie; + } + } + } + } + throw last; + } + + public static String get(String endpoint) throws IOException, InterruptedException { + try { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + endpoint)) + .timeout(Duration.ofSeconds(15)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET(); + + String accessToken = AuthManager.getAccessToken(); + if (accessToken != null && !accessToken.equals("0")) { + requestBuilder.header("Authorization", "Bearer " + accessToken); + } + + HttpRequest request = requestBuilder.build(); + HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 20); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + return response.body(); + } catch (Exception e) { + directFailCount++; + throw e; + } + } + + public static List getFabricLoaderVersions() throws IOException, InterruptedException { + String url = "https://meta.fabricmc.net/v2/versions/loader"; + return parseFabricVersionsFromJson(getWithSmartProxy(url)); + } + + public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException { + try { + return getMojangVersionManifestViaServer(); + } catch (Exception e) { + LauncherLogger.warn("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta..."); + return new JSONObject(getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")); + } + } + + /** + * Fetch the Mojang version manifest through the Zern server proxy. + * The launcher's own API is reliably reachable, and the manifest is cached server-side. + */ + public static JSONObject getMojangVersionManifestViaServer() throws IOException, InterruptedException { + return new JSONObject(getRetry("/proxy/mojang/version_manifest", 3)); + } + + /** + * Fetch a single Mojang version JSON through the Zern server proxy. + * Avoids direct connections to piston-meta.mojang.com entirely. + */ + public static JSONObject getMojangVersionViaServer(String versionId) throws IOException, InterruptedException { + return new JSONObject(getRetry("/proxy/mojang/version/" + URLEncoder.encode(versionId, StandardCharsets.UTF_8), 3)); + } + + public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException { + long deadline = System.currentTimeMillis() + 90_000; + LauncherLogger.info("[NET] Fetching version info for " + versionId + " via server proxy..."); + try { + JSONObject result = getMojangVersionViaServer(versionId); + LauncherLogger.info("[NET] Version info for " + versionId + " fetched via proxy (" + (System.currentTimeMillis() - (deadline - 90_000)) + "ms)"); + return result; + } catch (Exception e) { + if (System.currentTimeMillis() >= deadline) { + throw new IOException("Version " + versionId + " fetch timed out after 90s: " + e.getMessage(), e); + } + LauncherLogger.warn("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta..."); + JSONObject manifest = getMojangVersionManifest(); + JSONArray versions = manifest.getJSONArray("versions"); + + for (int i = 0; i < versions.length(); i++) { + JSONObject v = versions.getJSONObject(i); + if (v.getString("id").equals(versionId)) { + if (System.currentTimeMillis() >= deadline) { + throw new IOException("Version " + versionId + " fetch timed out after 90s"); + } + LauncherLogger.info("[NET] Fetching version info for " + versionId + " directly..."); + JSONObject result = new JSONObject(getWithSmartProxy(v.getString("url"))); + LauncherLogger.info("[NET] Version info for " + versionId + " fetched directly"); + return result; + } + } + throw new IOException("Version " + versionId + " not found"); + } + } + + public static String getForgeVersionsXml() throws IOException, InterruptedException { + String url = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"; + return getWithSmartProxy(url); + } + + public static void downloadFile(String url, Path target) throws Exception { + downloadFileWithSmartProxy(url, target); + } + + public static void downloadAsset(String hash, Path target) throws Exception { + String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; + downloadFileWithSmartProxy(url, target); + } + + public static String downloadString(String url) throws IOException, InterruptedException { + return getWithSmartProxy(url); + } + + /** + * Scans every installed version JSON in the instance and downloads any library file + * that is missing. Used after loader installers run (their subprocess downloads can + * time out on restricted networks), so remaining libraries are pulled through the smart proxy. + */ + public static void repairMissingLibraries(Path minecraftDir) { + Path versionsDir = minecraftDir.resolve("versions"); + if (!Files.isDirectory(versionsDir)) return; + + try (Stream stream = Files.list(versionsDir)) { + stream.filter(Files::isDirectory).forEach(versionDir -> { + Path versionJson = versionDir.resolve(versionDir.getFileName() + ".json"); + if (Files.exists(versionJson)) { + try { + repairLibrariesFromJson(versionJson, minecraftDir); + } catch (Exception e) { + LauncherLogger.warn("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage()); + } + } + }); + } catch (IOException e) { + LauncherLogger.warn("[LIB] Repair scan failed: " + e.getMessage()); + } + } + + /** + * Downloads every missing library listed in a version/loadermod JSON into minecraftDir/libraries. + * Supports both the Minecraft format (downloads.artifact.path/url) and the maven-coordinates + * format (name + base url) used by Fabric loadermod JSONs. + */ + public static void repairLibrariesFromJson(Path jsonPath, Path minecraftDir) throws Exception { + JSONObject root = new JSONObject(Files.readString(jsonPath)); + if (!root.has("libraries")) return; + + JSONArray libraries = root.getJSONArray("libraries"); + int repaired = 0; + + for (int i = 0; i < libraries.length(); i++) { + JSONObject lib = libraries.getJSONObject(i); + try { + if (lib.has("downloads") && lib.getJSONObject("downloads").has("artifact")) { + JSONObject art = lib.getJSONObject("downloads").getJSONObject("artifact"); + String url = art.getString("url"); + String path = art.getString("path"); + + Path target = minecraftDir.resolve("libraries").resolve(path); + if (!Files.exists(target)) { + Files.createDirectories(target.getParent()); + downloadFileWithSmartProxy(url, target); + repaired++; + } + } else if (lib.has("name") && lib.has("url")) { + Path target = resolveCoordinatesTarget(lib); + if (target != null && !Files.exists(target)) { + Files.createDirectories(target.getParent()); + downloadCoordinatesArtifact(lib, target); + repaired++; + } + } + } catch (Exception e) { + String libName = lib.optString("name", lib.optString("path", "?")); + LauncherLogger.warn("[LIB] Repair failed for " + libName + ": " + e.getMessage()); + } + } + + if (repaired > 0) { + LauncherLogger.info("[LIB] Repaired " + repaired + " missing libraries"); + } + } + + /** + * Pre-downloads every library referenced by a loader installer jar (Forge/NeoForge). + * The installer later runs as a separate JVM subprocess that downloads libraries + * directly from the loader's maven repository, bypassing this launcher's smart proxy. + * On blocked networks (e.g. maven.minecraftforge.net unreachable) that subprocess + * fails with "Connection reset". Because the installer reuses any library already + * present with a matching checksum ("File exists: Checksum validated"), pre-seeding + * those files through the proxy lets the install complete without direct downloads. + * + * @return number of new library files downloaded + */ + public static int prefetchInstallerLibraries(Path installerJar, Path minecraftDir) { + int prefetched = 0; + try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(installerJar.toFile())) { + for (String jsonName : List.of("version.json", "install_profile.json")) { + java.util.zip.ZipEntry entry = zip.getEntry(jsonName); + if (entry == null) continue; + String content = new String(zip.getInputStream(entry).readAllBytes(), StandardCharsets.UTF_8); + JSONObject root = new JSONObject(content); + JSONArray libs = root.optJSONArray("libraries"); + if (libs == null) continue; + for (int i = 0; i < libs.length(); i++) { + JSONObject lib = libs.optJSONObject(i); + if (lib == null) continue; + JSONObject downloads = lib.optJSONObject("downloads"); + if (downloads == null) continue; + JSONObject artifact = downloads.optJSONObject("artifact"); + if (artifact == null) continue; + String url = artifact.optString("url", null); + String path = artifact.optString("path", null); + String sha1 = artifact.optString("sha1", null); + if (url == null || path == null) continue; + + Path target = minecraftDir.resolve("libraries").resolve(path); + try { + if (Files.exists(target)) { + if (sha1 == null || sha1.isEmpty() || sha1Matches(target, sha1)) { + continue; + } + Files.delete(target); + } + Files.createDirectories(target.getParent()); + downloadFileWithSmartProxy(url, target); + if (sha1 != null && !sha1.isEmpty() && !sha1Matches(target, sha1)) { + Files.delete(target); + LauncherLogger.warn("[LIB] Checksum mismatch after prefetch: " + path); + continue; + } + prefetched++; + LauncherLogger.info("[LIB] Prefetched " + path); + } catch (Exception e) { + LauncherLogger.warn("[LIB] Prefetch failed for " + path + ": " + e.getMessage()); + } + } + } + } catch (Exception e) { + LauncherLogger.warn("[LIB] Installer prefetch scan failed: " + e.getMessage()); + } + return prefetched; + } + + private static boolean sha1Matches(Path file, String expectedSha1) throws Exception { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-1"); + try (java.io.InputStream in = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + digest.update(buffer, 0, read); + } + } + StringBuilder sb = new StringBuilder(); + for (byte b : digest.digest()) { + sb.append(String.format("%02x", b)); + } + return sb.toString().equalsIgnoreCase(expectedSha1); + } + + private static String resolveCoordinatesUrl(JSONObject lib) { + String name = lib.getString("name"); + String base = lib.getString("url"); + String[] parts = name.split(":"); + String group = parts[0]; + String artifact = parts[1]; + String version = parts[2]; + String classifier = parts.length > 3 ? parts[3] : null; + + String basePath = group.replace('.', '/') + "/" + artifact + "/" + version + "/"; + String fileName = artifact + "-" + version + (classifier != null ? "-" + classifier : "") + ".jar"; + return base.endsWith("/") ? base + basePath + fileName : base + "/" + basePath + fileName; + } + + private static String resolveCoordinatesUrlV2(JSONObject lib) { + String name = lib.getString("name"); + String base = lib.getString("url"); + String[] parts = name.split(":"); + String group = parts[0]; + String artifact = parts[1]; + String version = parts[2]; + String classifier = parts.length > 3 ? parts[3] : null; + + String basePath = group.replace('.', '/') + "/" + artifact + "/" + version + "/"; + String fileName = artifact + "-" + version + "-v2" + (classifier != null ? "-" + classifier : "") + ".jar"; + return base.endsWith("/") ? base + basePath + fileName : base + "/" + basePath + fileName; + } + + private static void downloadCoordinatesArtifact(JSONObject lib, Path target) throws Exception { + IOException last = null; + for (String candidateUrl : List.of(resolveCoordinatesUrl(lib), resolveCoordinatesUrlV2(lib))) { + try { + downloadFileWithSmartProxy(candidateUrl, target); + return; + } catch (IOException e) { + last = e; + Files.deleteIfExists(target); + } + } + throw last != null ? last : new IOException("Could not resolve coordinates artifact: " + lib.optString("name", "?")); + } + + private static Path resolveCoordinatesTarget(JSONObject lib) { + String name = lib.getString("name"); + String[] parts = name.split(":"); + if (parts.length < 3) return null; + String group = parts[0]; + String artifact = parts[1]; + String version = parts[2]; + String classifier = parts.length > 3 ? parts[3] : null; + + String basePath = group.replace('.', '/') + "/" + artifact + "/" + version + "/"; + String fileName = artifact + "-" + version + (classifier != null ? "-" + classifier : "") + ".jar"; + return Path.of(basePath, fileName); + } + + private static List parseFabricVersionsFromJson(String json) { + JSONArray array = new JSONArray(json); + List versions = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + if (obj.has("version")) { + versions.add(obj.getString("version")); + } + } + return versions; + } + + public static String getLauncherVersionInfo() throws IOException, InterruptedException { + return get("/launcher/version"); + } + + public static void forceProxyMode() { + useProxyMode.set(true); + System.out.println(ZAnsi.yellow("Global proxy mode forced on")); + } + + public static void disableProxyMode() { + useProxyMode.set(false); + for (ServiceType type : ServiceType.values()) { + if (!type.isAlwaysDirect()) { + serviceProxyMode.put(type, false); + serviceFailCount.put(type, 0); + } + } + System.out.println(ZAnsi.green("Proxy mode disabled")); + } + + public static boolean isProxyMode() { + return useProxyMode.get(); + } + + public static boolean isNetworkInitialized() { + return proxyTested.get(); + } + + public static boolean isZernServerHealthy() { + return serviceHealthy.getOrDefault(ServiceType.ZERN_SERVER, false); + } + + public static Map getNetworkStatus() { + Map status = new HashMap<>(); + status.put("initialized", proxyTested.get()); + status.put("globalProxy", useProxyMode.get()); + status.put("directSuccess", directSuccessCount); + status.put("directFail", directFailCount); + status.put("proxySuccess", proxySuccessCount); + + List> services = new ArrayList<>(); + for (ServiceType type : ServiceType.values()) { + if (type.isAlwaysDirect()) continue; + Map s = new HashMap<>(); + s.put("name", type.name()); + s.put("baseUrl", type.getBaseUrl()); + s.put("healthy", serviceHealthy.getOrDefault(type, false)); + s.put("proxy", serviceProxyMode.getOrDefault(type, false)); + s.put("fails", serviceFailCount.getOrDefault(type, 0)); + services.add(s); + } + status.put("services", services); + return status; + } + + public static void printStats() { + System.out.println(ZAnsi.cyan("\n=== Network Stats ===")); + System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF")); + System.out.println(ZAnsi.white("Direct successes: ") + directSuccessCount); + System.out.println(ZAnsi.white("Direct failures: ") + directFailCount); + System.out.println(ZAnsi.white("Proxy successes: ") + proxySuccessCount); + + System.out.println(ZAnsi.cyan("\nService status:")); + for (ServiceType type : ServiceType.values()) { + if (type.isAlwaysDirect()) continue; + String status = serviceProxyMode.get(type) ? ZAnsi.red("PROXY") : ZAnsi.green("DIRECT"); + String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]"); + System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health); + } + } +} diff --git a/launcher/launcher/src/resources/ui/icons/zernmc.ico b/launcher/launcher/src/resources/ui/icons/zernmc.ico new file mode 100644 index 0000000..6f60d98 Binary files /dev/null and b/launcher/launcher/src/resources/ui/icons/zernmc.ico differ diff --git a/launcher/launcher/src/resources/ui/icons/zernmc.png b/launcher/launcher/src/resources/ui/icons/zernmc.png new file mode 100644 index 0000000..45e75c2 Binary files /dev/null and b/launcher/launcher/src/resources/ui/icons/zernmc.png differ diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html new file mode 100644 index 0000000..41afe00 --- /dev/null +++ b/launcher/launcher/src/resources/ui/index.html @@ -0,0 +1,618 @@ + + + + + + ZernMC Launcher + + + + + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/launcher/launcher/src/resources/ui/launcher.js b/launcher/launcher/src/resources/ui/launcher.js new file mode 100644 index 0000000..3ba1fbe --- /dev/null +++ b/launcher/launcher/src/resources/ui/launcher.js @@ -0,0 +1,2645 @@ +const API = '/api'; + +const LOCALES = { + en: { + 'nav.packs': 'Packs', 'nav.news': 'News', 'nav.settings': 'Settings', + 'login.title': 'Sign In', 'login.username': 'Username', 'login.password': 'Password', + 'login.subtitle': 'Welcome back to ZernMC', + 'login.hint': 'Need an account?', + 'login.register': 'Register', + 'login.registerTitle': 'Create Account', + 'login.registerSubtitle': 'Join ZernMC today', + 'login.hasAccount': 'Already have an account?', + 'login.confirm': 'Confirm Password', + 'login.passMismatch': 'Passwords do not match', + 'login.passTooShort': 'Password must be at least 6 characters', + 'login.signingIn': 'Signing in...', + 'loading.text': 'Loading...', + 'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs', + 'pack.empty.title': 'Select a pack', + 'pack.empty.desc': 'Choose a pack from the sidebar to get started', + 'pack.description.loading': 'Loading description...', + 'pack.noDescription': 'No description available', + 'stat.loaderVer': 'Loader Ver', 'stat.files': 'Files', 'stat.size': 'Size', + 'playBar.play': 'Play', 'playBar.passRequired': 'Pass Required', + 'playBar.update': 'Update', 'playBar.updating': 'Updating...', + 'settings.title': 'Settings', + 'settings.activatePass.title': 'Activate Pass', + 'settings.activatePass.desc': 'Enter your pass code to access server packs', + 'settings.activatePass.placeholder': 'Pass code', + 'settings.activatePass.button': 'Activate', + 'settings.ram.title': 'Allocated RAM', + 'settings.resolution.title': 'Game Resolution', + 'settings.resolution.desc': 'Width x Height', + 'settings.jvmArgs.title': 'Extra JVM Arguments', + 'settings.jvmArgs.desc': 'Additional Java VM options', + 'settings.javaPath.title': 'Java Path', + 'settings.server.title': 'Server', + 'settings.server.checking': 'Checking...', + 'settings.server.connected': 'Connected', + 'settings.server.disconnected': 'Disconnected', + 'settings.network.title': 'Network', + 'settings.network.desc': 'Per-service connection mode (direct / via proxy)', + 'settings.network.direct': 'Direct', + 'settings.network.proxy': 'Proxy', + 'settings.network.down': 'Unavailable', + 'settings.network.loading': 'Checking...', + 'settings.network.unavailable': 'Not available', + 'settings.language.title': 'Language', + 'settings.language.desc': 'Interface language', + 'settings.systemJvm.title': 'System-based JVM Optimization', + 'settings.systemJvm.desc': 'Auto-tune JVM flags based on your system (experimental)', + 'settings.systemJvm.info': 'CPU: {cores} cores | RAM: {ram} GB | {flags}', + 'settings.systemJvm.enabled': 'Enabled', 'settings.systemJvm.disabled': 'Disabled', + 'settings.logViewer.title': 'Game Log', + 'settings.logViewer.desc': 'View real-time game logs', + 'settings.logViewer.open': 'Open Log', + 'logViewer.title': 'Game Log', + 'logViewer.noLogs': 'No logs yet. Launch the game to see logs here.', + 'logViewer.copy': 'Copy', 'logViewer.copied': 'Copied!', + 'logViewer.openFile': 'Open File', 'logViewer.close': 'Close', + 'logViewer.opened': 'File opened', + 'logViewer.openFailed': 'Could not open the log file', + 'logViewer.waiting': 'Waiting for game output...', + 'install.title': 'Install Pack', + 'install.tab.serverPack': 'Server Pack', 'install.tab.custom': 'Custom', + 'install.serverPack.label': 'Server Pack', + 'install.localName.label': 'Local Name', + 'install.downloadBtn': 'Download & Install', + 'install.custom.unavailable': 'Not available yet', + 'install.custom.desc': 'Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.', + 'install.progress.installing': 'Installing...', + 'install.enterName': 'Enter a name', + 'install.step': 'Step {current} of {total}: {name}', + 'select.loading': 'Loading...', + 'select.selectPack': 'Select a pack', + 'select.passRequired': 'Pass required', + 'select.noPacks': 'No packs available', + 'select.selectVersion': 'Select version', + 'select.failedLoad': 'Failed to load', + 'select.selectMCFirst': 'Select MC version first', + 'select.placeholder': 'Select...', + 'select.search': 'Search...', + 'packList.empty': 'No packs', + 'news.title': 'News', 'news.empty': 'No news yet', 'news.loading': 'Loading news...', + 'pack.update': 'Update', 'pack.delete': 'Delete', + 'badge.free': 'FREE', 'badge.pro': 'PRO', + 'toast.welcomeBack': 'Welcome back, {username}!', + 'toast.welcome': 'Welcome, {username}!', + 'toast.accountCreated': 'Account created! Welcome, {username}!', + 'toast.loggedOut': 'Logged out', + 'toast.enterCredentials': 'Enter username and password', + 'toast.loginFailed': 'Login failed', + 'login.errNetwork': 'Could not reach the server. Check your connection.', + 'login.errInvalidInput': 'Invalid data. The password must be at least 6 characters.', + 'login.errTaken': 'This username is already taken.', + 'login.errBadCredentials': 'Wrong username or password.', + 'login.errServer': 'The server returned an error. Try again later.', + 'offline.title': 'Offline Mode', + 'offline.subtitle': 'The Zern server is unreachable. You can still launch locally installed packs without an account.', + 'offline.nickname': 'Nickname', + 'offline.instance': 'Pack', + 'offline.noInstances': 'No locally installed packs found', + 'offline.launch': 'Launch Offline', + 'offline.launching': 'Launching...', + 'offline.needNickname': 'Enter a nickname', + 'offline.nickTooLong': 'Nickname must be at most 16 characters', + 'offline.needInstance': 'Select a pack', + 'offline.retry': 'Try again online', + 'offline.exit': 'Exit', + 'offline.stillOffline': 'Server is still unreachable', + 'toast.launching': 'Launching {name}...', + 'toast.launchFailed': 'Launch failed', + 'toast.updated': '{name} updated to v{version}!', + 'toast.updateFailed': 'Update failed', + 'toast.noPackSelected': 'Select a pack first', + 'toast.launched': 'Launched! PID: {pid}', + 'toast.installing': 'Installing...', + 'toast.installComplete': 'Installation complete!', + 'toast.installFailed': 'Installation failed', + 'toast.enterPassCode': 'Enter a pass code', + 'toast.passActivated': 'Pass activated!', + 'toast.activationFailed': 'Activation failed', + 'error.network': 'Network error', + 'pack.modsCount': '{count} mods', + 'pack.worldsCount': '{count} worlds', + 'pack.logsCount': '{count} logs', + 'locale.en': 'English', 'locale.ru': 'Russian', + 'ram.gb': '{value} GB', + 'ram.mbAllocated': '{value} MB allocated', + 'nav.friends': 'Friends', + 'friends.title': 'Friends', + 'friends.add': 'Add', + 'friends.addTitle': 'Add Friend', + 'friends.addLabel': 'Username', + 'friends.addPlaceholder': 'Enter username...', + 'friends.search': 'Search friends...', + 'friends.empty': 'No friends yet', + 'friends.online': 'Online', + 'friends.offline': 'Offline', + 'friends.playing': 'Playing {pack}', + 'friends.noPack': 'Idle', + 'friends.requests': 'Friend Requests', + 'friends.requestFrom': '{user} wants to add you as friend', + 'friends.accept': 'Accept', + 'friends.decline': 'Decline', + 'friends.remove': 'Remove', + 'friends.addSuccess': 'Request sent!', + 'friends.addError': 'Failed to add friend', + 'friends.removeSuccess': 'Friend removed', + 'friends.acceptSuccess': 'Request accepted', + 'friends.passActive': 'Pass Active', + 'friends.passInactive': 'No Pass', + 'playtime.label': 'Playtime', + 'playtime.hours': '{h}h {m}m', + 'playtime.total': 'Total: {h}h', + 'playtime.syncing': 'Syncing...', + 'whitelist.title': 'Additional Mods', + 'whitelist.loading': 'Loading...', + 'whitelist.empty': 'No additional mods available', + 'whitelist.error': 'Failed to load mods', + 'whitelist.install': 'Install Selected', + 'whitelist.installing': 'Installing...', + 'whitelist.installed': 'Installed', + 'whitelist.done': 'Mods installed!', + 'whitelist.selectNone': 'Select mods first', + 'whitelist.installError': 'Failed to install mod', + 'nav.admin': 'Admin', + 'admin.title': 'Admin Panel', + 'admin.clients': 'Clients', + 'admin.mods': 'Mods', + 'admin.users': 'Users', + 'admin.passes': 'Passes', + 'admin.news': 'News', + 'admin.clientsOnline': 'Online Clients', + 'admin.noClients': 'No connected clients', + 'admin.modsUpload': 'Upload Mod', + 'admin.modsUploadBtn': 'Upload', + 'admin.modsList': 'Whitelist Mods', + 'admin.modsEmpty': 'No whitelist mods', + 'admin.modsUploaded': 'Mod uploaded successfully!', + 'admin.modsDeleted': 'Mod deleted', + 'admin.usersSearch': 'User Management', + 'admin.usersSearchPlaceholder': 'Username...', + 'admin.search': 'Search', + 'admin.role': 'Role:', + 'admin.save': 'Save', + 'admin.roleUpdated': 'Role updated!', + 'admin.resetPass': 'Reset Password', + 'admin.passReset': 'Password reset! New password: {pass}', + 'admin.noUser': 'No user selected', + 'admin.passesList': 'Pass Management', + 'admin.passesEmpty': 'No passes', + 'admin.newsCreate': 'Create News', + 'admin.newsTitlePlaceholder': 'Title', + 'admin.newsBodyPlaceholder': 'Body (Markdown)', + 'admin.newsCreateBtn': 'Publish', + 'admin.newsManage': 'Manage News', + 'admin.newsCreated': 'News published!', + 'admin.newsDeleted': 'News deleted', + 'admin.newsEmpty': 'No news', + 'admin.packs': 'Packs', + 'admin.packsList': 'Pack Management', + 'admin.packsEmpty': 'No packs', + 'admin.packsToggle': 'Toggle', + 'admin.packsDisabled': 'Disabled', + 'admin.packsEnabled': 'Enabled', + 'admin.packsStatus': 'Status', + 'admin.delete': 'Delete', + 'admin.loading': 'Loading...', + 'admin.error': 'Error', + 'install.preset.label': 'Preset (optional)', + 'install.preset.default': 'Default', + 'pack.disabled': 'Disabled', + }, + ru: { + 'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки', + 'login.title': 'Вход', 'login.username': 'Логин', 'login.password': 'Пароль', + 'login.subtitle': 'С возвращением в ZernMC', + 'login.hint': 'Нет аккаунта?', + 'login.register': 'Регистрация', + 'login.registerTitle': 'Создать аккаунт', + 'login.registerSubtitle': 'Присоединяйтесь к ZernMC', + 'login.hasAccount': 'Уже есть аккаунт?', + 'login.confirm': 'Подтвердите пароль', + 'login.passMismatch': 'Пароли не совпадают', + 'login.passTooShort': 'Пароль должен быть минимум 6 символов', + 'login.signingIn': 'Вход...', + 'loading.text': 'Загрузка...', + 'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки', + 'pack.empty.title': 'Выберите сборку', + 'pack.empty.desc': 'Выберите сборку в боковой панели', + 'pack.description.loading': 'Загрузка описания...', + 'pack.noDescription': 'Описание отсутствует', + 'stat.loaderVer': 'Версия загрузчика', 'stat.files': 'Файлы', 'stat.size': 'Размер', + 'playBar.play': 'Играть', 'playBar.passRequired': 'Требуется проходка', + 'playBar.update': 'Обновить', 'playBar.updating': 'Обновление...', + 'settings.title': 'Настройки', + 'settings.activatePass.title': 'Активировать проходку', + 'settings.activatePass.desc': 'Введите код проходки для доступа к серверным сборкам', + 'settings.activatePass.placeholder': 'Код проходки', + 'settings.activatePass.button': 'Активировать', + 'settings.ram.title': 'Выделенная память', + 'settings.resolution.title': 'Разрешение экрана', + 'settings.resolution.desc': 'Ширина x Высота', + 'settings.jvmArgs.title': 'Дополнительные аргументы JVM', + 'settings.jvmArgs.desc': 'Дополнительные параметры Java VM', + 'settings.javaPath.title': 'Путь к Java', + 'settings.server.title': 'Сервер', + 'settings.server.checking': 'Проверка...', + 'settings.server.connected': 'Подключено', + 'settings.server.disconnected': 'Отключено', + 'settings.network.title': 'Сеть', + 'settings.network.desc': 'Режим подключения по сервисам (напрямую / через прокси)', + 'settings.network.direct': 'Напрямую', + 'settings.network.proxy': 'Прокси', + 'settings.network.down': 'Недоступен', + 'settings.network.loading': 'Проверка...', + 'settings.network.unavailable': 'Недоступно', + 'settings.language.title': 'Язык', + 'settings.language.desc': 'Язык интерфейса', + 'settings.systemJvm.title': 'Системная оптимизация JVM', + 'settings.systemJvm.desc': 'Автонастройка флагов JVM под вашу систему (экспериментально)', + 'settings.systemJvm.info': 'ЦП: {cores} ядер | ОЗУ: {ram} ГБ | {flags}', + 'settings.systemJvm.enabled': 'Включено', 'settings.systemJvm.disabled': 'Выключено', + 'settings.logViewer.title': 'Лог игры', + 'settings.logViewer.desc': 'Просмотр логов игры в реальном времени', + 'settings.logViewer.open': 'Открыть лог', + 'logViewer.title': 'Лог игры', + 'logViewer.noLogs': 'Логов пока нет. Запустите игру, чтобы увидеть логи.', + 'logViewer.copy': 'Копировать', 'logViewer.copied': 'Скопировано!', + 'logViewer.openFile': 'Открыть файл', 'logViewer.close': 'Закрыть', + 'logViewer.opened': 'Файл открыт', + 'logViewer.openFailed': 'Не удалось открыть файл логов', + 'logViewer.waiting': 'Ожидание вывода игры...', + 'install.title': 'Установить сборку', + 'install.tab.serverPack': 'Серверная сборка', 'install.tab.custom': 'Своя', + 'install.serverPack.label': 'Серверная сборка', + 'install.localName.label': 'Локальное имя', + 'install.downloadBtn': 'Скачать и установить', + 'install.custom.unavailable': 'Пока недоступно', + 'install.custom.desc': 'Установка своей сборки отключена в этой версии. Используйте вкладку "Серверная сборка".', + 'install.progress.installing': 'Установка...', + 'install.enterName': 'Введите имя', + 'install.step': 'Шаг {current} из {total}: {name}', + 'select.loading': 'Загрузка...', + 'select.selectPack': 'Выберите сборку', + 'select.passRequired': 'Требуется проходка', + 'select.noPacks': 'Нет доступных сборок', + 'select.selectVersion': 'Выберите версию', + 'select.failedLoad': 'Ошибка загрузки', + 'select.selectMCFirst': 'Сначала выберите версию', + 'select.placeholder': 'Выберите...', + 'select.search': 'Поиск...', + 'packList.empty': 'Нет сборок', + 'news.title': 'Новости', 'news.empty': 'Новостей пока нет', 'news.loading': 'Загрузка новостей...', + 'pack.update': 'Обновить', 'pack.delete': 'Удалить', + 'badge.free': 'БЕСПЛАТНО', 'badge.pro': 'ПРО', + 'toast.welcomeBack': 'С возвращением, {username}!', + 'toast.welcome': 'Добро пожаловать, {username}!', + 'toast.accountCreated': 'Аккаунт создан! Добро пожаловать, {username}!', + 'toast.loggedOut': 'Вы вышли', + 'toast.enterCredentials': 'Введите логин и пароль', + 'toast.loginFailed': 'Ошибка входа', + 'login.errNetwork': 'Не удалось связаться с сервером. Проверьте подключение.', + 'login.errInvalidInput': 'Некорректные данные. Пароль должен быть минимум 6 символов.', + 'login.errTaken': 'Это имя пользователя уже занято.', + 'login.errBadCredentials': 'Неверный логин или пароль.', + 'login.errServer': 'Сервер вернул ошибку. Попробуйте позже.', + 'offline.title': 'Оффлайн-режим', + 'offline.subtitle': 'Сервер Zern недоступен. Вы можете запустить локально установленные сборки без аккаунта.', + 'offline.nickname': 'Никнейм', + 'offline.instance': 'Сборка', + 'offline.noInstances': 'Локально установленные сборки не найдены', + 'offline.launch': 'Запустить оффлайн', + 'offline.launching': 'Запуск...', + 'offline.needNickname': 'Введите никнейм', + 'offline.nickTooLong': 'Никнейм должен быть не длиннее 16 символов', + 'offline.needInstance': 'Выберите сборку', + 'offline.retry': 'Попробовать снова онлайн', + 'offline.exit': 'Выйти', + 'offline.stillOffline': 'Сервер всё ещё недоступен', + 'toast.launching': 'Запуск {name}...', + 'toast.launchFailed': 'Ошибка запуска', + 'toast.updated': '{name} обновлён до v{version}!', + 'toast.updateFailed': 'Ошибка обновления', + 'toast.noPackSelected': 'Сначала выберите сборку', + 'toast.launched': 'Запущено! PID: {pid}', + 'toast.installing': 'Установка...', + 'toast.installComplete': 'Установка завершена!', + 'toast.installFailed': 'Ошибка установки', + 'toast.enterPassCode': 'Введите код проходки', + 'toast.passActivated': 'Проходка активирована!', + 'toast.activationFailed': 'Ошибка активации', + 'error.network': 'Ошибка сети', + 'pack.modsCount': '{count} модов', + 'pack.worldsCount': '{count} миров', + 'pack.logsCount': '{count} логов', + 'locale.en': 'Английский', 'locale.ru': 'Русский', + 'ram.gb': '{value} ГБ', + 'ram.mbAllocated': '{value} МБ выделено', + 'nav.friends': 'Друзья', + 'friends.title': 'Друзья', + 'friends.add': 'Добавить', + 'friends.addTitle': 'Добавить друга', + 'friends.addLabel': 'Имя пользователя', + 'friends.addPlaceholder': 'Введите имя...', + 'friends.search': 'Поиск друзей...', + 'friends.empty': 'Пока нет друзей', + 'friends.online': 'В сети', + 'friends.offline': 'Не в сети', + 'friends.playing': 'Играет в {pack}', + 'friends.noPack': 'В простое', + 'friends.requests': 'Заявки в друзья', + 'friends.requestFrom': '{user} хочет добавить вас в друзья', + 'friends.accept': 'Принять', + 'friends.decline': 'Отклонить', + 'friends.remove': 'Удалить', + 'friends.addSuccess': 'Заявка отправлена!', + 'friends.addError': 'Не удалось добавить друга', + 'friends.removeSuccess': 'Друг удалён', + 'friends.acceptSuccess': 'Заявка принята', + 'friends.passActive': 'Проходка активна', + 'friends.passInactive': 'Нет проходки', + 'playtime.label': 'Время игры', + 'playtime.hours': '{h}ч {m}м', + 'playtime.total': 'Всего: {h}ч', + 'playtime.syncing': 'Синхронизация...', + 'whitelist.title': 'Дополнительные моды', + 'whitelist.loading': 'Загрузка...', + 'whitelist.empty': 'Нет дополнительных модов', + 'whitelist.error': 'Ошибка загрузки модов', + 'whitelist.install': 'Установить выбранные', + 'whitelist.installing': 'Установка...', + 'whitelist.installed': 'Установлено', + 'whitelist.done': 'Моды установлены!', + 'whitelist.selectNone': 'Выберите моды', + 'whitelist.installError': 'Ошибка установки мода', + 'nav.admin': 'Админка', + 'admin.title': 'Панель администратора', + 'admin.clients': 'Клиенты', + 'admin.mods': 'Моды', + 'admin.users': 'Пользователи', + 'admin.passes': 'Проходки', + 'admin.news': 'Новости', + 'admin.clientsOnline': 'Онлайн клиенты', + 'admin.noClients': 'Нет подключенных клиентов', + 'admin.modsUpload': 'Загрузить мод', + 'admin.modsUploadBtn': 'Загрузить', + 'admin.modsList': 'Вайтлист моды', + 'admin.modsEmpty': 'Нет модов в вайтлисте', + 'admin.modsUploaded': 'Мод успешно загружен!', + 'admin.modsDeleted': 'Мод удалён', + 'admin.usersSearch': 'Управление пользователями', + 'admin.usersSearchPlaceholder': 'Имя пользователя...', + 'admin.search': 'Поиск', + 'admin.role': 'Роль:', + 'admin.save': 'Сохранить', + 'admin.roleUpdated': 'Роль обновлена!', + 'admin.resetPass': 'Сбросить пароль', + 'admin.passReset': 'Пароль сброшен! Новый пароль: {pass}', + 'admin.noUser': 'Пользователь не выбран', + 'admin.passesList': 'Управление проходками', + 'admin.passesEmpty': 'Нет проходок', + 'admin.newsCreate': 'Создать новость', + 'admin.newsTitlePlaceholder': 'Заголовок', + 'admin.newsBodyPlaceholder': 'Текст (Markdown)', + 'admin.newsCreateBtn': 'Опубликовать', + 'admin.newsManage': 'Управление новостями', + 'admin.newsCreated': 'Новость опубликована!', + 'admin.newsDeleted': 'Новость удалена', + 'admin.newsEmpty': 'Нет новостей', + 'admin.packs': 'Сборки', + 'admin.packsList': 'Управление сборками', + 'admin.packsEmpty': 'Нет сборок', + 'admin.packsToggle': 'Переключить', + 'admin.packsDisabled': 'Отключена', + 'admin.packsEnabled': 'Включена', + 'admin.packsStatus': 'Статус', + 'admin.delete': 'Удалить', + 'admin.loading': 'Загрузка...', + 'admin.error': 'Ошибка', + 'install.preset.label': 'Пресет (опционально)', + 'install.preset.default': 'Стандартный', + 'pack.disabled': 'Отключена', + } +}; + +let _localeData = LOCALES.en; +let _currentLocale = 'en'; + +function t(key, fallback) { + return _localeData[key] || fallback || key; +} + +function tr(key, fallback, replacements) { + let s = t(key, fallback); + if (replacements) { + for (const [k, v] of Object.entries(replacements)) { + s = s.replace('{' + k + '}', v); + } + } + return s; +} + +function applyLocale() { + document.querySelectorAll('[data-i18n]').forEach(function(el) { + var key = el.getAttribute('data-i18n'); + var fallback = el.textContent; + el.textContent = t(key, fallback); + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach(function(el) { + var key = el.getAttribute('data-i18n-placeholder'); + el.setAttribute('placeholder', t(key, el.getAttribute('placeholder'))); + }); +} + +class ZernMCLauncher { + constructor() { + this.state = { account: null, instances: [], selectedPack: null, installing: false, serverPacks: [], friends: [], friendRequests: [], playtimeStats: null }; + this.toastTimer = null; + this.progressPoller = null; + this._friendsPollInterval = null; + this._playtimeInterval = null; + this._playtimeAccumulated = 0; + this._playtimeStartTime = null; + this._currentPlaytimePack = null; + this._openSection = undefined; + this.init(); + } + + async init() { + this.bindEvents(); + this.initBg(); + await this.checkAuth(); + } + + setLocale(lang) { + _currentLocale = lang; + _localeData = LOCALES[lang] || LOCALES.en; + applyLocale(); + var sel = document.getElementById('locale-select'); + if (sel) { + sel.value = lang; + if (sel.dataset.enhanced) this.refreshCustomSelect(sel); + } + } + + // ==================== BACKGROUND ==================== + initBg() { + const c = document.getElementById('bg-canvas'); + const ctx = c.getContext('2d'); + let t = 0, mode = 'login', modePulse = 0; + + this.setWaveMode = m => { mode = m; modePulse = 1; }; + + const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; }; + window.addEventListener('resize', resize); + + const draw = () => { + ctx.clearRect(0, 0, c.width, c.height); + const w = c.width, h = c.height; + modePulse += (0 - modePulse) * 0.03; + + const baseAlpha = 0.04 + modePulse * 0.03; + const amp = 20 + modePulse * 10; + const freq = 0.008 + modePulse * 0.003; + const speed = 0.008; + const lines = 5; + + for (let i = 0; i < lines; i++) { + const yOff = (h / (lines + 1)) * (i + 1); + const alpha = baseAlpha * (1 - i * 0.12); + ctx.beginPath(); + for (let x = 0; x <= w; x += 2) { + const wave = Math.sin(x * freq + t * speed + i * 1.8) * amp; + const wave2 = Math.sin(x * freq * 0.5 + t * speed * 0.7 + i * 2.5) * amp * 0.5; + const y = yOff + wave + wave2; + x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.strokeStyle = `rgba(233, 69, 96, ${alpha})`; + ctx.lineWidth = 1.2; + ctx.stroke(); + } + + t++; + }; + + const anim = () => { draw(); requestAnimationFrame(anim); }; + resize(); + anim(); + } + + // ==================== API ==================== + async req(endpoint, opts = {}) { + try { + var headers = { 'Content-Type': 'application/json' }; + if (opts.headers) { + for (var k in opts.headers) headers[k] = opts.headers[k]; + } + if (typeof fetch !== 'undefined') { + var controller = new AbortController(); + var timeoutMs = opts.timeout || 15000; + var timeoutId = setTimeout(function() { controller.abort(); }, timeoutMs); + try { + var r = await fetch(API + endpoint, { + method: opts.method || 'GET', + headers: headers, + body: opts.body || null, + signal: controller.signal + }); + clearTimeout(timeoutId); + return await r.json(); + } catch (fetchErr) { + clearTimeout(timeoutId); + return { success: false, error: fetchErr.message || 'Request failed' }; + } + } + return await new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open(opts.method || 'GET', API + endpoint, true); + for (var k in headers) xhr.setRequestHeader(k, headers[k]); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + try { resolve(JSON.parse(xhr.responseText)); } + catch (e) { resolve({ success: false, error: 'Invalid JSON response' }); } + } + }; + xhr.onerror = function() { resolve({ success: false, error: 'Network error' }); }; + xhr.ontimeout = function() { resolve({ success: false, error: 'Request timeout' }); }; + xhr.timeout = opts.timeout || 30000; + xhr.send(opts.body || null); + }); + } catch (e) { + return { success: false, error: e.message }; + } + } + + // ==================== AUTH ==================== + async checkAuth() { + this.showLoading(true); + const status = await this.req('/offline/status'); + if (status.success && status.offline) { + this.showOffline(); + this.showLoading(false); + return; + } + const auto = await this.req('/auto-login'); + if (auto.success && auto.autoLogin) { + this.state.account = auto.data; + this.enterMain(); + this.toast(tr('toast.welcomeBack', null, {username: auto.data.username}), 'success'); + } else { + const acct = await this.req('/account'); + if (acct.success) { + this.state.account = acct.data; + this.enterMain(); + } else { + this.showLogin(); + } + } + this.showLoading(false); + } + + async handleLogin(e) { + e.preventDefault(); + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('password').value; + const errEl = document.getElementById('login-error'); + const btn = document.getElementById('login-btn'); + const label = document.getElementById('login-btn-label'); + const spinner = btn.querySelector('.spinner'); + + if (!username || !password) { this.showLoginError(t('toast.enterCredentials')); return; } + + if (this._registerMode) { + const confirm = document.getElementById('confirm-password').value; + if (password !== confirm) { this.showLoginError(t('login.passMismatch')); return; } + if (password.length < 6) { this.showLoginError(t('login.passTooShort')); return; } + } + + btn.disabled = true; + label.textContent = t('login.signingIn'); + spinner.classList.remove('hidden'); + + const endpoint = this._registerMode ? '/register' : '/login'; + const r = await this.req(endpoint, { method: 'POST', body: JSON.stringify({ username, password }) }); + + btn.disabled = false; + label.textContent = this._registerMode ? t('login.register') : t('login.title'); + spinner.classList.add('hidden'); + + if (r.success) { + this.state.account = r.data; + this.enterMain(); + const key = this._registerMode ? 'toast.accountCreated' : 'toast.welcome'; + this.toast(tr(key, null, {username: r.data.username}), 'success'); + } else { + if (!this._registerMode && r.error && (r.error.includes('not found') || r.error.includes('Invalid'))) { + var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) }); + if (reg.success) { + this.state.account = reg.data; + this.enterMain(); + this.toast(tr('toast.accountCreated', null, {username: reg.data.username}), 'success'); + return; + } + } + this.showLoginError(this.friendlyError(r.error) || r.error || t('toast.loginFailed')); + } + } + + friendlyError(err) { + if (!err) return null; + const s = String(err); + if (/failed to fetch|fetch failed|networkerror|ENOTFOUND|ECONNREFUSED|ECONNRESET|timeout|aborted/i.test(s)) { + return t('login.errNetwork'); + } + if (s.includes('HTTP 422') || /at least 6|string should have/i.test(s)) { + return t('login.errInvalidInput'); + } + if (s.includes('HTTP 409') || /already taken/i.test(s)) { + return t('login.errTaken'); + } + if (s.includes('HTTP 401') || /invalid.*(login|password)|wrong.*(login|password)/i.test(s)) { + return t('login.errBadCredentials'); + } + if (/HTTP \d{3}/.test(s)) { + return t('login.errServer'); + } + return null; + } + + validatePassword() { + if (!this._registerMode) return; + const password = document.getElementById('password'); + const hint = document.getElementById('password-hint'); + const btn = document.getElementById('login-btn'); + const tooShort = password.value.length < 6; + if (tooShort) { + password.classList.add('invalid'); + hint.classList.remove('hidden'); + } else { + password.classList.remove('invalid'); + hint.classList.add('hidden'); + } + if (btn) btn.disabled = tooShort; + const errEl = document.getElementById('login-error'); + if (errEl && !errEl.classList.contains('hidden')) { + errEl.classList.add('hidden'); + } + } + + toggleMode() { + this._registerMode = !this._registerMode; + const isReg = this._registerMode; + const container = document.getElementById('login-container'); + const title = document.getElementById('login-title'); + const subtitle = document.getElementById('login-subtitle'); + const btnLabel = document.getElementById('login-btn-label'); + const footerHint = document.getElementById('footer-hint'); + const footerAction = document.getElementById('footer-action'); + const confirmField = document.getElementById('confirm-field'); + + container.style.transform = 'translateY(-4px)'; + container.style.opacity = '0.6'; + + setTimeout(() => { + title.textContent = isReg ? t('login.registerTitle') : t('login.title'); + subtitle.textContent = isReg ? t('login.registerSubtitle') : t('login.subtitle'); + btnLabel.textContent = isReg ? t('login.register') : t('login.title'); + footerHint.textContent = isReg ? t('login.hasAccount') : t('login.hint'); + footerAction.textContent = isReg ? t('login.title') : t('login.register'); + + if (isReg) { + confirmField.classList.remove('hidden'); + document.getElementById('confirm-password').required = true; + this.validatePassword(); + } else { + confirmField.classList.add('hidden'); + document.getElementById('confirm-password').required = false; + document.getElementById('confirm-password').value = ''; + document.getElementById('login-btn').disabled = false; + document.getElementById('password').classList.remove('invalid'); + document.getElementById('password-hint').classList.add('hidden'); + } + + document.getElementById('login-error').classList.add('hidden'); + + container.style.transform = ''; + container.style.opacity = ''; + + if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login'); + }, 150); + } + + showLoginError(msg) { + const el = document.getElementById('login-error'); + el.textContent = msg; + el.classList.remove('hidden'); + } + + showLogin() { + document.getElementById('login-screen').classList.remove('hidden'); + document.getElementById('main-screen').classList.add('hidden'); + } + + async logout() { + this.state.selectedPack = null; + this.state.instances = []; + this.state.account = null; + this.toast(t('toast.loggedOut')); + document.getElementById('login-screen').classList.remove('hidden'); + document.getElementById('main-screen').classList.add('hidden'); + } + + enterMain() { + document.getElementById('login-screen').classList.add('hidden'); + document.getElementById('main-screen').classList.remove('hidden'); + const a = this.state.account; + const avatar = document.getElementById('user-avatar'); + avatar.textContent = (a.username || 'Z')[0].toUpperCase(); + document.getElementById('username-display').textContent = a.username; + this.updateBadge(); + document.getElementById('header-version').textContent = document.getElementById('version').textContent; + this.switchView('packs'); + this.loadInstances(); + this.loadSettings(); + this.loadServerPacksList(); + this.loadNews(); + this.loadFriends(); + this.loadPlaytimeStats(); + this.startFriendStatusHeartbeat(); + this.enhanceSelects(); + this.updateAdminNav(); + } + + async loadServerPacksList() { + const r = await this.req('/packs'); + if (r.success && r.data) { + this.state.serverPacks = r.data; + this.state.serverPacksMap = {}; + r.data.forEach(function(p) { + app.state.serverPacksMap[p.name] = p; + }); + } + } + + // ==================== OFFLINE MODE ==================== + showOffline() { + document.getElementById('login-screen').classList.add('hidden'); + document.getElementById('main-screen').classList.add('hidden'); + document.getElementById('offline-screen').classList.remove('hidden'); + this.loadOfflineInstances(); + } + + async loadOfflineInstances() { + const select = document.getElementById('offline-instance'); + if (!select) return; + select.innerHTML = ''; + const r = await this.req('/instances'); + let instances = (r.success && r.data) ? r.data : []; + instances = instances.filter(function(inst) { return inst.name; }); + if (instances.length === 0) { + const opt = document.createElement('option'); + opt.textContent = t('offline.noInstances'); + opt.value = ''; + select.appendChild(opt); + return; + } + instances.forEach(function(inst) { + const opt = document.createElement('option'); + opt.value = inst.name; + let label = inst.name; + if (inst.minecraftVersion) label += ' (' + inst.minecraftVersion + ')'; + opt.textContent = label; + select.appendChild(opt); + }); + this.state.offlineInstances = instances; + } + + async handleOfflineLaunch(e) { + e.preventDefault(); + const nickname = document.getElementById('offline-nickname').value.trim(); + const name = document.getElementById('offline-instance').value; + const errEl = document.getElementById('offline-error'); + const btn = document.getElementById('offline-launch-btn'); + const label = btn.querySelector('.btn-label'); + const spinner = btn.querySelector('.spinner'); + + errEl.classList.add('hidden'); + if (!nickname) { + errEl.textContent = t('offline.needNickname'); + errEl.classList.remove('hidden'); + return; + } + if (nickname.length > 16) { + errEl.textContent = t('offline.nickTooLong'); + errEl.classList.remove('hidden'); + return; + } + if (!name) { + errEl.textContent = t('offline.needInstance'); + errEl.classList.remove('hidden'); + return; + } + + btn.disabled = true; + label.textContent = t('offline.launching'); + spinner.classList.remove('hidden'); + + const r = await this.req('/offline-launch', { + method: 'POST', + body: JSON.stringify({ name: name, nickname: nickname }), + timeout: 120000 + }); + + btn.disabled = false; + label.textContent = t('offline.launch'); + spinner.classList.add('hidden'); + + if (r.success) { + this.toast(tr('toast.launched', null, {pid: String(r.data?.pid || '')}), 'success'); + } else { + errEl.textContent = r.error || t('toast.launchFailed'); + errEl.classList.remove('hidden'); + } + } + + async retryOnline() { + this.showLoading(true); + document.getElementById('offline-screen').classList.add('hidden'); + const status = await this.req('/offline/status'); + if (status.success && status.offline) { + this.showOffline(); + this.showLoading(false); + this.toast(t('offline.stillOffline'), 'warning'); + return; + } + await this.checkAuth(); + } + + // ==================== NAV ==================== + bindEvents() { + document.getElementById('login-form').addEventListener('submit', e => this.handleLogin(e)); + const offlineForm = document.getElementById('offline-form'); + if (offlineForm) offlineForm.addEventListener('submit', e => this.handleOfflineLaunch(e)); + const retryBtn = document.getElementById('offline-retry-btn'); + if (retryBtn) retryBtn.addEventListener('click', () => this.retryOnline()); + const exitBtn = document.getElementById('offline-exit-btn'); + if (exitBtn) exitBtn.addEventListener('click', () => this.req('/shutdown', { method: 'POST' })); + const pwInput = document.getElementById('password'); + if (pwInput) pwInput.addEventListener('input', () => this.validatePassword()); + const confirmInput = document.getElementById('confirm-password'); + if (confirmInput) confirmInput.addEventListener('input', () => this.validatePassword()); + document.getElementById('login-footer').addEventListener('click', () => this.toggleMode()); + document.getElementById('logout-btn').addEventListener('click', () => this.logout()); + document.getElementById('settings-btn').addEventListener('click', () => this.switchView('settings')); + + document.querySelectorAll('.nav-btn').forEach(btn => { + btn.addEventListener('click', () => this.switchView(btn.dataset.view)); + }); + + document.getElementById('add-pack-btn').addEventListener('click', () => this.showInstallModal()); + document.getElementById('add-server-pack-btn').addEventListener('click', () => this.showInstallModal()); + + // Accordion sidebar sections: max one pack list open at a time + const serverHeader = document.getElementById('server-packs-header'); + const localHeader = document.getElementById('local-packs-header'); + [serverHeader, localHeader].forEach(header => { + if (!header) return; + header.addEventListener('click', (e) => { + if (e.target.closest('.btn-icon')) return; + const name = header.id === 'server-packs-header' ? 'server' : 'local'; + this._openSection = this._openSection === name ? null : name; + this.syncOpenSections(); + }); + }); + + document.getElementById('close-modal-btn').addEventListener('click', () => this.hideInstallModal()); + document.querySelectorAll('.modal-tab').forEach(t => { + t.addEventListener('click', () => this.switchInstallTab(t.dataset.tab)); + }); + var loaderSel = document.getElementById('loader-select'); + if (loaderSel) loaderSel.addEventListener('change', e => this.onLoaderChange(e.target.value)); + document.getElementById('install-zernmc-btn').addEventListener('click', () => this.installZernMCPack()); + var customBtn = document.getElementById('install-custom-btn'); + if (customBtn) customBtn.addEventListener('click', () => this.installCustom()); + document.getElementById('play-btn').addEventListener('click', () => this.launchPack()); + document.getElementById('ram-slider').addEventListener('input', e => { + var val = e.target.value; + document.getElementById('ram-value').textContent = tr('ram.gb', null, {value: (val / 1024).toFixed(1)}); + document.getElementById('ram-info').textContent = tr('ram.mbAllocated', null, {value: val}); + }); + document.getElementById('ram-slider').addEventListener('change', e => this.saveSettings()); + document.getElementById('activate-pass-btn').addEventListener('click', () => this.activatePass()); + + let saveTimer; + const debouncedSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(() => this.saveSettings(), 500); }; + document.getElementById('win-width').addEventListener('input', debouncedSave); + document.getElementById('win-height').addEventListener('input', debouncedSave); + document.getElementById('jvm-args').addEventListener('input', debouncedSave); + document.getElementById('java-path-input').addEventListener('input', debouncedSave); + + var logViewerBtn = document.getElementById('show-log-viewer-btn'); + if (logViewerBtn) logViewerBtn.addEventListener('click', () => this.openLogViewer()); + + document.querySelectorAll('.modal-backdrop').forEach(m => { + m.addEventListener('click', e => { if (e.target === m) this.hideInstallModal(); }); + }); + + var afModal = document.getElementById('add-friend-modal'); + if (afModal) { + afModal.addEventListener('click', function(e) { if (e.target === afModal) app.closeAddFriend(); }); + } + var afInput = document.getElementById('add-friend-input'); + if (afInput) { + afInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') app.submitAddFriend(); }); + } + + document.querySelectorAll('.admin-tab').forEach(function(t) { + t.addEventListener('click', function() { app.switchAdminTab(t.dataset.atab); }); + }); + + var localeSelect = document.getElementById('locale-select'); + if (localeSelect) { + localeSelect.addEventListener('change', async () => { + var lang = localeSelect.value; + this.setLocale(lang); + await this.saveSettings(); + }); + } + + var sysJvmToggle = document.getElementById('system-jvm-toggle'); + if (sysJvmToggle) { + sysJvmToggle.addEventListener('change', () => { + this.saveSettings(); + var data = { systemBasedJvm: sysJvmToggle.checked, cpuCores: null, totalRamMB: null }; + var infoEl = document.getElementById('system-jvm-info'); + if (infoEl && this.state._sysInfo) { + data.cpuCores = this.state._sysInfo.cpuCores; + data.totalRamMB = this.state._sysInfo.totalRamMB; + } + this.updateSystemJvmInfo(data); + }); + } + } + + switchView(view) { + document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); + document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + view)); + if (view === 'settings') { + this.loadNetworkStatus(); + if (!this.networkPoller) { + this.networkPoller = setInterval(() => this.loadNetworkStatus(), 15000); + } + } else if (this.networkPoller) { + clearInterval(this.networkPoller); + this.networkPoller = null; + } + } + + async loadNetworkStatus() { + const list = document.getElementById('network-list'); + if (!list) return; + try { + const r = await this.req('/network/status'); + if (!r.success || !r.data) return; + const services = r.data.services || []; + if (!services.length) { + list.innerHTML = '' + t('settings.network.unavailable') + ''; + return; + } + let html = ''; + services.forEach(function(s) { + const state = s.proxy ? 'proxy' : (s.healthy ? 'direct' : 'down'); + const labels = { direct: t('settings.network.direct'), proxy: t('settings.network.proxy'), down: t('settings.network.down') }; + const colors = { direct: 'var(--success)', proxy: 'var(--warning)', down: 'var(--error)' }; + html += '
' + + '' + s.name.toLowerCase() + '' + + '' + labels[state] + '' + + '
'; + }); + list.innerHTML = html; + } catch (e) {} + } + + // ==================== INSTANCES ==================== + async loadInstances() { + const r = await this.req('/instances'); + if (r.success && r.data) { + this.state.instances = r.data; + this.state.updateMap = {}; + await this.checkUpdatesForServerPacks(); + this.renderSidebar(); + } + } + + async checkUpdatesForServerPacks() { + if (!this.state.instances) return; + for (const inst of this.state.instances) { + if (!inst.isServerPack) continue; + // Skip disabled packs + if (this.state.serverPacksMap && this.state.serverPacksMap[inst.name] && this.state.serverPacksMap[inst.name].disabled) { + continue; + } + const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name)); + if (r.success && r.data) { + this.state.updateMap[inst.name] = r.data.hasUpdate; + } + } + } + + async checkUpdateForPack(inst) { + if (!inst || !inst.isServerPack) return; + // Skip disabled packs + if (this.state.serverPacksMap && this.state.serverPacksMap[inst.name] && this.state.serverPacksMap[inst.name].disabled) { + return; + } + const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name)); + if (r.success && r.data) { + if (!this.state.updateMap) this.state.updateMap = {}; + this.state.updateMap[inst.name] = r.data.hasUpdate; + } + } + + hasUpdate(inst) { + return inst && inst.isServerPack && this.state.updateMap && this.state.updateMap[inst.name] === true; + } + + syncOpenSections() { + const serverSection = document.getElementById('server-packs-section'); + const localSection = document.getElementById('local-packs-section'); + if (serverSection) serverSection.classList.toggle('collapsed', this._openSection !== 'server'); + if (localSection) localSection.classList.toggle('collapsed', this._openSection !== 'local'); + } + + renderSidebar() { + const serverList = document.getElementById('server-packs-list'); + const localList = document.getElementById('local-packs-list'); + const localSection = document.getElementById('local-packs-section'); + serverList.innerHTML = ''; + localList.innerHTML = ''; + if (localSection) localSection.classList.add('hidden'); + + if (!this.state.instances || this.state.instances.length === 0) return; + + this.state.instances.forEach(inst => { + const isZern = inst.isServerPack || inst.category === 'zernmc'; + const targetList = isZern ? serverList : localList; + const isDisabled = this.state.serverPacksMap && this.state.serverPacksMap[inst.name] && this.state.serverPacksMap[inst.name].disabled; + + const el = document.createElement('div'); + el.className = 'pack-entry' + (this.state.selectedPack && this.state.selectedPack.name === inst.name ? ' selected' : '') + (isDisabled ? ' disabled' : ''); + if (isDisabled) el.setAttribute('data-disabled-text', t('pack.disabled')); + const hasUpd = this.hasUpdate(inst); + el.innerHTML = ` +
+ ${isZern + ? '' + : '' + } + ${hasUpd && !isDisabled ? '' : ''} +
+ + `; + if (!isDisabled) { + el.addEventListener('click', () => this.selectPack(inst)); + } + targetList.appendChild(el); + }); + + if (localSection && localList.children.length > 0) { + localSection.classList.remove('hidden'); + } + + // Default: open the section that has packs (server list first) + if (this._openSection === undefined) { + this._openSection = serverList.children.length > 0 ? 'server' + : (localList.children.length > 0 ? 'local' : null); + } + this.syncOpenSections(); + } + + selectPack(inst) { + if (this.state.selectedPack && this.state.selectedPack.name === inst.name) { + this.state.selectedPack = null; + this.renderSidebar(); + this.showEmptyState(); + return; + } + this.state.selectedPack = inst; + this.renderSidebar(); + // Enable play button immediately so user can click it right away + this.setPlayBtnText('playBar.play', false); + this.showPackDetail(inst); + // Check for updates asynchronously + this.checkUpdateForPack(inst).then(() => { + if (this.state.selectedPack === inst) this.showPackDetail(inst); + }); + // Refresh account in background to check pass status + this.refreshAccount().then(() => { + if (this.state.selectedPack === inst) { + this.showPackDetail(inst); + } + }); + const activeView = document.querySelector('.view.active'); + if (activeView && activeView.id !== 'view-packs') { + this.switchView('packs'); + } + } + + async refreshAccount() { + const r = await this.req('/account'); + if (r.success && r.data) { + if (this.state.account) { + this.state.account.passActive = r.data.passActive; + this.state.account.role = r.data.role; + this.state.account.roleName = r.data.roleName; + } + } + } + + showEmptyState() { + document.getElementById('pack-empty-state').classList.remove('hidden'); + document.getElementById('pack-detail-content').classList.add('hidden'); + document.getElementById('selected-pack-title').textContent = t('pack.empty.title'); + document.getElementById('selected-pack-meta').textContent = t('pack.empty.desc'); + document.getElementById('play-bar-name').textContent = t('pack.empty.title'); + document.getElementById('play-btn').disabled = true; + this.setPlayBtnText('playBar.play', false); + } + + setPlayBtnText(key, disabled) { + var btn = document.getElementById('play-btn'); + if (!btn) return; + btn.disabled = disabled; + btn.innerHTML = ' ' + t(key) + ''; + } + + showPackDetail(inst) { + document.getElementById('pack-empty-state').classList.add('hidden'); + const detail = document.getElementById('pack-detail-content'); + detail.classList.remove('hidden'); + + document.getElementById('detail-name').textContent = inst.name; + document.getElementById('detail-mc').textContent = inst.version || '?'; + const loader = document.getElementById('detail-loader'); + if (inst.loaderType && inst.loaderType !== 'vanilla') { + loader.textContent = inst.loaderType; + loader.classList.remove('hidden'); + } else { + loader.classList.add('hidden'); + } + const serverTag = document.getElementById('detail-server'); + if (inst.isServerPack && inst.serverVersion) { + serverTag.textContent = 'v' + inst.serverVersion; + serverTag.classList.remove('hidden'); + } else { + serverTag.classList.add('hidden'); + } + document.getElementById('detail-loader-ver').textContent = inst.loaderVersion || '-'; + document.getElementById('detail-files').textContent = '...'; + + var playtimeEl = document.getElementById('detail-playtime'); + var stats = this.state.playtimeStats; + if (stats && stats.packs) { + var packStat = stats.packs.find(function(p) { return p.pack_name === inst.name; }); + if (packStat) { + var h = Math.floor(packStat.minutes / 60); + var m = packStat.minutes % 60; + playtimeEl.textContent = tr('playtime.hours', null, {h: h, m: m}); + } else { + playtimeEl.textContent = '0m'; + } + } else { + playtimeEl.textContent = '-'; + } + playtimeEl.style.display = 'inline'; + + document.getElementById('selected-pack-title').textContent = inst.name; + document.getElementById('selected-pack-meta').textContent = + (inst.version || '?') + (inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : ''); + + const playBar = document.getElementById('play-bar-name'); + playBar.textContent = inst.name; + + var passActive = this.state.account ? this.state.account.passActive : false; + if (inst.isServerPack && !passActive) { + this.setPlayBtnText('playBar.passRequired', true); + } else if (this.hasUpdate(inst)) { + this.setPlayBtnText('playBar.update', false); + } else { + this.setPlayBtnText('playBar.play', false); + } + + // Load pack description from cached server packs + const descEl = document.getElementById('pack-description-text'); + const galleryEl = document.getElementById('pack-gallery'); + galleryEl.innerHTML = ''; + descEl.innerHTML = ''; + if (inst.isServerPack && inst.serverPackName) { + const pack = this.state.serverPacks.find(p => p.name === inst.serverPackName); + if (pack && pack.description) { + descEl.innerHTML = this.mdToHtml(pack.description); + } else { + descEl.textContent = t('pack.noDescription'); + } + } else { + descEl.textContent = ''; + } + + // Load pack info from local files + this.loadPackInfo(inst.name); + + // Load whitelist mods for server packs + const whitelistSection = document.getElementById('whitelist-mods-section'); + if (inst.isServerPack) { + this.loadWhitelistMods(inst.name); + whitelistSection.classList.remove('hidden'); + } else { + whitelistSection.classList.add('hidden'); + } + } + + async loadPackInfo(name) { + const r = await this.req('/pack-info?name=' + encodeURIComponent(name)); + if (r.success && r.data) { + document.getElementById('detail-files').textContent = tr('pack.modsCount', null, {count: String(r.data.modsCount || 0)}); + if (r.data.worlds && r.data.worlds.length > 0) { + document.getElementById('detail-size').textContent = tr('pack.worldsCount', null, {count: r.data.worlds.length}); + } + if (r.data.recentLogs && r.data.recentLogs.length > 0) { + var curSize = document.getElementById('detail-size').textContent || '-'; + document.getElementById('detail-size').textContent = curSize + ' \u00b7 ' + tr('pack.logsCount', null, {count: r.data.recentLogs.length}); + } + } + } + + async loadWhitelistMods(instanceName) { + const listEl = document.getElementById('whitelist-mods-list'); + listEl.innerHTML = '
' + t('whitelist.loading') + '
'; + const r = await this.req('/whitelist/mods'); + if (!r.success || !r.data) { + listEl.innerHTML = '
' + (r.error || t('whitelist.error')) + '
'; + return; + } + if (r.data.length === 0) { + listEl.innerHTML = '
' + t('whitelist.empty') + '
'; + return; + } + var html = ''; + r.data.forEach(function(mod) { + var sizeStr = mod.size > 1048576 ? (mod.size / 1048576).toFixed(1) + ' MB' : Math.ceil(mod.size / 1024) + ' KB'; + html += ''; + }, this); + html += ''; + listEl.innerHTML = html; + } + + async installSelectedWhitelistMods(instanceName) { + var checkboxes = document.querySelectorAll('.whitelist-mod-checkbox:checked'); + if (checkboxes.length === 0) { this.toast(t('whitelist.selectNone'), 'warning'); return; } + var btn = document.querySelector('.whitelist-install-btn'); + btn.disabled = true; + btn.textContent = t('whitelist.installing'); + for (var i = 0; i < checkboxes.length; i++) { + var cb = checkboxes[i]; + var modName = cb.getAttribute('data-name'); + var modHash = cb.getAttribute('data-hash'); + var r = await this.req('/whitelist/mods/install', { + method: 'POST', + body: JSON.stringify({ instance: instanceName, modName: modName, modHash: modHash }) + }); + if (r.success) { + cb.checked = false; + cb.parentElement.querySelector('.whitelist-mod-info').innerHTML += '
' + t('whitelist.installed') + '
'; + } else { + this.toast(r.error || t('whitelist.installError'), 'error'); + } + } + btn.disabled = false; + btn.innerHTML = 'Install Selected'; + this.toast(t('whitelist.done'), 'success'); + } + + // ==================== ADMIN ==================== + updateAdminNav() { + var a = this.state.account; + var btn = document.getElementById('admin-nav-btn'); + if (!btn) return; + if (a && a.role >= 2) { + btn.classList.remove('hidden'); + } else { + btn.classList.add('hidden'); + } + } + + switchAdminTab(tab) { + document.querySelectorAll('.admin-tab').forEach(function(t) { + t.classList.toggle('active', t.dataset.atab === tab); + }); + document.querySelectorAll('.admin-tab-content').forEach(function(c) { + c.classList.toggle('active', c.id === 'atab-' + tab); + }); + if (tab === 'clients') this.adminLoadClients(); + if (tab === 'mods') this.adminLoadMods(); + if (tab === 'users') this.adminClearUser(); + if (tab === 'passes') this.adminLoadPasses(); + if (tab === 'news') this.adminLoadNewsList(); + if (tab === 'packs') this.adminLoadPacks(); + } + + // --- Clients --- + async adminLoadClients() { + var el = document.getElementById('admin-clients-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/clients'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + ': ' + (r.error || '') + '
'; + return; + } + var clients = r.clients || r.data || []; + if (!clients.length) { + el.innerHTML = '
' + t('admin.noClients') + '
'; + return; + } + var html = ''; + clients.forEach(function(c) { + html += '
' + + '' + app.esc(c.username || c.name || '?') + '' + + 'IP: ' + app.esc(c.ip || '-') + '' + + '
'; + }); + el.innerHTML = html; + setTimeout(function() { app.adminLoadClients(); }, 5000); + } + + // --- Mods --- + async adminLoadMods() { + var el = document.getElementById('admin-mods-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/whitelist/mods'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var mods = r.mods || r.data || []; + if (!mods.length) { + el.innerHTML = '
' + t('admin.modsEmpty') + '
'; + return; + } + var html = ''; + mods.forEach(function(m) { + var sizeStr = m.size > 1048576 ? (m.size / 1048576).toFixed(1) + ' MB' : Math.ceil(m.size / 1024) + ' KB'; + html += '
' + + '' + app.esc(m.name) + '' + + '' + sizeStr + '' + + '
' + + '' + + '
'; + }); + el.innerHTML = html; + } + + async adminUploadMod() { + var input = document.getElementById('admin-mod-file'); + var status = document.getElementById('admin-mod-status'); + if (!input || !input.files || !input.files[0]) { this.toast('Select a file', 'error'); return; } + var file = input.files[0]; + if (!file.name.endsWith('.jar')) { this.toast('Only .jar files', 'error'); return; } + var reader = new FileReader(); + reader.onload = async function(e) { + var base64 = e.target.result.split(',')[1]; + var r = await app.req('/admin/whitelist/mods', { + method: 'POST', + body: JSON.stringify({ filename: file.name, data: base64 }) + }); + status.classList.remove('hidden'); + if (r.success) { + status.textContent = t('admin.modsUploaded'); + status.className = 'admin-status success'; + input.value = ''; + app.adminLoadMods(); + } else { + status.textContent = r.error || t('admin.error'); + status.className = 'admin-status error'; + } + }; + reader.readAsDataURL(file); + } + + async adminDeleteMod(hash) { + var r = await this.req('/admin/whitelist/mods/' + hash, { method: 'DELETE' }); + if (r.success) { + this.toast(t('admin.modsDeleted'), 'success'); + this.adminLoadMods(); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + + // --- Users --- + async adminSearchUser() { + var input = document.getElementById('admin-user-search'); + var results = document.getElementById('admin-user-results'); + var query = input.value.trim(); + if (!query) return; + results.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/users/search', { + method: 'POST', + body: JSON.stringify({ search: query }) + }); + if (!r.success) { + results.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var users = r.users || r.data || []; + if (!users.length) { + results.innerHTML = '
' + t('admin.noUser') + '
'; + return; + } + var html = ''; + users.forEach(function(u) { + html += '
' + + '' + app.esc(u.username) + '' + + '' + (u.role_name || 'Role ' + (u.role || 0)) + '' + + '
'; + }); + results.innerHTML = html; + } + + adminClearUser() { + document.getElementById('admin-user-results').innerHTML = ''; + document.getElementById('admin-user-detail').classList.add('hidden'); + document.getElementById('admin-user-search').value = ''; + } + + adminSelectUser(id, username, role) { + var detail = document.getElementById('admin-user-detail'); + document.getElementById('admin-user-detail-name').textContent = username; + document.getElementById('admin-user-role-select').value = String(role); + detail.dataset.userId = id; + detail.dataset.username = username; + detail.classList.remove('hidden'); + } + + async adminSetRole() { + var detail = document.getElementById('admin-user-detail'); + var userId = detail.dataset.userId; + var username = detail.dataset.username; + var role = parseInt(document.getElementById('admin-user-role-select').value); + if (!userId) { this.toast(t('admin.noUser'), 'error'); return; } + var r = await this.req('/admin/users/role', { + method: 'POST', + body: JSON.stringify({ user_id: parseInt(userId), role: role, username: username }) + }); + if (r.success) { + this.toast(t('admin.roleUpdated'), 'success'); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + + async adminResetPassword() { + var detail = document.getElementById('admin-user-detail'); + var userId = detail.dataset.userId; + var username = detail.dataset.username; + if (!userId) { this.toast(t('admin.noUser'), 'error'); return; } + var r = await this.req('/admin/users/reset-password', { + method: 'POST', + body: JSON.stringify({ user_id: parseInt(userId), username: username }) + }); + if (r.success) { + this.toast(tr('admin.passReset', null, {pass: r.new_password || r.password || ''}), 'success'); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + + // --- Passes --- + async adminLoadPasses() { + var el = document.getElementById('admin-passes-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/passes'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var passes = r.passes || r.data || []; + if (!passes.length) { + el.innerHTML = '
' + t('admin.passesEmpty') + '
'; + return; + } + var html = ''; + passes.forEach(function(p) { + html += '
' + + '' + app.esc(p.code || p.name || '?') + '' + + '' + (p.username ? app.esc(p.username) : '') + '' + + '
'; + }); + el.innerHTML = html; + } + + // --- News --- + async adminLoadNewsList() { + var el = document.getElementById('admin-news-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/news'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var newsList = []; + try { newsList = (typeof r.data === 'string' ? JSON.parse(r.data) : r.data).news || []; } catch(e) {} + if (!newsList.length) { + el.innerHTML = '
' + t('admin.newsEmpty') + '
'; + return; + } + var html = ''; + newsList.forEach(function(item, idx) { + html += '
' + + '
' + + '
' + app.esc(item.title) + '
' + + '
' + app.esc(item.type) + ' \u00b7 ' + app.esc(item.version || '') + '
' + + '
' + + '
' + + '' + + '
'; + }); + el.innerHTML = html; + } + + async adminCreateNews() { + var title = document.getElementById('admin-news-title').value.trim(); + var type = document.getElementById('admin-news-type').value; + var version = document.getElementById('admin-news-version').value.trim(); + var body = document.getElementById('admin-news-body').value.trim(); + var status = document.getElementById('admin-news-status'); + if (!title || !body) { this.toast('Fill in title and body', 'error'); return; } + var r = await this.req('/admin/news', { + method: 'POST', + body: JSON.stringify({ title: title, type: type, version: version, body: body }) + }); + status.classList.remove('hidden'); + if (r.success) { + status.textContent = t('admin.newsCreated'); + status.className = 'admin-status success'; + document.getElementById('admin-news-title').value = ''; + document.getElementById('admin-news-version').value = ''; + document.getElementById('admin-news-body').value = ''; + this.adminLoadNewsList(); + } else { + status.textContent = r.error || t('admin.error'); + status.className = 'admin-status error'; + } + } + + async adminDeleteNews(idx) { + var r = await this.req('/admin/news/delete', { + method: 'POST', + body: JSON.stringify({ index: parseInt(idx) }) + }); + if (r.success) { + this.toast(t('admin.newsDeleted'), 'success'); + this.adminLoadNewsList(); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + + // --- Packs --- + async adminLoadPacks() { + var el = document.getElementById('admin-packs-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/packs'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var packs = r.packs || []; + if (!packs.length) { + el.innerHTML = '
' + t('admin.packsEmpty') + '
'; + return; + } + var html = ''; + packs.forEach(function(p) { + var statusText = p.disabled ? t('admin.packsDisabled') : t('admin.packsEnabled'); + var statusClass = p.disabled ? 'admin-pack-disabled' : 'admin-pack-enabled'; + html += '
' + + '' + app.esc(p.name) + '' + + '' + statusText + '' + + '
' + + '' + + '
'; + }); + el.innerHTML = html; + } + + async adminTogglePack(packName) { + var r = await this.req('/admin/packs/' + encodeURIComponent(packName) + '/toggle', { + method: 'POST', + body: '{}' + }); + if (r.success) { + this.toast((r.disabled ? t('admin.packsDisabled') : t('admin.packsEnabled')) + ': ' + packName, 'success'); + this.adminLoadPacks(); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + + // ==================== NEWS ==================== + async loadNews() { + const r = await this.req('/news'); + if (r.success && r.data) { + let parsed; + try { + parsed = typeof r.data === 'string' ? JSON.parse(r.data) : r.data; + } catch(e) { return; } + const newsList = parsed.news || []; + this.renderNews(newsList); + } + } + + renderNews(newsList) { + const grid = document.getElementById('news-grid'); + grid.innerHTML = ''; + if (!newsList.length) { + grid.innerHTML = '

' + t('news.empty') + '

'; + return; + } + newsList.forEach((item, idx) => { + const card = document.createElement('article'); + card.className = 'news-card'; + var tempDiv = document.createElement('div'); + tempDiv.innerHTML = this.mdToHtml(item.body || ''); + var previewText = tempDiv.textContent.replace(/\s+/g, ' ').trim().substring(0, 200); + card.innerHTML = ` +
${this.esc(item.type)}
+

${this.esc(item.title)}

+

${this.esc(previewText)}

+ + `; + card.addEventListener('click', () => this.showNewsModal(item)); + grid.appendChild(card); + }); + } + + mdToHtml(md) { + if (!md) return ''; + try { + var processed = md; + processed = processed.replace(/\[photo="([^"]+)"\]/g, '![]($1)'); + processed = processed.replace(/\[([^\[\]]+)\]/g, function(match, inner) { + try { + var arr = JSON.parse(match); + if (Array.isArray(arr) && arr.length > 0 && typeof arr[0] === 'object' && 'text' in arr[0]) { + return arr.map(function(item) { + var t = item.text || ''; + var url = item.click_action === 'open_url' ? item.url : null; + if (url) return '[' + t + '](' + url + ')'; + var parts = []; + if (item.bold) parts.push('**'); + if (item.italic) parts.push('*'); + parts.push(t); + if (item.italic) parts.push('*'); + if (item.bold) parts.push('**'); + return parts.join(''); + }).join(''); + } + } catch(e) {} + return match; + }); + return marked.parse(processed); + } catch (e) { + return '

' + this.esc(md) + '

'; + } + } + + showNewsModal(item) { + let modal = document.getElementById('news-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'news-modal'; + modal.className = 'modal-backdrop hidden'; + modal.innerHTML = ` + + `; + document.getElementById('app').appendChild(modal); + document.getElementById('news-modal-close').addEventListener('click', () => this.hideNewsModal()); + modal.addEventListener('click', e => { if (e.target === modal) this.hideNewsModal(); }); + } + document.getElementById('news-modal-title').textContent = item.title; + const bodyEl = document.getElementById('news-modal-body'); + bodyEl.innerHTML = this.mdToHtml(item.body || ''); + modal.classList.remove('hidden'); + } + + hideNewsModal() { + const modal = document.getElementById('news-modal'); + if (modal) modal.classList.add('hidden'); + } + + // ==================== LAUNCH ==================== + async launchPack() { + const inst = this.state.selectedPack; + if (!inst) { + this.toast(t('toast.noPackSelected'), 'warning'); + return; + } + if (this.hasUpdate(inst)) { + await this.updateSelectedPack(); + return; + } + this.toast(tr('toast.launching', null, {name: inst.name}), 'info'); + const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }), timeout: 120000 }); + if (r.success) { + this.toast(tr('toast.launched', null, {pid: String(r.data?.pid || '')}), 'success'); + this.startPlaytimeTracking(inst.name); + } else { + this.toast(r.error || t('toast.launchFailed'), 'error'); + } + } + + async updateSelectedPack() { + const inst = this.state.selectedPack; + if (!inst) return; + this.setPlayBtnText('playBar.updating', true); + const r = await this.req('/update', { method: 'POST', body: JSON.stringify({ name: inst.name }), timeout: 120000 }); + if (r.success) { + this.toast(tr('toast.updated', null, {name: inst.name, version: String(r.data?.version || '')}), 'success'); + if (this.state.updateMap) { + this.state.updateMap[inst.name] = false; + } + await this.loadInstances(); + } else { + this.toast(r.error || t('toast.updateFailed'), 'error'); + this.setPlayBtnText('playBar.update', false); + } + } + + // ==================== INSTALL ==================== + async showInstallModal() { + document.getElementById('install-modal').classList.remove('hidden'); + const zernmcSel = document.getElementById('zernmc-pack-select'); + const presetContainer = document.getElementById('preset-select-container'); + const presetSel = document.getElementById('preset-select'); + const mcSel = document.getElementById('mc-version-select'); + if (zernmcSel) { + zernmcSel.innerHTML = ''; + } + if (presetContainer) presetContainer.classList.add('hidden'); + if (mcSel) { + mcSel.innerHTML = ''; + } + + try { + const packs = await this.req('/packs'); + if (zernmcSel && packs.success && Array.isArray(packs.data) && packs.data.length > 0) { + if (this.state.account && this.state.account.passActive) { + zernmcSel.innerHTML = ''; + zernmcSel.disabled = false; + packs.data.forEach(p => { + if (p.disabled) return; // skip disabled packs + const o = document.createElement('option'); + o.value = p.name; + o.dataset.presets = JSON.stringify(p.presets || []); + o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')'; + zernmcSel.appendChild(o); + }); + // Update preset selector when pack changes + zernmcSel.onchange = function() { + const selected = zernmcSel.options[zernmcSel.selectedIndex]; + const presets = selected && selected.dataset.presets ? JSON.parse(selected.dataset.presets) : []; + if (presetSel) { + presetSel.innerHTML = ''; + presets.forEach(function(pr) { + const o = document.createElement('option'); + o.value = pr; + o.textContent = pr; + presetSel.appendChild(o); + }); + } + if (presetContainer) { + if (presets.length > 0) { + presetContainer.classList.remove('hidden'); + } else { + presetContainer.classList.add('hidden'); + } + } + app.enhanceSelects(); + }; + } else { + zernmcSel.innerHTML = ''; + zernmcSel.disabled = true; + } + } else if (zernmcSel) { + zernmcSel.innerHTML = ''; + zernmcSel.disabled = true; + } + + if (this.state.account && !this.state.account.passActive) { + const zernmcTab = document.querySelector('[data-tab="zernmc"]'); + if (zernmcTab) zernmcTab.style.opacity = '0.5'; + } else { + const zernmcTab = document.querySelector('[data-tab="zernmc"]'); + if (zernmcTab) zernmcTab.style.opacity = '1'; + } + } catch (e) { + if (zernmcSel) { + zernmcSel.innerHTML = ''; + zernmcSel.disabled = true; + } + } + if (mcSel) this.enhanceSelects(); + + if (mcSel) { + try { + const mc = await this.req('/mc-versions'); + if (mc.success && Array.isArray(mc.data)) { + mcSel.innerHTML = ''; + mc.data.forEach(v => { + const o = document.createElement('option'); + o.value = v; o.textContent = v; + mcSel.appendChild(o); + }); + } else { + mcSel.innerHTML = ''; + } + } catch (e) { + mcSel.innerHTML = ''; + } + } + this.enhanceSelects(); + } + + hideInstallModal() { + document.getElementById('install-modal').classList.add('hidden'); + document.getElementById('install-progress').classList.add('hidden'); + if (this.progressPoller) { + document.getElementById('inline-install-progress').classList.remove('hidden'); + } + } + + switchInstallTab(tab) { + document.querySelectorAll('.modal-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); + document.querySelectorAll('.modal-tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab)); + } + + // ==================== CUSTOM SELECT ==================== + enhanceSelects() { + document.querySelectorAll('select').forEach(sel => { + if (sel.dataset.enhanced) { + this.refreshCustomSelect(sel); + return; + } + sel.dataset.enhanced = '1'; + sel.style.display = 'none'; + const wrap = document.createElement('div'); + wrap.className = 'custom-select-wrap'; + + const trigger = document.createElement('div'); + trigger.className = 'custom-select-trigger'; + const placeholder = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : (sel.placeholder || t('select.placeholder')); + trigger.innerHTML = `${placeholder} + `; + + const dropdown = document.createElement('div'); + dropdown.className = 'custom-select-dropdown'; + dropdown.innerHTML = '
'; + const optionsContainer = dropdown.querySelector('.custom-select-options'); + const searchInput = dropdown.querySelector('input'); + + sel.querySelectorAll('option').forEach(o => { + const opt = document.createElement('div'); + opt.className = 'custom-select-option' + (o.selected ? ' selected' : ''); + opt.textContent = o.text; + opt.dataset.value = o.value; + optionsContainer.appendChild(opt); + }); + + const updateTrigger = () => { + const selected = optionsContainer.querySelector('.selected'); + const txt = selected ? selected.textContent : (sel.options[0] ? sel.options[0].text : t('select.placeholder')); + trigger.innerHTML = `${txt} + `; + }; + + const updateDisabled = () => { + if (sel.disabled) { + trigger.style.opacity = '0.5'; + trigger.style.cursor = 'not-allowed'; + } else { + trigger.style.opacity = ''; + trigger.style.cursor = ''; + } + }; + updateDisabled(); + + trigger.addEventListener('click', () => { + if (sel.disabled) return; + const open = dropdown.classList.toggle('open'); + trigger.classList.toggle('open', open); + if (open) { + searchInput.value = ''; + searchInput.focus(); + optionsContainer.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('hidden')); + } + }); + + searchInput.addEventListener('input', () => { + const q = searchInput.value.toLowerCase(); + optionsContainer.querySelectorAll('.custom-select-option').forEach(o => { + o.classList.toggle('hidden', q && !o.textContent.toLowerCase().includes(q)); + }); + }); + + optionsContainer.addEventListener('click', e => { + const opt = e.target.closest('.custom-select-option'); + if (!opt || opt.classList.contains('hidden')) return; + optionsContainer.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected')); + opt.classList.add('selected'); + sel.value = opt.dataset.value; + updateTrigger(); + dropdown.classList.remove('open'); + trigger.classList.remove('open'); + sel.dispatchEvent(new Event('change', { bubbles: true })); + }); + + document.addEventListener('click', e => { + if (!wrap.contains(e.target)) { + dropdown.classList.remove('open'); + trigger.classList.remove('open'); + } + }); + + wrap.appendChild(trigger); + wrap.appendChild(dropdown); + sel.parentNode.insertBefore(wrap, sel.nextSibling); + }); + } + + refreshCustomSelect(sel) { + if (!sel || !sel.dataset.enhanced) return; + let wrap = sel.nextElementSibling; + while (wrap && !wrap.classList.contains('custom-select-wrap')) { + wrap = wrap.nextElementSibling; + } + if (!wrap) return; + const optionsContainer = wrap.querySelector('.custom-select-options'); + const trigger = wrap.querySelector('.custom-select-trigger'); + optionsContainer.innerHTML = ''; + sel.querySelectorAll('option').forEach(o => { + const opt = document.createElement('div'); + opt.className = 'custom-select-option' + (o.selected ? ' selected' : ''); + opt.textContent = o.text; + opt.dataset.value = o.value; + optionsContainer.appendChild(opt); + }); + const selected = optionsContainer.querySelector('.selected'); + trigger.innerHTML = '' + (selected ? selected.textContent : t('select.placeholder')) + '' + + ''; + } + + async onLoaderChange(loader) { + const f = document.getElementById('loader-ver-field'); + if (loader === 'vanilla') { f.classList.add('hidden'); return; } + f.classList.remove('hidden'); + const sel = document.getElementById('loader-ver-select'); + sel.innerHTML = ''; + const mc = document.getElementById('mc-version-select').value; + if (!mc) { sel.innerHTML = ''; this.enhanceSelects(); return; } + const r = await this.req(`/loader-versions?mc=${mc}&loader=${loader}`); + if (r.success && r.data) { + sel.innerHTML = ''; + r.data.forEach(v => { + const o = document.createElement('option'); + o.value = v; o.textContent = v; + sel.appendChild(o); + }); + } else { + sel.innerHTML = ''; + } + this.enhanceSelects(); + } + + async installZernMCPack() { + const packName = document.getElementById('zernmc-pack-select').value; + if (!packName) { this.toast(t('select.selectPack'), 'error'); return; } + const preset = document.getElementById('preset-select') ? document.getElementById('preset-select').value : ''; + // Use server pack name as instance name (no local naming) + const instanceName = packName; + + const body = { name: instanceName, version: packName, loader: 'zernmc' }; + if (preset) body.preset = preset; + + const r = await this.req('/install', { + method: 'POST', + body: JSON.stringify(body) + }); + if (r.success) { + this.toast(t('toast.installing'), 'info'); + this.startProgressPoll(); + } else { + this.toast(r.error || t('toast.launchFailed'), 'error'); + } + } + + async installCustom() { + const mc = document.getElementById('mc-version-select').value; + const loader = document.getElementById('loader-select').value; + const loaderVer = document.getElementById('loader-ver-select').value; + const name = document.getElementById('custom-instance-name').value.trim(); + + if (!mc) { this.toast(t('select.selectVersion'), 'error'); return; } + if (!name) { this.toast(t('install.enterName'), 'error'); return; } + + const r = await this.req('/install', { + method: 'POST', + body: JSON.stringify({ name, version: mc, loader, loaderVersion: loaderVer }) + }); + if (r.success) { + this.toast(t('toast.installing'), 'info'); + this.startProgressPoll(); + } else { + this.toast(r.error || t('toast.launchFailed'), 'error'); + } + } + + startProgressPoll() { + this.progressPoller = setInterval(async () => { + const r = await this.req('/install/progress'); + if (r.success && r.data) { + const modalP = document.getElementById('install-progress'); + modalP.classList.remove('hidden'); + const pct = (r.data.percent || 0) + '%'; + const label = r.data.label || 'Installing...'; + const indeterminate = r.data.inProgress && (!r.data.total || r.data.total <= 0); + + const fill = document.getElementById('progress-fill'); + if (indeterminate) { + fill.classList.add('indeterminate'); + fill.style.width = ''; + } else { + fill.classList.remove('indeterminate'); + fill.style.width = pct; + } + document.getElementById('progress-label').textContent = label; + + const inlineProgress = document.getElementById('inline-install-progress'); + if (!inlineProgress.classList.contains('hidden')) { + const inlineFill = document.getElementById('inline-progress-fill'); + if (indeterminate) { + inlineFill.classList.add('indeterminate'); + inlineFill.style.width = ''; + } else { + inlineFill.classList.remove('indeterminate'); + inlineFill.style.width = pct; + } + document.getElementById('inline-progress-label').textContent = label; + const inlineStage = document.getElementById('inline-progress-stage'); + if (r.data.stageName && r.data.stageCount > 1) { + inlineStage.textContent = tr('install.step', null, {current: String(r.data.stageIndex || 0), total: String(r.data.stageCount), name: r.data.stageName}); + inlineStage.classList.remove('hidden'); + } else { + inlineStage.classList.add('hidden'); + } + } + + const modalStage = document.getElementById('progress-stage'); + if (r.data.stageName && r.data.stageCount > 1) { + modalStage.textContent = tr('install.step', null, {current: String(r.data.stageIndex || 0), total: String(r.data.stageCount), name: r.data.stageName}); + modalStage.classList.remove('hidden'); + } else { + modalStage.classList.add('hidden'); + } + if (!r.data.inProgress) { + this.stopProgressPoll(); + this.hideInstallModal(); + inlineProgress.classList.add('hidden'); + var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && r.data.label.startsWith('Error')); + this.toast(failed ? (r.data.label || t('toast.installFailed')) : t('toast.installComplete'), failed ? 'error' : 'success'); + if (!failed) { + await this.loadInstances(); + } + } + } + }, 500); + } + + stopProgressPoll() { + if (this.progressPoller) { + clearInterval(this.progressPoller); + this.progressPoller = null; + this.networkPoller = null; + } + } + + // ==================== SETTINGS ==================== + async loadSettings() { + const r = await this.req('/settings'); + if (r.success && r.data) { + var ram = r.data.maxMemory || 4096; + document.getElementById('ram-slider').value = ram; + document.getElementById('ram-value').textContent = tr('ram.gb', null, {value: (ram / 1024).toFixed(1)}); + document.getElementById('ram-info').textContent = tr('ram.mbAllocated', null, {value: ram}); + document.getElementById('server-url').textContent = r.data.serverUrl || 'http://87.120.187.36:1582'; + + if (r.data.windowWidth) { + document.getElementById('win-width').value = r.data.windowWidth; + } + if (r.data.windowHeight) { + document.getElementById('win-height').value = r.data.windowHeight; + } + if (r.data.extraJvmArgs !== undefined) { + document.getElementById('jvm-args').value = r.data.extraJvmArgs || ''; + } + if (r.data.javaPath) { + document.getElementById('java-path-input').value = r.data.javaPath; + } + if (r.data.locale) { + this.setLocale(r.data.locale); + } + var sysJvm = !!r.data.systemBasedJvm; + document.getElementById('system-jvm-toggle').checked = sysJvm; + this.updateSystemJvmInfo(r.data); + this.state._sysInfo = { cpuCores: r.data.cpuCores, totalRamMB: r.data.totalRamMB }; + } else { + document.getElementById('ram-value').textContent = tr('ram.gb', null, {value: '4'}); + document.getElementById('ram-slider').value = '4096'; + } + var sr = await this.req('/instances'); + if (sr.success) { + document.getElementById('server-status').textContent = t('settings.server.connected'); + document.getElementById('server-status').style.color = 'var(--success)'; + } else { + document.getElementById('server-status').textContent = t('settings.server.disconnected'); + document.getElementById('server-status').style.color = 'var(--error)'; + } + } + + updateSystemJvmInfo(data) { + var el = document.getElementById('system-jvm-info'); + if (!el) return; + var cores = data.cpuCores || '-'; + var ramGB = data.totalRamMB ? (data.totalRamMB / 1024).toFixed(1) : '-'; + var flags = data.systemBasedJvm ? t('settings.systemJvm.enabled') : t('settings.systemJvm.disabled'); + el.textContent = tr('settings.systemJvm.info', null, {cores: cores, ram: ramGB, flags: flags}); + } + + async saveSettings() { + try { + const ram = document.getElementById('ram-slider').value; + const w = parseInt(document.getElementById('win-width').value) || 1280; + const h = parseInt(document.getElementById('win-height').value) || 720; + const jvm = document.getElementById('jvm-args').value.trim(); + const jp = document.getElementById('java-path-input').value.trim(); + var localeSel = document.getElementById('locale-select'); + var locale = localeSel ? localeSel.value : _currentLocale; + var sysToggle = document.getElementById('system-jvm-toggle'); + var sysJvm = sysToggle ? sysToggle.checked : false; + var r = await this.req('/settings', { method: 'POST', body: JSON.stringify({ maxMemory: ram, windowWidth: w, windowHeight: h, extraJvmArgs: jvm, javaPath: jp, locale: locale, systemBasedJvm: sysJvm }) }); + if (!r.success && r.error) this.toast(r.error, 'error'); + } catch (e) { + this.toast(t('error.network'), 'error'); + } + } + + // ==================== FRIENDS ==================== + async loadFriends() { + try { + var r = await this.req('/friends/list'); + if (r.success !== false) { + this.state.friends = r.friends || []; + } else { + this.state.friends = []; + } + r = await this.req('/friends/requests'); + if (r.success !== false) { + this.state.friendRequests = r.requests || []; + } else { + this.state.friendRequests = []; + } + this.renderFriends(); + this.renderFriendRequests(); + } catch (e) { + this.state.friends = []; + this.state.friendRequests = []; + } + } + + startFriendStatusHeartbeat() { + if (this._friendsPollInterval) clearInterval(this._friendsPollInterval); + this._friendsPollInterval = setInterval(this.loadFriends.bind(this), 10000); + // Send online status + this.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: this.state.selectedPack ? this.state.selectedPack.name : null }) }); + setInterval(function() { + app.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: app.state.selectedPack ? app.state.selectedPack.name : null }) }); + }, 60000); + } + + renderFriends() { + var list = document.getElementById('friends-list'); + if (!list) return; + var query = (document.getElementById('friends-search-input') || {}).value || ''; + var filtered = this.state.friends; + if (query) { + var q = query.toLowerCase(); + filtered = filtered.filter(function(f) { return f.username.toLowerCase().includes(q); }); + } + if (!filtered || filtered.length === 0) { + list.innerHTML = '
' + t('friends.empty') + '
'; + return; + } + var online = filtered.filter(function(f) { return f.online; }); + var offline = filtered.filter(function(f) { return !f.online; }); + var html = ''; + if (online.length) { + html += '
' + t('friends.online') + ' (' + online.length + ')
'; + online.forEach(function(f) { html += renderFriendItem(f); }); + } + if (offline.length) { + html += '
' + t('friends.offline') + ' (' + offline.length + ')
'; + offline.forEach(function(f) { html += renderFriendItem(f); }); + } + list.innerHTML = html; + } + + renderFriendRequests() { + var section = document.getElementById('friend-requests-section'); + var list = document.getElementById('friend-requests-list'); + if (!section || !list) return; + var reqs = this.state.friendRequests; + if (!reqs || reqs.length === 0) { + section.classList.add('hidden'); + return; + } + section.classList.remove('hidden'); + list.innerHTML = reqs.map(function(r) { + return '
' + + '
' + + '
' + r.username[0].toUpperCase() + '
' + + '
' + + '
' + app.esc(r.username) + '
' + + '
' + tr('friends.requestFrom', null, {user: app.esc(r.username)}) + '
' + + '
' + + '
' + + '' + + '' + + '
'; + }).join(''); + } + + showAddFriend() { + document.getElementById('add-friend-modal').classList.remove('hidden'); + document.getElementById('add-friend-input').value = ''; + document.getElementById('add-friend-error').classList.add('hidden'); + document.getElementById('add-friend-input').focus(); + } + + closeAddFriend() { + document.getElementById('add-friend-modal').classList.add('hidden'); + document.getElementById('add-friend-input').value = ''; + document.getElementById('add-friend-error').classList.add('hidden'); + } + + async submitAddFriend() { + var input = document.getElementById('add-friend-input'); + var username = input.value.trim(); + if (!username) return; + var errEl = document.getElementById('add-friend-error'); + var btn = document.getElementById('add-friend-submit'); + btn.disabled = true; + try { + var r = await this.req('/friends/add', { method: 'POST', body: JSON.stringify({ username: username }) }); + if (r.detail) { + errEl.textContent = r.detail; + errEl.classList.remove('hidden'); + } else { + this.toast(r.message || t('friends.addSuccess'), 'success'); + this.closeAddFriend(); + this.loadFriends(); + } + } catch (e) { + errEl.textContent = t('friends.addError'); + errEl.classList.remove('hidden'); + } + btn.disabled = false; + } + + filterFriends() { + this.renderFriends(); + } + + async acceptFriendRequest(userId) { + var r = await this.req('/friends/accept', { method: 'POST', body: JSON.stringify({ user_id: userId }) }); + if (r.detail) { + this.toast(r.detail, 'error'); + } else { + this.toast(r.message || t('friends.acceptSuccess'), 'success'); + this.loadFriends(); + } + } + + async declineFriendRequest(userId) { + var r = await this.req('/friends/remove', { method: 'POST', body: JSON.stringify({ user_id: userId }) }); + if (r.detail) { + this.toast(r.detail, 'error'); + } else { + this.toast(r.message || 'Declined', 'info'); + this.loadFriends(); + } + } + + async removeFriend(userId) { + var r = await this.req('/friends/remove', { method: 'POST', body: JSON.stringify({ user_id: userId }) }); + if (r.detail) { + this.toast(r.detail, 'error'); + } else { + this.toast(r.message || t('friends.removeSuccess'), 'success'); + this.loadFriends(); + } + } + + // ==================== PLAYTIME ==================== + async loadPlaytimeStats() { + try { + var r = await this.req('/playtime/stats'); + if (r.success !== false) { + this.state.playtimeStats = r; + } + if (this.state.selectedPack) this.showPackDetail(this.state.selectedPack); + } catch (e) {} + } + + startPlaytimeTracking(packName) { + this._playtimeAccumulated = 0; + this._playtimeStartTime = Date.now(); + this._currentPlaytimePack = packName; + if (this._playtimeInterval) clearInterval(this._playtimeInterval); + this._playtimeInterval = setInterval(this.syncPlaytime.bind(this), 60000); + } + + stopPlaytimeTracking() { + if (this._playtimeInterval) { + clearInterval(this._playtimeInterval); + this._playtimeInterval = null; + } + if (this._playtimeAccumulated > 0) { + this.syncPlaytime(); + } + this._playtimeAccumulated = 0; + this._playtimeStartTime = null; + this._currentPlaytimePack = null; + } + + async syncPlaytime() { + if (!this._currentPlaytimePack || this._playtimeAccumulated <= 0) return; + var minutes = Math.floor(this._playtimeAccumulated); + if (minutes <= 0) return; + try { + var r = await this.req('/playtime/sync', { method: 'POST', body: JSON.stringify({ minutes: minutes, pack_name: this._currentPlaytimePack }) }); + if (r.success !== false) { + this._playtimeAccumulated = 0; + this.loadPlaytimeStats(); + } + } catch (e) {} + } + + async activatePass() { + const code = document.getElementById('pass-code').value.trim(); + if (!code) { this.toast(t('toast.enterPassCode'), 'error'); return; } + const r = await this.req('/activate-pass', { method: 'POST', body: JSON.stringify({ pass_code: code }) }); + if (r.success) { + this.toast(t('toast.passActivated'), 'success'); + document.getElementById('pass-code').value = ''; + this.state.account.passActive = true; + this.state.account.role = 1; + this.state.account.roleName = 'PASS_HOLDER'; + this.updateBadge(); + await this.loadInstances(); + await this.loadServerPacksList(); + } else { + this.toast(r.error || t('toast.activationFailed'), 'error'); + } + } + + // ==================== TOAST ==================== + toast(msg, type = 'info') { + const el = document.getElementById('toast'); + el.textContent = msg; + el.className = 'toast ' + type; + el.classList.remove('hidden'); + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => el.classList.add('hidden'), 3000); + } + + // ==================== LOADING ==================== + showLoading(show) { + document.getElementById('loading-overlay').classList.toggle('hidden', !show); + } + + updateBadge() { + const a = this.state.account; + if (!a) return; + const status = document.getElementById('account-status'); + if (a.passActive) { + status.textContent = t('badge.pro'); + status.className = 'badge badge-pro'; + } else { + status.textContent = t('badge.free'); + status.className = 'badge badge-free'; + } + const role = document.getElementById('account-role'); + if (a.roleName) { + role.textContent = a.roleName; + role.classList.remove('hidden'); + } else { + role.classList.add('hidden'); + } + } + + // ==================== LOG VIEWER ==================== + openLogViewer() { + var overlay = document.getElementById('log-viewer-overlay'); + if (!overlay) return; + this._logRenderedLines = null; + overlay.classList.remove('hidden'); + this._pollLogViewer(); + this._logPollInterval = setInterval(this._pollLogViewer.bind(this), 1500); + } + + closeLogViewer() { + var overlay = document.getElementById('log-viewer-overlay'); + if (overlay) overlay.classList.add('hidden'); + if (this._logPollInterval) { + clearInterval(this._logPollInterval); + this._logPollInterval = null; + } + } + + async _pollLogViewer() { + try { + var r = await this.req('/game-logs'); + if (r.success && r.data) { + this._renderLogViewer(r.data); + } + } catch(e) {} + } + + _renderLogViewer(raw) { + var content = document.getElementById('log-viewer-content'); + if (!content) return; + if (!raw || !raw.trim()) { + this._logRenderedLines = null; + content.innerHTML = '
' + t('logViewer.noLogs') + '
'; + return; + } + var lines = raw.split('\n').filter(function(l) { return l.trim(); }); + var cleaned = lines.map(function(l) { return l.replace(/§[0-9a-fklmnor]/g, ''); }); + + var stick = content.scrollTop + content.clientHeight >= content.scrollHeight - 40; + + var prev = this._logRenderedLines; + var appendFrom = 0; + if (prev && prev.length > 0 && lines.length >= prev.length) { + var same = true; + for (var i = 0; i < prev.length; i++) { + if (cleaned[i] !== prev[i]) { same = false; break; } + } + if (same) appendFrom = prev.length; + } + + this._logRenderedLines = cleaned; + if (appendFrom > 0) { + content.insertAdjacentHTML('beforeend', lines.slice(appendFrom).map(this._renderLogLine.bind(this)).join('')); + } else { + content.innerHTML = lines.map(this._renderLogLine.bind(this)).join(''); + } + if (stick) content.scrollTop = content.scrollHeight; + } + + _renderLogLine(line) { + var cls = 'log-line'; + var upper = line.toUpperCase(); + if (upper.includes('ERROR') || upper.includes('FATAL')) cls += ' log-error'; + else if (upper.includes('WARN')) cls += ' log-warn'; + else if (upper.includes('INFO')) cls += ' log-info'; + else if (upper.includes('DEBUG') || upper.includes('TRACE')) cls += ' log-debug'; + var text = line.replace(/§[0-9a-fklmnor]/g, ''); + return '
' + this.esc(text) + '
'; + } + + async copyLogs() { + var content = document.getElementById('log-viewer-content'); + if (!content) return; + var text = content.textContent; + try { + var r = await this.req('/clipboard', { method: 'POST', body: JSON.stringify({ text: text }) }); + if (r && r.success) { + this.toast(t('logViewer.copied'), 'success'); + return; + } + } catch(e) {} + // Fallback for plain-browser use (outside the launcher) + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function() { + app.toast(t('logViewer.copied'), 'success'); + }).catch(function() { + app.toast(t('logViewer.copied'), 'success'); + }); + } else { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch(e) {} + document.body.removeChild(ta); + this.toast(t('logViewer.copied'), 'success'); + } + } + + async openLogFile() { + try { + var r = await this.req('/open-log-file', { method: 'POST' }); + if (r && r.success) { + this.toast(t('logViewer.opened'), 'success'); + } else { + this.toast((r && r.error) || t('logViewer.openFailed'), 'error'); + } + } catch(e) { + this.toast(t('logViewer.openFailed'), 'error'); + } + } + + esc(s) { + if (!s) return ''; + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +} + +function renderFriendItem(f) { + var initial = (f.username || '?')[0].toUpperCase(); + var statusClass = f.online ? 'online' : 'offline'; + var statusText = f.online ? t('friends.online') : t('friends.offline'); + var packText = f.online && f.current_pack ? tr('friends.playing', null, {pack: f.current_pack}) : (f.online ? t('friends.noPack') : ''); + var roleBadge = f.role && f.role > 0 ? '' + app.esc('Role ' + f.role) + '' : ''; + return '
' + + '
' + initial + '
' + + '
' + + '
' + + '' + app.esc(f.username) + '' + + roleBadge + + '
' + + '
' + + '' + + '' + statusText + '' + + (packText ? ' \u00b7 ' + packText + '' : '') + + '
' + + '
' + + '
'; +} + +var renderer = new marked.Renderer(); +renderer.image = function(token) { + var src = token.href; + var alt = (token.text || '').replace(/"/g, '"'); + var titleAttr = token.title ? ' title="' + token.title.replace(/"/g, '"') + '"' : ''; + return '' + alt + ''; +}; +renderer.link = function(token) { + var href = token.href; + var text = marked.parseInline(token.text); + var titleAttr = token.title ? ' title="' + token.title.replace(/"/g, '"') + '"' : ''; + return '' + text + ''; +}; +marked.setOptions({ renderer: renderer }); + +var app = new ZernMCLauncher(); diff --git a/launcher/launcher/src/resources/ui/marked.min.js b/launcher/launcher/src/resources/ui/marked.min.js new file mode 100644 index 0000000..b4e0d73 --- /dev/null +++ b/launcher/launcher/src/resources/ui/marked.min.js @@ -0,0 +1,69 @@ +/** + * marked v15.0.12 - a markdown parser + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ +(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports}; +"use strict";var H=Object.defineProperty;var be=Object.getOwnPropertyDescriptor;var Te=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/|$))/,Ce=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Ie=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",le).getRegex(),X={blockquote:Ie,code:_e,def:Ae,fences:Le,heading:ze,hr:O,html:Ce,lheading:oe,list:Ee,newline:$e,paragraph:le,table:I,text:Pe},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Oe={...X,lheading:Me,table:re,paragraph:h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:I,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Q).replace("hr",O).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function me(l,e,t,n,s){let i=e.href,r=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:r,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function rt(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` +`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(` +`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,` +`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],` +`).split(` +`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(` +`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+` +`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(` +`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=` +`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=` +`+g}!T&&!g.trim()&&(T=!0),p+=G+` +`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;cd.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=` +`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+` +`;return s?'
'+(n?i:R(i,!0))+`
+`:"
"+(n?i:R(i,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
+`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o +`+s+" +`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • +`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let i=0;i${s}`),` + +`+t+` +`+s+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${R(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='
    ",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=`${n}{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let s="

    An error occurred:

    "+R(n.message+"",!0)+"
    ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex; + +if(__exports != exports)module.exports = exports;return module.exports})); diff --git a/launcher/launcher/src/resources/ui/style.css b/launcher/launcher/src/resources/ui/style.css new file mode 100644 index 0000000..c749faa --- /dev/null +++ b/launcher/launcher/src/resources/ui/style.css @@ -0,0 +1,985 @@ +:root { + --bg-deep: #07070a; + --bg-surface: #0c0c12; + --bg-elevated: #111118; + --bg-card: #16161f; + --bg-card-hover: #1c1c28; + --bg-inset: #0a0a0f; + --accent: #e94560; + --accent-glow: rgba(233, 69, 96, 0.25); + --accent-soft: rgba(233, 69, 96, 0.1); + --text: #eeeef0; + --text-secondary: #88889a; + --text-muted: #555566; + --border: #1e1e2a; + --border-light: #2a2a3a; + --success: #4ade80; + --error: #f87171; + --warning: #fbbf24; + --info: #60a5fa; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --shadow: 0 4px 24px rgba(0,0,0,0.5); + --shadow-glow: 0 0 40px var(--accent-glow); + --transition: 200ms ease; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', Arial, sans-serif; + --mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Monaco', monospace; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body, * { -webkit-user-select: none; user-select: none; } +input, textarea { -webkit-user-select: text; user-select: text; } + +html { font-size: 14px; } + +body { + font-family: var(--font); + background: var(--bg-deep); + color: var(--text); + min-height: 100vh; + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +#bg-canvas { + position: fixed; inset: 0; width: 100%; height: 100%; + z-index: 0; opacity: 0.08; pointer-events: none; +} + +#app { position: relative; z-index: 1; height: 100vh; display: flex; } + +.screen { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + transition: opacity 0.4s ease, transform 0.4s ease; +} + +.screen.hidden { opacity: 0; transform: scale(0.97); pointer-events: none; } + +.hidden { display: none !important; } + +/* ========== LOGIN ========== */ +.login-container { + position: relative; z-index: 1; + background: var(--bg-elevated); + border-radius: var(--radius-md); + padding: 32px 32px 24px; + width: 100%; + max-width: 360px; + box-shadow: 0 8px 40px rgba(0,0,0,0.5); + transition: transform 0.35s ease, opacity 0.35s ease; +} + +.login-header { text-align: center; margin-bottom: 28px; overflow: hidden; } +.brand-icon { margin-bottom: 12px; } +.login-title { + font-size: 22px; font-weight: 700; + color: var(--text); transition: transform 0.3s ease, opacity 0.3s ease; +} +.login-subtitle { + color: var(--text-muted); font-size: 13px; margin-top: 4px; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.login-form { display: flex; flex-direction: column; gap: 16px; } + +.field { display: flex; flex-direction: column; gap: 6px; } +.field label { + font-size: 11px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.5px; + color: var(--text-secondary); +} +.field input { + width: 100%; padding: 10px 12px; font-size: 15px; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: 4px; color: var(--text); + font-family: var(--font); transition: border-color 150ms ease, box-shadow 150ms ease; + outline: none; +} +.field input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} +.field input.invalid { + border-color: var(--error); + box-shadow: 0 0 0 2px rgba(248,113,113,0.15); +} +.field .field-hint { + font-size: 12px; color: var(--error); line-height: 1.4; +} +.field .field-hint.hidden { display: none; } +.field select { + width: 100%; padding: 10px 12px; font-size: 15px; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: 4px; color: var(--text); + font-family: var(--font); cursor: pointer; outline: none; +} +.field select:focus { border-color: var(--accent); } +#confirm-field { + transition: opacity 0.3s ease, max-height 0.3s ease, margin 0.3s ease; + overflow: hidden; max-height: 60px; opacity: 1; margin: 0; +} +#confirm-field.hidden { max-height: 0; opacity: 0; margin: 0; display: block !important; padding: 0; overflow: hidden; pointer-events: none; } + +.btn-primary { + width: 100%; padding: 14px; border: none; border-radius: 4px; + background: var(--accent); color: #fff; + font-size: 14px; font-weight: 600; cursor: pointer; + font-family: var(--font); transition: background 150ms ease, box-shadow 150ms ease; + display: flex; align-items: center; justify-content: center; gap: 8px; + min-height: 44px; position: relative; +} +.btn-primary:hover { background: #d63850; } +.btn-primary:active { background: #c22f46; transform: scale(0.98); } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } + +.error-msg { + color: var(--error); font-size: 13px; text-align: center; + padding: 10px; background: rgba(248,113,113,0.1); + border-radius: 4px; animation: shake 0.4s ease; +} +@keyframes shake { + 0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)} +} + +.login-footer { + display: flex; justify-content: center; gap: 4px; + font-size: 12px; color: var(--text-muted); margin-top: 4px; +} +.login-footer-action { color: var(--accent); cursor: pointer; } +.login-footer-action:hover { text-decoration: underline; } + +.spinner { + position: absolute; width: 20px; height: 20px; + border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; + border-radius: 50%; animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ========== OVERLAY ========== */ +.overlay { + position: fixed; inset: 0; background: rgba(7,7,10,0.92); + display: flex; flex-direction: column; align-items: center; justify-content: center; + z-index: 100; animation: fadeIn 0.3s ease; +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.loader-ring { + width: 48px; height: 48px; + border: 3px solid var(--border-light); border-top-color: var(--accent); + border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px; +} +.loader-text { color: var(--text-secondary); font-size: 14px; } + +/* ========== MAIN SHELL ========== */ +.shell { + display: flex; width: 100%; height: 100vh; + background: var(--bg-surface); +} + +/* ========== SIDEBAR ========== */ +.sidebar { + width: 260px; min-width: 260px; + background: var(--bg-deep); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + padding: 16px 12px; +} + +.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; } + +.sidebar-brand { + display: flex; align-items: center; gap: 10px; + padding: 4px 8px 16px; border-bottom: 1px solid var(--border); +} +.sidebar-brand-text { display: flex; flex-direction: column; } +.sidebar-brand-name { font-size: 16px; font-weight: 700; } +.sidebar-brand-ver { font-size: 11px; color: var(--text-muted); } + +.sidebar-nav { + display: flex; flex-direction: column; gap: 4px; + padding-bottom: 16px; border-bottom: 1px solid var(--border); +} +.sidebar-nav-primary, .sidebar-nav-secondary { + display: flex; gap: 4px; +} +.sidebar-nav-primary .nav-btn, .sidebar-nav-secondary .nav-btn { + flex: 1; +} +.nav-btn { + flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; + padding: 8px; background: transparent; border: 1px solid transparent; + border-radius: var(--radius-sm); color: var(--text-muted); font-size: 11px; + font-weight: 500; cursor: pointer; font-family: var(--font); + transition: var(--transition); +} +.nav-btn:hover { color: var(--text-secondary); background: var(--bg-card); } +.nav-btn.active { color: var(--accent); background: var(--accent-soft); border-color: rgba(233,69,96,0.2); } + +.section-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 8px; padding: 0 4px; +} +.section-collapsible .section-header { cursor: pointer; user-select: none; } +.section-collapsible .section-header:hover .section-title { color: var(--text-secondary); } +.section-title-wrap { + display: flex; align-items: center; gap: 6px; min-width: 0; +} +.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); transition: var(--transition); } +.section-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform .2s var(--ease, ease); } +.section-collapsible { display: flex; flex-direction: column; min-height: 0; } +.section-collapsible:not(.collapsed) { flex: 1 1 auto; } +.section-collapsible:not(.collapsed) .section-chevron { transform: rotate(0deg); } +.section-collapsible.collapsed .section-chevron { transform: rotate(-90deg); } +.section-collapsible.collapsed .section-header { margin-bottom: 0; } +.section-collapsible.collapsed .section-title { color: var(--text-muted); } + +.pack-list-collapse { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows .3s var(--ease, ease); + min-height: 0; + overflow: hidden; +} +.section-collapsible:not(.collapsed) .pack-list-collapse { + grid-template-rows: 1fr; + flex: 1; +} +.pack-list-collapse > .pack-list { + overflow-y: auto; + min-height: 0; +} + +.pack-list { + display: flex; flex-direction: column; gap: 3px; +} +.pack-list:empty::after { + content: 'No packs'; display: block; padding: 12px 8px; + font-size: 12px; color: var(--text-muted); text-align: center; +} + +.pack-entry { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: var(--radius-sm); + cursor: pointer; transition: var(--transition); + border: 1px solid transparent; +} +.pack-entry:hover { background: var(--bg-card); } +.pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); } + +.pack-entry-icon { position: relative; } +.pack-entry-icon .update-badge { + position: absolute; top: -3px; right: -3px; width: 10px; height: 10px; + border-radius: 50%; background: #e94560; border: 2px solid var(--bg-main, #0f0f1a); +} +.update-label { + display: inline-block; font-size: 10px; font-weight: 600; color: #e94560; + background: rgba(233,69,96,0.12); padding: 0 5px; border-radius: 3px; + margin-left: 4px; vertical-align: middle; line-height: 16px; +} +.pack-entry-icon { + width: 32px; height: 32px; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.pack-entry-icon.server { background: rgba(251,191,36,0.15); color: var(--warning); } +.pack-entry-icon.local { background: var(--accent-soft); color: var(--accent); } + +.pack-entry-info { flex: 1; min-width: 0; } +.pack-entry-name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pack-entry-meta { font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.btn-icon { + width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; + background: transparent; border: 1px solid transparent; border-radius: var(--radius-sm); + color: var(--text-muted); cursor: pointer; transition: var(--transition); flex-shrink: 0; +} +.btn-icon:hover { color: var(--text); background: var(--bg-card); border-color: var(--border-light); } + +/* Sidebar bottom */ +.sidebar-bottom { padding-top: 12px; border-top: 1px solid var(--border); } +.user-card { + display: flex; align-items: center; gap: 10px; + padding: 8px; border-radius: var(--radius-sm); +} +.user-avatar { + width: 32px; height: 32px; border-radius: 8px; + background: linear-gradient(135deg, var(--accent), #ff6b6b); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 14px; color: #fff; flex-shrink: 0; +} +.user-info { flex: 1; min-width: 0; } +.user-name { font-size: 13px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.user-badges { display: flex; gap: 4px; margin-top: 2px; } +.badge { + font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px; + text-transform: uppercase; letter-spacing: 0.5px; +} +.badge-pro { background: rgba(74,222,128,0.15); color: var(--success); } +.badge-free { background: rgba(248,113,113,0.12); color: var(--error); } +.badge-role { background: rgba(96,165,250,0.15); color: var(--info); } + +/* ========== CONTENT ========== */ +.content { + flex: 1; display: flex; flex-direction: column; + padding: 24px 32px; min-width: 0; + position: relative; + overflow-y: auto; +} + +.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; } +.view.active { display: flex; } + +.view-header { + display: flex; align-items: flex-start; justify-content: space-between; + margin-bottom: 24px; gap: 16px; +} +.view-title { font-size: 22px; font-weight: 700; } +.view-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 4px; } +.view-actions { display: flex; gap: 8px; flex-shrink: 0; } + +.btn-secondary { + display: flex; align-items: center; gap: 6px; + padding: 8px 16px; background: var(--bg-card); border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 13px; + font-weight: 500; cursor: pointer; font-family: var(--font); + transition: var(--transition); +} +.btn-secondary:hover { background: var(--bg-card-hover); color: var(--text); border-color: var(--border); } +.btn-secondary.btn-danger:hover { color: var(--error); border-color: rgba(248,113,113,0.3); background: rgba(248,113,113,0.08); } + +/* ========== PACK DETAIL ========== */ +.pack-detail { flex: 1; display: flex; } +.pack-empty { + flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 12px; color: var(--text-muted); +} +.pack-empty h3 { font-size: 18px; font-weight: 600; color: var(--text-secondary); } +.pack-empty p { font-size: 13px; } + +.pack-detail-content { flex: 1; display: flex; flex-direction: column; gap: 24px; } +.pack-hero { display: flex; align-items: center; gap: 16px; } +.pack-icon { + width: 56px; height: 56px; border-radius: var(--radius-md); + background: var(--bg-card); border: 1px solid var(--border-light); + display: flex; align-items: center; justify-content: center; color: var(--accent); +} +.detail-name { font-size: 20px; font-weight: 700; } +.detail-tags { display: flex; gap: 6px; margin-top: 6px; } +.tag { + font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 4px; +} +.tag-mc { background: var(--bg-card); color: var(--text-secondary); } +.tag-loader { background: rgba(99,102,241,0.15); color: #818cf8; } +.tag-server { background: rgba(251,191,36,0.15); color: var(--warning); } + +.pack-stats { + display: flex; gap: 24px; padding: 16px; + background: var(--bg-card); border-radius: var(--radius-md); + border: 1px solid var(--border); +} +.stat { display: flex; flex-direction: column; gap: 2px; } +.stat-value { font-size: 18px; font-weight: 700; color: var(--text); } +.stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } + +/* ========== PLAY BAR ========== */ +.play-bar { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 20px; margin-top: auto; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-md); +} +.play-bar-info { font-size: 14px; font-weight: 500; color: var(--text-secondary); } + +/* ========== PACK DESCRIPTION ========== */ +.pack-description { + padding: 16px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius-md); +} +.pack-description-text { + font-size: 13px; color: var(--text-secondary); line-height: 1.6; +} +.pack-gallery { + display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap; +} +.pack-gallery-item { + width: 120px; height: 80px; border-radius: var(--radius-sm); + background: var(--bg-elevated); border: 1px solid var(--border-light); + display: flex; align-items: center; justify-content: center; + color: var(--text-muted); font-size: 11px; + overflow: hidden; +} +.pack-gallery-item img { + width: 100%; height: 100%; object-fit: cover; +} +.pack-description-text .news-link { + color: var(--accent); text-decoration: underline; cursor: pointer; +} +.pack-description-text .news-link:hover { + color: var(--accent-hover); +} + +.btn-play { + display: flex; align-items: center; gap: 8px; + padding: 12px 28px; border: none; border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--success), #22c55e); + color: #07070a; font-size: 15px; font-weight: 700; cursor: pointer; + font-family: var(--font); transition: var(--transition); + box-shadow: 0 4px 20px rgba(74,222,128,0.35); +} +.btn-play:hover:not(:disabled) { transform: translateY(-2px) scale(1.02); box-shadow: 0 8px 32px rgba(74,222,128,0.45); } +.btn-play:active:not(:disabled) { transform: translateY(0); } +.btn-play:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; } + +/* ========== CUSTOM SELECT ========== */ +.custom-select-wrap { + position: relative; + width: 100%; +} +.custom-select-trigger { + display: flex; align-items: center; justify-content: space-between; + width: 100%; padding: 10px 12px; + background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text); + font-size: 13px; font-family: var(--font); cursor: pointer; + transition: var(--transition); user-select: none; + gap: 8px; +} +.custom-select-trigger:hover { border-color: var(--text-muted); } +.custom-select-trigger.open { border-color: var(--accent); } +.custom-select-trigger .arrow { + width: 16px; height: 16px; flex-shrink: 0; + transition: transform 0.2s ease; opacity: 0.5; +} +.custom-select-trigger.open .arrow { transform: rotate(180deg); } +.custom-select-trigger .placeholder { color: var(--text-muted); } +.custom-select-dropdown { + position: absolute; top: calc(100% + 4px); left: 0; right: 0; + background: var(--bg-elevated); border: 1px solid var(--border); + border-radius: var(--radius-sm); box-shadow: var(--shadow); + z-index: 100; max-height: 240px; display: none; + flex-direction: column; +} +.custom-select-dropdown.open { display: flex; } +.custom-select-search { + padding: 8px; border-bottom: 1px solid var(--border); + position: sticky; top: 0; background: var(--bg-elevated); + z-index: 1; +} +.custom-select-search input { + width: 100%; padding: 6px 10px; + background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: 4px; color: var(--text); font-size: 12px; + font-family: var(--font); outline: none; +} +.custom-select-search input:focus { border-color: var(--accent); } +.custom-select-options { + overflow-y: auto; flex: 1; +} +.custom-select-option { + padding: 8px 12px; cursor: pointer; font-size: 13px; + color: var(--text-secondary); transition: var(--transition); +} +.custom-select-option:hover { background: var(--bg-card); color: var(--text); } +.custom-select-option.selected { background: var(--accent-soft); color: var(--accent); } +.custom-select-option.hidden { display: none; } + +/* ========== NEWS ========== */ +.news-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; overflow-y: auto; padding-bottom: 24px; +} +.news-loading { + grid-column: 1 / -1; text-align: center; padding: 60px 20px; + color: var(--text-muted); font-size: 14px; +} +.news-empty { + grid-column: 1 / -1; text-align: center; padding: 60px 20px; + color: var(--text-muted); font-size: 14px; +} +.news-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-md); padding: 24px; display: flex; + flex-direction: column; gap: 12px; transition: var(--transition); + cursor: pointer; +} +.news-card:hover { border-color: var(--border-light); transform: translateY(-1px); } +.news-preview { + font-size: 13px; color: var(--text-secondary); line-height: 1.5; + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; + overflow: hidden; +} +.news-card-badge { + align-self: flex-start; font-size: 10px; font-weight: 600; text-transform: uppercase; + letter-spacing: 1px; padding: 4px 10px; border-radius: 4px; +} +.news-placeholder .news-card-badge { background: var(--accent-soft); color: var(--accent); } +.news-card h3 { font-size: 16px; font-weight: 600; } +.news-card p { font-size: 13px; color: var(--text-secondary); line-height: 1.5; } +.news-card time { font-size: 11px; color: var(--text-muted); margin-top: auto; } +.news-card-badge.type-Update { background: rgba(96,165,250,0.15); color: var(--info); } +.news-card-badge.type-Announcement { background: rgba(251,191,36,0.15); color: var(--warning); } +.news-card-badge.type-Event { background: rgba(74,222,128,0.15); color: var(--success); } + +.news-modal-body { + max-height: 60vh; overflow-y: auto; line-height: 1.7; + font-size: 14px; color: var(--text-secondary); +} +.news-modal-body .news-text-line { display: inline; } +.news-modal-body .news-link { + color: var(--info); text-decoration: underline; cursor: pointer; +} +.news-modal-body .news-link:hover { color: var(--accent); } +.news-modal-body .news-photo { + display: block; max-width: 100%; border-radius: var(--radius-sm); + margin: 12px 0; cursor: pointer; border: 1px solid var(--border); + transition: var(--transition); +} +.news-modal-body .news-photo:hover { opacity: 0.9; } +.modal-news { max-width: 640px; } + +/* ========== SETTINGS ========== */ +.settings-grid { display: flex; flex-direction: column; gap: 12px; } +.setting-card { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 20px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-md); gap: 24px; +} +.setting-info h4 { font-size: 14px; font-weight: 600; } +.setting-info p { font-size: 12px; color: var(--text-secondary); margin-top: 2px; } +.setting-control { display: flex; align-items: center; gap: 12px; flex-shrink: 0; } +.setting-control input[type="range"] { + width: 160px; height: 4px; -webkit-appearance: none; appearance: none; + background: var(--border); border-radius: 2px; outline: none; +} +.setting-control input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; + background: var(--accent); cursor: pointer; border: 2px solid var(--bg-deep); +} +.setting-value { font-size: 14px; font-weight: 600; color: var(--text); min-width: 48px; text-align: right; } +.setting-badge { + font-size: 12px; padding: 4px 10px; border-radius: 4px; + background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-light); +} +.setting-pass { display: flex; align-items: center; gap: 8px; } +.setting-network-list { + display: flex; flex-direction: column; gap: 6px; min-width: 260px; flex-shrink: 0; +} +.network-row { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + font-size: 12px; padding: 4px 10px; border-radius: 4px; + background: var(--bg-surface); border: 1px solid var(--border-light); +} +.network-name { + font-family: var(--mono); color: var(--text-secondary); text-transform: uppercase; +} +.network-state { font-weight: 600; } +.pass-input { + width: 160px; padding: 6px 12px; border-radius: var(--radius-sm); + background: var(--bg-inset); border: 1px solid var(--border-light); + color: var(--text); font-size: 13px; outline: none; +} +.pass-input:focus { border-color: var(--accent); } +.setting-input { + padding: 6px 10px; border-radius: var(--radius-sm); + background: var(--bg-inset); border: 1px solid var(--border-light); + color: var(--text); font-size: 13px; outline: none; font-family: var(--mono); +} +.setting-input:focus { border-color: var(--accent); } +.btn-sm { padding: 6px 14px !important; font-size: 12px !important; } + +/* ========== TOGGLE ========== */ +.toggle { + position: relative; display: inline-block; width: 44px; height: 24px; cursor: pointer; +} +.toggle input { opacity: 0; width: 0; height: 0; } +.toggle-slider { + position: absolute; inset: 0; background: var(--border-light); border-radius: 12px; transition: var(--transition); +} +.toggle-slider::before { + content: ''; position: absolute; left: 3px; bottom: 3px; width: 18px; height: 18px; + background: var(--text-secondary); border-radius: 50%; transition: var(--transition); +} +.toggle input:checked + .toggle-slider { background: var(--accent); } +.toggle input:checked + .toggle-slider::before { transform: translateX(20px); background: #fff; } + +/* ========== LOCALE SELECT ========== */ +#locale-select { font-family: var(--font); cursor: pointer; } + +/* ========== MODAL ========== */ +.modal-backdrop { + position: fixed; inset: 0; background: rgba(7,7,10,0.85); + display: flex; align-items: center; justify-content: center; z-index: 50; + animation: fadeIn 0.2s ease; +} +.modal { + background: var(--bg-elevated); border: 1px solid var(--border); + border-radius: var(--radius-lg); width: 90%; max-width: 480px; + max-height: 85vh; box-shadow: var(--shadow); + animation: floatIn 0.3s ease; +} +.modal-head { + display: flex; align-items: center; justify-content: space-between; + padding: 20px 24px; border-bottom: 1px solid var(--border); +} +.modal-head h3 { font-size: 17px; font-weight: 600; } +.modal-close { + width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; + background: transparent; border: none; color: var(--text-muted); + font-size: 22px; cursor: pointer; border-radius: var(--radius-sm); transition: var(--transition); +} +.modal-close:hover { color: var(--text); background: var(--bg-card); } + +.modal-body { padding: 20px 24px 24px; } +.modal-tabs { display: flex; gap: 8px; margin-bottom: 20px; } +.modal-tab { + flex: 1; padding: 10px; background: transparent; border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text-muted); font-size: 13px; + font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition); +} +.modal-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); } +.modal-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); } + +.modal-tab-content { display: none; flex-direction: column; gap: 16px; } +.modal-tab-content.active { display: flex; } +.modal-tab-content .field label { + display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary); + margin-bottom: 6px; position: static; transform: none; + background: none; padding: 0; +} + +.tag-wip { + font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; + background: rgba(251,191,36,0.15); color: var(--warning); vertical-align: middle; margin-left: 4px; +} +.disabled-tab { + display: flex; flex-direction: column; align-items: center; gap: 12px; + padding: 40px 20px; text-align: center; color: var(--text-muted); +} +.disabled-tab h3 { font-size: 18px; font-weight: 600; color: var(--text-secondary); } +.disabled-tab p { font-size: 13px; line-height: 1.5; max-width: 300px; } + +.select-wrap select { + width: 100%; padding: 10px 12px; font-size: 13px; + background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text); + font-family: var(--font); cursor: pointer; outline: none; +} +.select-wrap select:focus { border-color: var(--accent); } + +.install-progress { padding-top: 16px; border-top: 1px solid var(--border); } +.inline-install-progress { padding: 16px 20px; border-top: 1px solid var(--border); background: var(--bg-surface); } +.progress-track { + height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden; +} +.progress-fill { + height: 100%; width: 0%; + background: linear-gradient(90deg, var(--accent), #ff6b6b); + border-radius: 3px; transition: width 0.3s ease; +} +.progress-fill.indeterminate { + width: 30%; + transition: none; + animation: indeterminate 1.3s ease-in-out infinite; +} +@keyframes indeterminate { + 0% { margin-left: -30%; } + 100% { margin-left: 100%; } +} +.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; } +.progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; } + +/* ========== LOG VIEWER ========== */ +.modal-log { max-width: 800px; } +.log-viewer-actions { display: flex; align-items: center; gap: 8px; } +.log-viewer-body { padding: 0 !important; } +.log-viewer-content { + font-family: var(--mono); + font-size: 11px; + line-height: 1.5; + background: var(--bg-deep); + padding: 12px 16px; + max-height: 50vh; + overflow-y: auto; + user-select: text; + -webkit-user-select: text; + white-space: pre-wrap; + word-break: break-all; +} +.log-line { padding: 1px 0; } +.log-empty { color: var(--text-muted); font-family: var(--font); font-size: 13px; padding: 20px; text-align: center; } +.log-error { color: #f87171; } +.log-warn { color: #fbbf24; } +.log-info { color: #4ade80; } +.log-debug { color: #60a5fa; } + +/* ========== TOAST ========== */ +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + padding: 12px 24px; border-radius: var(--radius-sm); + font-size: 13px; font-weight: 500; z-index: 200; + background: var(--bg-elevated); border: 1px solid var(--border); + color: var(--text); box-shadow: var(--shadow); + animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards; +} +.toast.error { border-color: rgba(248,113,113,0.3); color: var(--error); } +.toast.success { border-color: rgba(74,222,128,0.3); color: var(--success); } +.toast.warning { border-color: rgba(251,191,36,0.3); color: var(--warning); } +@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } +@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } } + +/* ========== SCROLLBAR ========== */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* ========== RESPONSIVE ========== */ +@media (max-width: 900px) { + .sidebar { width: 200px; min-width: 200px; } + .content { padding: 16px; } +} +@media (max-width: 700px) { + .sidebar { width: 56px; min-width: 56px; } + .sidebar-brand-text, .sidebar-nav-primary .nav-btn span, .sidebar-nav-secondary .nav-btn span, + .section-header, .pack-entry-info, .user-info, + .sidebar-bottom .user-card .btn-icon:first-child { display: none; } + .sidebar-brand { justify-content: center; padding: 8px; } + .sidebar-nav-primary, .sidebar-nav-secondary { flex-direction: column; } + .nav-btn { padding: 8px; } + .pack-entry { justify-content: center; padding: 8px; } + .content { padding: 12px; } + .play-bar { flex-direction: column; gap: 12px; } + .view-header { flex-direction: column; } +} + +/* ========== FRIENDS ========== */ +.friends-search { + display: flex; align-items: center; gap: 8px; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 12px; +} +.friends-search svg { flex-shrink: 0; color: var(--text-muted); } +.friends-search input { + flex: 1; background: transparent; border: none; outline: none; + color: var(--text); font-size: 13px; font-family: var(--font); +} +.friends-search input::placeholder { color: var(--text-muted); } +.friends-list { display: flex; flex-direction: column; gap: 4px; } +.friends-empty { + text-align: center; padding: 40px 20px; color: var(--text-muted); font-size: 13px; +} +.friends-group-label { + font-size: 11px; font-weight: 600; text-transform: uppercase; + color: var(--text-muted); padding: 8px 4px 4px; letter-spacing: 0.5px; +} +.friend-item { + display: flex; align-items: center; gap: 10px; padding: 8px 10px; + border-radius: var(--radius-sm); transition: var(--transition); +} +.friend-item:hover { background: var(--bg-card-hover); } +.friend-item:hover .friend-remove-btn { opacity: 1; } +.friend-avatar { + width: 36px; height: 36px; border-radius: 50%; display: flex; + align-items: center; justify-content: center; font-weight: 600; font-size: 14px; + flex-shrink: 0; position: relative; + background: var(--accent-soft); color: var(--accent); +} +.friend-avatar.online::after { + content: ''; position: absolute; bottom: 0; right: 0; + width: 10px; height: 10px; border-radius: 50%; + background: var(--success); border: 2px solid var(--bg-surface); +} +.friend-avatar.offline { opacity: 0.6; } +.friend-info { flex: 1; min-width: 0; } +.friend-name-row { display: flex; align-items: center; gap: 6px; } +.friend-name { font-size: 13px; font-weight: 500; color: var(--text); } +.friend-status { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.friend-status-dot { + width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; +} +.friend-status-dot.online { background: var(--success); } +.friend-status-dot.offline { background: var(--text-muted); } +.friend-pack { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.friend-remove-btn { + opacity: 0; transition: var(--transition); flex-shrink: 0; + width: 28px; height: 28px; color: var(--text-muted); +} +.friend-remove-btn:hover { color: var(--error); } +.friend-requests-section { + margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); +} +.friend-requests-section .section-header { + font-size: 11px; font-weight: 600; text-transform: uppercase; + color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px; +} +.friend-request-item { + display: flex; align-items: center; gap: 10px; padding: 10px; + background: var(--bg-card); border-radius: var(--radius-sm); + margin-bottom: 6px; +} +.friend-request-avatar { + width: 36px; height: 36px; border-radius: 50%; display: flex; + align-items: center; justify-content: center; font-weight: 600; font-size: 14px; + flex-shrink: 0; background: var(--accent-soft); color: var(--accent); +} +.friend-request-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; } +.friend-request-name { font-size: 13px; font-weight: 500; } +.friend-request-text { font-size: 11px; color: var(--text-muted); } +.friend-request-actions { display: flex; gap: 6px; flex-shrink: 0; } + +/* ========== WHITELIST MODS ========== */ +.whitelist-mods-section { + margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); +} +.whitelist-mods-section .section-header { + font-size: 11px; font-weight: 600; text-transform: uppercase; + color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px; +} +.whitelist-mods-list { display: flex; flex-direction: column; gap: 4px; } +.whitelist-loading { + text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px; +} +.whitelist-mod-item { + display: flex; align-items: center; gap: 10px; padding: 8px 10px; + border-radius: var(--radius-sm); transition: var(--transition); + background: var(--bg-card); +} +.whitelist-mod-item:hover { background: var(--bg-card-hover); } +.whitelist-mod-checkbox { + width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent); +} +.whitelist-mod-info { flex: 1; min-width: 0; } +.whitelist-mod-name { font-size: 13px; font-weight: 500; color: var(--text); } +.whitelist-mod-size { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.whitelist-mod-status { font-size: 11px; color: var(--success); } +.whitelist-install-btn { + width: 100%; padding: 10px; margin-top: 8px; + background: var(--accent-soft); border: 1px solid rgba(233,69,96,0.3); + border-radius: var(--radius-sm); color: var(--accent); font-size: 13px; + font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition); +} +.whitelist-install-btn:hover { background: var(--accent); color: #fff; } +.whitelist-install-btn:disabled { opacity: 0.4; cursor: default; } + +/* ========== MODAL SM ========== */ +.modal-sm { max-width: 360px; } + +/* ========== BADGE SM ========== */ +.badge-sm { font-size: 10px; padding: 2px 6px; } + +/* ========== ADMIN PANEL ========== */ +.admin-tabs { + display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; +} +.admin-tab { + padding: 8px 14px; background: transparent; border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12px; + font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition); +} +.admin-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); } +.admin-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); } +.admin-tab-content { display: none; flex-direction: column; gap: 12px; } +.admin-tab-content.active { display: flex; } +.admin-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-md); padding: 16px; +} +.admin-card-header { + font-size: 13px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; + border-bottom: 1px solid var(--border); color: var(--text); +} +.admin-list { display: flex; flex-direction: column; gap: 4px; } +.admin-loading, .admin-empty { + text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px; +} +.admin-row { + display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; +} +.admin-input { + flex: 1; padding: 8px 12px; background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text); font-size: 13px; outline: none; font-family: var(--font); +} +.admin-input:focus { border-color: var(--accent); } +.admin-select { + padding: 6px 10px; background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text); font-size: 12px; outline: none; cursor: pointer; font-family: var(--font); +} +.admin-textarea { + width: 100%; min-height: 100px; padding: 10px; background: var(--bg-surface); + border: 1px solid var(--border-light); border-radius: var(--radius-sm); color: var(--text); + font-size: 13px; outline: none; font-family: var(--font); resize: vertical; +} +.admin-textarea:focus { border-color: var(--accent); } +.admin-file-input { + font-size: 12px; color: var(--text-secondary); font-family: var(--font); +} +.admin-status { + font-size: 12px; padding: 8px 12px; border-radius: var(--radius-sm); margin-top: 8px; +} +.admin-status.success { background: rgba(74,222,128,0.1); color: var(--success); } +.admin-status.error { background: rgba(248,113,113,0.1); color: var(--error); } +.admin-item { + display: flex; align-items: center; gap: 10px; padding: 8px 10px; + border-radius: var(--radius-sm); transition: var(--transition); font-size: 13px; +} +.admin-item:hover { background: var(--bg-card-hover); } +.admin-item-name { flex: 1; color: var(--text); } +.admin-item-meta { font-size: 11px; color: var(--text-muted); } +.admin-item-actions { display: flex; gap: 6px; flex-shrink: 0; } +.admin-form { display: flex; flex-direction: column; gap: 8px; } +.admin-btn-danger { color: var(--error) !important; } +.admin-btn-danger:hover { background: rgba(248,113,113,0.1) !important; border-color: rgba(248,113,113,0.3) !important; } +.admin-news-item { + display: flex; align-items: flex-start; gap: 10px; padding: 10px; + border-radius: var(--radius-sm); transition: var(--transition); border: 1px solid var(--border); + margin-bottom: 6px; +} +.admin-news-item:hover { background: var(--bg-card-hover); } +.admin-news-info { flex: 1; min-width: 0; } +.admin-news-title { font-size: 13px; font-weight: 500; } +.admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } + +.pack-entry.disabled { opacity: 0.5; pointer-events: none; } +.pack-entry.disabled .pack-entry-name::after { content: ' (' attr(data-disabled-text) ')'; color: var(--error); font-size: 11px; } +.admin-pack-disabled { color: var(--error) !important; font-weight: 600; } +.admin-pack-enabled { color: var(--success) !important; font-weight: 600; } + +/* ========== OFFLINE MODE ========== */ +.offline-container { + position: relative; z-index: 1; + background: var(--bg-elevated); + border-radius: var(--radius-md); + padding: 32px 32px 24px; + width: 100%; + max-width: 380px; + box-shadow: 0 8px 40px rgba(0,0,0,0.5); + text-align: center; +} +.offline-badge { + width: 64px; height: 64px; + margin: 0 auto 14px; + display: flex; align-items: center; justify-content: center; + background: rgba(233,69,96,0.12); + border: 1px solid rgba(233,69,96,0.3); + border-radius: 14px; + color: var(--accent); +} +.offline-title { font-size: 22px; font-weight: 700; color: var(--text); } +.offline-subtitle { + color: var(--text-muted); font-size: 13px; margin: 8px 0 20px; line-height: 1.5; +} +.offline-container .login-form { text-align: left; } +.offline-footer { + display: flex; gap: 8px; justify-content: center; margin-top: 18px; +} +.btn-ghost { + padding: 9px 14px; font-size: 13px; + background: transparent; color: var(--text-secondary); + border: 1px solid var(--border); border-radius: 4px; + cursor: pointer; font-family: var(--font); + transition: border-color 150ms ease, color 150ms ease; +} +.btn-ghost:hover { border-color: var(--accent); color: var(--accent); } diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerParsingTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerParsingTest.java new file mode 100644 index 0000000..c585785 --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerParsingTest.java @@ -0,0 +1,90 @@ +package me.sashegdev.zernmc.launcher.auth; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for AuthManager error extraction and response parsing. + * Tests the contract between server error responses and Java client parsing. + */ +class AuthManagerParsingTest { + + @Test + void extractError_simpleStringDetail() { + // Server: raise HTTPException(401, "Неверное имя пользователя или пароль") + String body = "{\"detail\":\"Неверное имя пользователя или пароль\"}"; + String error = extractError(body); + assertEquals("Неверное имя пользователя или пароль", error); + } + + @Test + void extractError_validationErrorArray() { + // FastAPI 422: {"detail": [{"loc": ["body", "username"], "msg": "...", "type": "..."}]} + String body = "{" + + "\"detail\":[" + + "{\"loc\":[\"body\",\"username\"],\"msg\":\"String should have at least 3 characters\",\"type\":\"string_too_short\"}" + + "]" + + "}"; + String error = extractError(body); + assertEquals("String should have at least 3 characters", error); + } + + @Test + void extractError_multipleValidationErrors_returnsFirst() { + String body = "{" + + "\"detail\":[" + + "{\"loc\":[\"body\",\"username\"],\"msg\":\"Username error\",\"type\":\"value_error\"}," + + "{\"loc\":[\"body\",\"password\"],\"msg\":\"Password error\",\"type\":\"value_error\"}" + + "]" + + "}"; + String error = extractError(body); + assertEquals("Username error", error); + } + + @Test + void extractError_plainTextBody() { + // Non-JSON error body + String body = "Internal Server Error"; + String error = extractError(body); + assertEquals("Internal Server Error", error); + } + + @Test + void extractError_longBody_truncated() { + String longBody = "A".repeat(300); + String error = extractError(longBody); + assertEquals(203, error.length()); // 200 + "..." + assertTrue(error.endsWith("...")); + } + + @Test + void extractError_emptyDetail() { + String body = "{\"detail\":\"\"}"; + String error = extractError(body); + assertEquals("", error); + } + + @Test + void extractError_noDetailField_returnsBody() { + String body = "{\"error\":\"something went wrong\"}"; + String error = extractError(body); + assertEquals("{\"error\":\"something went wrong\"}", error); + } + + /** + * Replicates AuthManager.extractError() logic for testing. + * If this passes, the real method in AuthManager works correctly. + */ + private static String extractError(String body) { + try { + com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(body).getAsJsonObject(); + if (json.has("detail")) { + if (json.get("detail").isJsonArray()) { + return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString(); + } + return json.get("detail").getAsString(); + } + } catch (Exception ignored) {} + return body.length() > 200 ? body.substring(0, 200) + "..." : body; + } +} diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerPassTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerPassTest.java new file mode 100644 index 0000000..f604fe2 --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerPassTest.java @@ -0,0 +1,274 @@ +package me.sashegdev.zernmc.launcher.auth; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +class AuthManagerPassTest { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private static AuthManager.AuthSession createSession(String token, int role) { + AuthManager.AuthSession s = new AuthManager.AuthSession(); + s.accessToken = token; + s.role = role; + s.expiresAt = System.currentTimeMillis() / 1000L + 3600; + return s; + } + + @Test + void hasPass_returnsFalse_whenNotLoggedIn() { + AuthManager.resetForTest(); + assertFalse(AuthManager.hasPass()); + assertFalse(AuthManager.hasActivePass()); + } + + @Test + void hasPass_usesUserInfo_whenAvailable() { + AuthManager.UserInfo info = new AuthManager.UserInfo(); + info.has_pass = true; + info.role = 1; + AuthManager.setTestUserInfo(info); + + AuthManager.setTestSession(createSession("tok", 1)); + + assertTrue(AuthManager.hasPass()); + assertTrue(AuthManager.hasActivePass()); + } + + @Test + void hasPass_usesRole_whenUserInfoNull() { + AuthManager.setTestUserInfo(null); + + AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER)); + + assertTrue(AuthManager.hasPass()); + assertTrue(AuthManager.hasActivePass()); + } + + @Test + void hasPass_returnsFalse_whenRoleTooLow() { + AuthManager.setTestUserInfo(null); + + AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_USER)); + + assertFalse(AuthManager.hasPass()); + assertFalse(AuthManager.hasActivePass()); + } + + @Test + void hasPass_userInfoTakesPriorityOverRole() { + AuthManager.UserInfo info = new AuthManager.UserInfo(); + info.has_pass = false; + info.role = 1; + AuthManager.setTestUserInfo(info); + + AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER)); + + assertFalse(AuthManager.hasPass()); + assertFalse(AuthManager.hasActivePass()); + } + + @Test + void canViewPacks_usesPermissions_whenAvailable() { + AuthManager.UserInfo info = new AuthManager.UserInfo(); + info.permissions = List.of("view_packs", "download_pack"); + info.has_pass = true; + AuthManager.setTestUserInfo(info); + + AuthManager.setTestSession(createSession("tok", 1)); + + assertTrue(AuthManager.canViewPacks()); + assertTrue(AuthManager.canDownloadPacks()); + } + + @Test + void canViewPacks_fallsBackToHasPass_whenNoPermissions() { + AuthManager.setTestUserInfo(null); + + AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER)); + + assertTrue(AuthManager.canViewPacks()); + assertTrue(AuthManager.canDownloadPacks()); + } + + @Test + void authSession_parsesFromLoginResponse() { + String json = """ + { + "access_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test", + "refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh", + "expires_in": 86400, + "token_type": "bearer", + "username": "testuser", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "role": 1, + "role_name": "PASS_HOLDER" + } + """; + AuthManager.AuthSession session = GSON.fromJson(json, AuthManager.AuthSession.class); + + assertNotNull(session); + assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test", session.accessToken); + assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh", session.refreshToken); + assertEquals(86400, session.expiresIn); + assertEquals("testuser", session.username); + assertEquals("550e8400-e29b-41d4-a716-446655440000", session.uuid); + assertEquals(1, session.role); + } + + @Test + void authSession_roundTrip() { + AuthManager.AuthSession original = new AuthManager.AuthSession(); + original.accessToken = "access123"; + original.refreshToken = "refresh123"; + original.expiresIn = 86400; + original.expiresAt = System.currentTimeMillis() / 1000L + 86400; + original.username = "testuser"; + original.uuid = "550e8400-e29b-41d4-a716-446655440000"; + original.role = 1; + + String json = GSON.toJson(original); + AuthManager.AuthSession parsed = GSON.fromJson(json, AuthManager.AuthSession.class); + + assertEquals(original.accessToken, parsed.accessToken); + assertEquals(original.refreshToken, parsed.refreshToken); + assertEquals(original.expiresIn, parsed.expiresIn); + assertEquals(original.expiresAt, parsed.expiresAt); + assertEquals(original.username, parsed.username); + assertEquals(original.uuid, parsed.uuid); + assertEquals(original.role, parsed.role); + } + + @Test + void userInfo_parsesFromMeEndpoint() { + String json = """ + { + "id": 1, + "username": "testuser", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "role": 1, + "role_name": "PASS_HOLDER", + "has_pass": true, + "permissions": ["view_packs", "download_pack"] + } + """; + AuthManager.UserInfo info = GSON.fromJson(json, AuthManager.UserInfo.class); + + assertNotNull(info); + assertEquals(1, info.id); + assertEquals("testuser", info.username); + assertEquals(1, info.role); + assertEquals("PASS_HOLDER", info.role_name); + assertTrue(info.has_pass); + assertTrue(info.permissions.contains("view_packs")); + assertTrue(info.permissions.contains("download_pack")); + assertTrue(info.hasPermission("view_packs")); + assertFalse(info.hasPermission("admin")); + } + + @Test + void updateRole_updatesSessionRole() { + AuthManager.resetForTest(); + AuthManager.setTestSession(createSession("tok", 0)); + AuthManager.setTestUserInfo(null); + + assertEquals(0, AuthManager.getRole()); + assertFalse(AuthManager.hasPass()); + + AuthManager.updateRole(1); + + assertEquals(1, AuthManager.getRole()); + } + + @Test + void isLoggedIn_returnsTrue_whenSessionExists() { + AuthManager.resetForTest(); + assertFalse(AuthManager.isLoggedIn()); + + AuthManager.AuthSession s = createSession("tok", 0); + s.username = "testuser"; + AuthManager.setTestSession(s); + + assertTrue(AuthManager.isLoggedIn()); + assertEquals("testuser", AuthManager.getUsername()); + } + + @Test + void getUsername_returnsSessionUsername() { + AuthManager.AuthSession s = createSession("tok", 0); + s.username = "testuser"; + AuthManager.setTestSession(s); + + assertEquals("testuser", AuthManager.getUsername()); + } + + @Test + void getRole_returnsZero_whenSessionNull() { + AuthManager.resetForTest(); + assertEquals(0, AuthManager.getRole()); + } + + @Test + void getRoleName_fallsBackToUSER_whenUserInfoNull() { + AuthManager.resetForTest(); + AuthManager.setTestUserInfo(null); + AuthManager.setTestSession(createSession("tok", 0)); + + assertEquals("USER", AuthManager.getRoleName()); + } + + @Test + void getAccessToken_returnsToken_whenSessionValid() { + AuthManager.resetForTest(); + AuthManager.setTestUserInfo(null); + AuthManager.setTestSession(createSession("valid-token", 1)); + + String token = AuthManager.getAccessToken(); + assertEquals("valid-token", token); + } + + @Test + void getAccessToken_doesNotInvalidate_whenNoRefreshToken() { + AuthManager.resetForTest(); + AuthManager.setTestUserInfo(null); + AuthManager.AuthSession s = createSession("tok", 1); + s.refreshToken = null; + AuthManager.setTestSession(s); + + String token = AuthManager.getAccessToken(); + assertEquals("tok", token); + assertTrue(AuthManager.isLoggedIn()); + } + + @Test + void getAccessToken_returnsZero_whenSessionNull() { + AuthManager.resetForTest(); + assertEquals("0", AuthManager.getAccessToken()); + } + + @Test + void invalidateSession_clearsState() { + AuthManager.resetForTest(); + AuthManager.setTestSession(createSession("tok", 1)); + AuthManager.setTestUserInfo(new AuthManager.UserInfo()); + assertTrue(AuthManager.isLoggedIn()); + + AuthManager.logout(); + + assertFalse(AuthManager.isLoggedIn()); + assertEquals(0, AuthManager.getRole()); + } + + @Test + void loadSavedSession_returnsFalse_whenNoAuthFile() { + AuthManager.resetForTest(); + AuthManager.logout(); + assertFalse(AuthManager.loadSavedSession()); + } +} diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ForgeInstallIntegrationTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ForgeInstallIntegrationTest.java new file mode 100644 index 0000000..22c101b --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ForgeInstallIntegrationTest.java @@ -0,0 +1,252 @@ +package me.sashegdev.zernmc.launcher.integration; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.installer.ModLoaderInstaller; +import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; +import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; +import org.junit.jupiter.api.*; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.URL; +import java.net.HttpURLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +@Tag("integration") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ForgeInstallIntegrationTest { + + private static final String MC_VERSION = "1.20.1"; + private static final String FORGE_VERSION = "47.4.22"; + + private static Path instanceDir; + private static Instance instance; + private static boolean installSuccess = false; + + @BeforeAll + static void setup() throws Exception { + // Check network + assumeTrue(networkReachable("https://maven.minecraftforge.net"), + "Forge maven not reachable, skipping"); + assumeTrue(networkReachable("https://piston-meta.mojang.com"), + "Mojang meta not reachable, skipping"); + + instanceDir = Files.createTempDirectory("zernmc-forge-test-"); + instance = new Instance("forge-test", instanceDir); + instance.setMinecraftVersion(MC_VERSION); + instance.setLoaderType("forge"); + instance.setLoaderVersion(FORGE_VERSION); + instance.setAssetIndex(MC_VERSION); + } + + @AfterAll + static void cleanup() throws Exception { + if (instanceDir != null) { + try (var stream = Files.walk(instanceDir)) { + stream.sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { + try { Files.deleteIfExists(p); } catch (Exception ignored) {} + }); + } + } + } + + @Test + @Order(1) + @DisplayName("Install Minecraft vanilla 1.20.1") + void installMinecraft() throws Exception { + System.out.println("\n=== Installing Minecraft " + MC_VERSION + " ==="); + VersionInstaller versionInstaller = new VersionInstaller(instanceDir); + String assetIndex = versionInstaller.install(MC_VERSION); + assertNotNull(assetIndex, "assetIndex must not be null after install"); + instance.setAssetIndex(assetIndex); + + // Verify version.json exists + Path versionJson = instanceDir.resolve("versions/" + MC_VERSION + "/" + MC_VERSION + ".json"); + assertTrue(Files.exists(versionJson), "version.json must exist after vanilla install"); + + // Verify client jar exists + Path clientJar = instanceDir.resolve("versions/" + MC_VERSION + "/" + MC_VERSION + ".jar"); + assertTrue(Files.exists(clientJar), "client jar must exist after vanilla install"); + + // Verify libraries exist + Path libsDir = instanceDir.resolve("libraries"); + assertTrue(Files.exists(libsDir) && Files.list(libsDir).findAny().isPresent(), + "At least one library must exist after vanilla install"); + + System.out.println("=== Minecraft install OK ===\n"); + } + + @Test + @Order(2) + @DisplayName("Install Forge 1.20.1-47.4.22") + void installForge() throws Exception { + System.out.println("\n=== Installing Forge " + MC_VERSION + "-" + FORGE_VERSION + " ==="); + ModLoaderInstaller installer = new ModLoaderInstaller(instance); + boolean success = installer.install(MC_VERSION, FORGE_VERSION, ModLoaderInstaller.LoaderType.FORGE); + assertTrue(success, "Forge installation must succeed"); + installSuccess = true; + + // Verify Forge version.json exists + String forgeVersionId = MC_VERSION + "-forge-" + FORGE_VERSION; + Path forgeJson = instanceDir.resolve("versions/" + forgeVersionId + "/" + forgeVersionId + ".json"); + assertTrue(Files.exists(forgeJson), "Forge version.json must exist at " + forgeJson); + System.out.println(" Forge version.json size: " + Files.size(forgeJson) + " bytes\n"); + } + + @Test + @Order(3) + @DisplayName("Build launch command and verify structure") + void buildAndVerifyCommand() throws Exception { + assumeTrue(installSuccess, "Skipping: Forge install did not succeed"); + System.out.println("\n=== Building launch command ==="); + + LaunchOptions options = new LaunchOptions(); + options.setUsername("TestPlayer"); + options.setUuid("00000000-0000-0000-0000-000000000000"); + options.setAccessToken("test-token"); + options.setMaxMemory(4096); + options.setWidth(854); + options.setHeight(480); + + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + List command = builder.build(options); + + // === JVM args checks === + // Real Forge version.json (1.20.1-47.x) has no -cp, so the launcher adds it + assertTrue(command.contains("-cp"), + "Launcher must add -cp for Forge (version.json has none)"); + int cpIdx = command.indexOf("-cp"); + assertTrue(cpIdx + 1 < command.size(), "-cp must have a value"); + String classpath = command.get(cpIdx + 1); + assertNotNull(classpath, "-cp value must not be null"); + assertFalse(classpath.contains("${classpath}"), "-cp must not contain unresolved ${classpath} placeholder"); + + // Find -Djava.library.path (added manually, not from forge child) + String libPath = null; + for (String arg : command) { + if (arg != null && arg.startsWith("-Djava.library.path=")) { + libPath = arg; + break; + } + } + assertNotNull(libPath, "Must have -Djava.library.path"); + assertTrue(libPath.contains("natives"), "-Djava.library.path must point to natives dir"); + + // Must have -p (module path) from forge child + int pIdx = command.indexOf("-p"); + assertTrue(pIdx >= 0, "Must have -p"); + String modulePath = command.get(pIdx + 1); + assertNotNull(modulePath, "-p must have a value"); + assertTrue(modulePath.contains("bootstraplauncher"), "-p must include bootstraplauncher"); + + // Must have --add-modules + ALL-MODULE-PATH as two args (forge format) + int amIdx = command.indexOf("--add-modules"); + assertTrue(amIdx >= 0, "Must have --add-modules"); + assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1), + "--add-modules must be followed by ALL-MODULE-PATH"); + + // Must have --add-opens (at least one) from forge child + boolean hasAddOpens = command.stream().anyMatch(a -> a != null && a.startsWith("--add-opens")); + assertTrue(hasAddOpens, "Must have at least one --add-opens"); + + // Main class + assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"), + "Main class must be BootstrapLauncher"); + + // Game args: parent (--username) + child (--launchTarget) + assertTrue(command.contains("--username"), "Game args must include --username from parent"); + assertTrue(command.contains("--launchTarget"), "Game args must include --launchTarget from child"); + + // Memory/GC args + assertTrue(command.contains("-Xmx4096M"), "Must have -Xmx"); + assertTrue(command.contains("-XX:+UseG1GC"), "Must have GC flags"); + + System.out.println("=== Command structure OK (" + command.size() + " args) ===\n"); + } + + @Test + @Order(4) + @DisplayName("Module path is valid (headless smoke test)") + void modulePathSmokeTest() throws Exception { + assumeTrue(installSuccess, "Skipping: Forge install did not succeed"); + System.out.println("\n=== Module path smoke test ==="); + + LaunchOptions options = new LaunchOptions(); + options.setUsername("TestPlayer"); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + List command = builder.build(options); + + // Extract key args + String libPath = null; + String modulePath = null; + + for (int i = 0; i < command.size(); i++) { + String arg = command.get(i); + if (arg != null) { + if (arg.startsWith("-Djava.library.path=")) libPath = arg; + } + } + int pIdx = command.indexOf("-p"); + if (pIdx >= 0) modulePath = command.get(pIdx + 1); + + // Find --add-modules + ALL-MODULE-PATH as two args + int amIdx = command.indexOf("--add-modules"); + String addModules = null; + if (amIdx >= 0 && amIdx + 1 < command.size()) { + addModules = command.get(amIdx + 1); + } + + assertNotNull(libPath, "-Djava.library.path must exist"); + assertNotNull(modulePath, "-p must exist"); + assertNotNull(addModules, "--add-modules must exist"); + assertEquals("ALL-MODULE-PATH", addModules, "--add-modules must be ALL-MODULE-PATH"); + + // Run: java -Djava.library.path= -p --add-modules ALL-MODULE-PATH -version + ProcessBuilder pb = new ProcessBuilder( + "java", + libPath, + "-p", modulePath, + "--add-modules", addModules, + "-version" + ); + pb.redirectErrorStream(true); + + long start = System.currentTimeMillis(); + Process proc = pb.start(); + String output = new String(proc.getInputStream().readAllBytes()); + int exit = proc.waitFor(); + long elapsed = System.currentTimeMillis() - start; + + System.out.println(" JVM accepted module path in " + elapsed + "ms"); + System.out.println(" Output: " + output.trim().replace('\n', ' ')); + assertEquals(0, exit, + "JVM must accept module path without errors.\nExit: " + exit + "\nOutput: " + output); + System.out.println("=== Module path smoke test OK ===\n"); + } + + // ================================================================ + // Helper + // ================================================================ + + private static boolean networkReachable(String urlStr) { + try { + URL url = new URL(urlStr); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(5000); + conn.setRequestMethod("GET"); + int code = conn.getResponseCode(); + conn.disconnect(); + return true; + } catch (Exception e) { + System.out.println(" Network check failed for " + urlStr + ": " + e.getMessage()); + return false; + } + } +} diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ServerIntegrationTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ServerIntegrationTest.java new file mode 100644 index 0000000..d5a0890 --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ServerIntegrationTest.java @@ -0,0 +1,469 @@ +package me.sashegdev.zernmc.launcher.integration; + +import org.junit.jupiter.api.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +/** + * Integration tests: real Java client ↔ real Python server. + * + * These tests: + * 1. Start the FastAPI test server via Python subprocess + * 2. Use actual Java HTTP client code to make requests + * 3. Verify JSON parsing and response handling + * + * Requires: Python 3, pytest, and the server/.venv to be available. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ServerIntegrationTest { + + private static Process serverProcess; + private static String serverBaseUrl; + private static Path testDir; + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + @BeforeAll + static void startTestServer() throws Exception { + // Create temp directory for test data + testDir = Files.createTempDirectory("zern_integration_test_"); + + // Find the server directory + String serverDir = findServerDir(); + if (serverDir == null) { + System.out.println("WARNING: Server directory not found, skipping integration tests"); + serverBaseUrl = null; + return; + } + + // Start the test server on a random port + int port = findFreePort(); + serverBaseUrl = "http://127.0.0.1:" + port; + + System.out.println("Starting test server on " + serverBaseUrl); + System.out.println("Server directory: " + serverDir); + + // Find Python executable (prefer venv python) + String pythonPath = findPythonPath(serverDir); + if (pythonPath == null) { + System.out.println("WARNING: Python not found, skipping integration tests"); + serverBaseUrl = null; + return; + } + + // Create a Python startup script that properly sets up paths + String startupScript = + "import sys, os, tempfile\n" + + "from pathlib import Path\n" + + "sys.path.insert(0, '" + serverDir + "')\n" + + "os.chdir('" + serverDir + "')\n" + + "import auth\n" + + "db_dir = tempfile.mkdtemp()\n" + + "auth.AUTH_DB = Path(db_dir) / 'auth.db'\n" + + "auth.SECRET_KEY = Path(db_dir) / '.secret_key'\n" + + "auth.init_db()\n" + + "import uvicorn\n" + + "import main\n" + + "uvicorn.run(main.app, host='127.0.0.1', port=" + port + ", log_level='error')\n"; + + ProcessBuilder pb = new ProcessBuilder(pythonPath, "-c", startupScript); + pb.directory(new File(serverDir)); + pb.redirectErrorStream(true); + + try { + serverProcess = pb.start(); + System.out.println("Server process started, PID: " + serverProcess.pid()); + } catch (IOException e) { + System.out.println("WARNING: Could not start server process: " + e.getMessage()); + System.out.println("Skipping integration tests"); + serverBaseUrl = null; + return; + } + + // Wait for server to start + Thread.sleep(4000); + + // Verify server is running + try { + URL url = new URL(serverBaseUrl + "/health"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(5000); + conn.connect(); + if (conn.getResponseCode() != 200) { + System.out.println("WARNING: Server health check failed: " + conn.getResponseCode()); + System.out.println("Skipping integration tests"); + serverBaseUrl = null; + if (serverProcess != null) serverProcess.destroy(); + conn.disconnect(); + return; + } + conn.disconnect(); + System.out.println("Test server started successfully"); + } catch (Exception e) { + System.out.println("WARNING: Server failed to start: " + e.getMessage()); + System.out.println("Skipping integration tests"); + serverBaseUrl = null; + if (serverProcess != null) serverProcess.destroy(); + } + } + + @AfterAll + static void stopTestServer() { + if (serverProcess != null) { + serverProcess.destroy(); + try { + serverProcess.waitFor(5000, java.util.concurrent.TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) {} + } + // Cleanup temp dir + if (testDir != null) { + try { + Files.walk(testDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(path -> { + try { Files.delete(path); } catch (IOException ignored) {} + }); + } catch (IOException ignored) {} + } + } + + @BeforeEach + void setUp() { + if (serverBaseUrl != null) { + ZHttpClient.setBaseUrl(serverBaseUrl); + } + } + + // ===== Auth flow tests ===== + + @Test + @Order(1) + void testRegister() throws Exception { + assumeServerRunning(); + + String response = httpPost("/auth/register", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertTrue(json.has("access_token")); + assertTrue(json.has("refresh_token")); + assertTrue(json.has("expires_in")); + assertTrue(json.has("uuid")); + assertEquals("integration_test_user", json.get("username").getAsString()); + assertTrue(json.has("role")); + } + + @Test + @Order(2) + void testLogin() throws Exception { + assumeServerRunning(); + + String response = httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertTrue(json.has("access_token")); + assertTrue(json.has("refresh_token")); + assertEquals("integration_test_user", json.get("username").getAsString()); + assertTrue(json.has("role")); + assertTrue(json.has("uuid")); + } + + @Test + @Order(3) + void testDuplicateRegistration() throws Exception { + assumeServerRunning(); + + try { + httpPost("/auth/register", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"AnotherPassword123\"" + + "}"); + fail("Should have thrown IOException for duplicate registration"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("409") || e.getMessage().contains("409"), + "Expected 409 conflict, got: " + e.getMessage()); + } + } + + @Test + @Order(4) + void testLoginWrongPassword() throws Exception { + assumeServerRunning(); + + try { + httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"WrongPassword\"" + + "}"); + fail("Should have thrown IOException for wrong password"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("401"), + "Expected 401, got: " + e.getMessage()); + } + } + + @Test + @Order(5) + void testGetAdminMe() throws Exception { + assumeServerRunning(); + + // Login to get token + String loginResp = httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject(); + String token = loginJson.get("access_token").getAsString(); + + // Get user info + String response = httpGet("/admin/me", token); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + + assertTrue(json.has("id")); + assertEquals("integration_test_user", json.get("username").getAsString()); + assertTrue(json.has("uuid")); + assertTrue(json.has("role")); + assertTrue(json.has("role_name")); + assertTrue(json.has("has_pass")); + assertTrue(json.has("permissions")); + } + + @Test + @Order(6) + void testValidateToken() throws Exception { + assumeServerRunning(); + + String loginResp = httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject(); + String token = loginJson.get("access_token").getAsString(); + String uuid = loginJson.get("uuid").getAsString(); + + // Validate + String response = httpPost("/auth/validate", + "{\"username\":\"integration_test_user\",\"uuid\":\"" + uuid + "\"}", + token); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + + assertTrue(json.has("valid")); + assertTrue(json.get("valid").getAsBoolean()); + assertEquals("integration_test_user", json.get("username").getAsString()); + } + + @Test + @Order(7) + void testRefreshToken() throws Exception { + assumeServerRunning(); + + String loginResp = httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject(); + String refreshToken = loginJson.get("refresh_token").getAsString(); + + // Refresh + String response = httpPost("/auth/refresh", + "{\"refresh_token\":\"" + refreshToken + "\"}"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + + assertTrue(json.has("access_token")); + assertTrue(json.has("refresh_token")); + assertTrue(json.has("expires_in")); + assertEquals("integration_test_user", json.get("username").getAsString()); + } + + // ===== Pack endpoint tests ===== + + @Test + @Order(8) + void testPacksNoAuth() throws Exception { + assumeServerRunning(); + + try { + httpGet("/packs"); + fail("Should have thrown IOException for unauthenticated access"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("401") || e.getMessage().contains("403")); + } + } + + @Test + @Order(9) + void testPackManifestPublic() throws Exception { + assumeServerRunning(); + + // /pack/{name} is public + try { + String response = httpGet("/pack/nonexistent-pack"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + fail("Should have thrown IOException for non-existent pack"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("404"), + "Expected 404, got: " + e.getMessage()); + } + } + + @Test + @Order(10) + void testLauncherVersion() throws Exception { + assumeServerRunning(); + + String response = httpGet("/launcher/version"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertTrue(json.has("version") || json.has("latest")); + } + + // ===== Helper methods ===== + + private static void assumeServerRunning() { + org.junit.jupiter.api.Assumptions.assumeTrue( + serverBaseUrl != null && serverProcess != null && serverProcess.isAlive(), + "Test server is not running" + ); + } + + private static String httpPost(String endpoint, String body) throws IOException { + return httpPost(endpoint, body, null); + } + + private static String httpPost(String endpoint, String body, String token) throws IOException { + URL url = new URL(serverBaseUrl + endpoint); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "application/json"); + if (token != null) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setDoOutput(true); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + byte[] input = body.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(input.length); + try (var os = conn.getOutputStream()) { + os.write(input); + } + + int code = conn.getResponseCode(); + String response = readResponse(conn, code); + + if (code >= 400) { + throw new IOException("HTTP " + code + ": " + response); + } + + conn.disconnect(); + return response; + } + + private static String httpGet(String endpoint) throws IOException { + return httpGet(endpoint, null); + } + + private static String httpGet(String endpoint, String token) throws IOException { + URL url = new URL(serverBaseUrl + endpoint); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + if (token != null) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + int code = conn.getResponseCode(); + String response = readResponse(conn, code); + + if (code >= 400) { + throw new IOException("HTTP " + code + ": " + response); + } + + conn.disconnect(); + return response; + } + + private static String readResponse(HttpURLConnection conn, int code) throws IOException { + var is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream(); + if (is == null) { + return ""; + } + try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) { + return scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; + } + } + + private static String findPythonPath(String serverDir) { + String[] paths = { + serverDir + "/.venv/bin/python3", + serverDir + "/.venv/bin/python", + "python3", + "python" + }; + for (String path : paths) { + File f = new File(path); + if (f.exists() && f.canExecute()) { + return path; + } + // Try which command + try { + Process p = new ProcessBuilder(path, "--version").start(); + int exit = p.waitFor(); + if (exit == 0) return path; + } catch (Exception ignored) {} + } + return null; + } + + private static String findServerDir() { + String[] paths = { + "../server", + "server", + System.getenv("SERVER_DIR") + }; + for (String path : paths) { + if (path != null && new File(path).exists() && new File(path, "main.py").exists()) { + return path; + } + } + return null; + } + + private static int findFreePort() throws IOException { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private static String readProcessOutput() throws IOException { + if (serverProcess == null) return ""; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(serverProcess.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + return sb.toString(); + } + } +} diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloaderParsingTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloaderParsingTest.java new file mode 100644 index 0000000..8f5c91c --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloaderParsingTest.java @@ -0,0 +1,287 @@ +package me.sashegdev.zernmc.launcher.minecraft; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for PackDownloader JSON parsing. + * Tests that the Java client correctly parses server JSON responses. + */ +class PackDownloaderParsingTest { + + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + // ===== /packs response parsing ===== + + @Test + void parsePacksResponse_singlePack() { + String body = "{" + + "\"packs\":[" + + "{" + + "\"name\":\"test-modpack\"," + + "\"version\":3," + + "\"files_count\":15," + + "\"updated_at\":\"2024-01-15T10:30:00\"," + + "\"minecraft_version\":\"1.20.4\"," + + "\"loader_type\":\"fabric\"," + + "\"loader_version\":\"0.15.6\"" + + "}" + + "]" + + "}"; + + List packs = parsePacksResponse(body); + assertEquals(1, packs.size()); + + ServerPack pack = packs.get(0); + assertEquals("test-modpack", pack.getName()); + assertEquals(3, pack.getVersion()); + assertEquals(15, pack.getFilesCount()); + assertEquals("1.20.4", pack.getMinecraftVersion()); + assertEquals("fabric", pack.getLoaderType()); + assertEquals("0.15.6", pack.getLoaderVersion()); + assertNotNull(pack.getUpdatedAt()); + } + + @Test + void parsePacksResponse_multiplePacks() { + String body = "{" + + "\"packs\":[" + + "{\"name\":\"survival\",\"version\":1,\"files_count\":5,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," + + "{\"name\":\"pvp\",\"version\":10,\"files_count\":50,\"minecraft_version\":\"1.20.4\",\"loader_type\":\"fabric\",\"loader_version\":\"0.15.6\",\"updated_at\":\"2024-02-01T00:00:00\"}" + + "]" + + "}"; + + List packs = parsePacksResponse(body); + assertEquals(2, packs.size()); + assertEquals("survival", packs.get(0).getName()); + assertEquals("pvp", packs.get(1).getName()); + } + + @Test + void parsePacksResponse_skipsErroredPacks() { + String body = "{" + + "\"packs\":[" + + "{\"name\":\"good-pack\",\"version\":1,\"files_count\":1,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," + + "{\"name\":\"bad-pack\",\"error\":\"scan failed\"}," + + "{\"name\":\"not-scanned\",\"status\":\"not_scanned\"}" + + "]" + + "}"; + + List packs = parsePacksResponse(body); + assertEquals(1, packs.size()); + assertEquals("good-pack", packs.get(0).getName()); + } + + @Test + void parsePacksResponse_missingFields_defaults() { + String body = "{" + + "\"packs\":[" + + "{\"name\":\"minimal-pack\"}" + + "]" + + "}"; + + List packs = parsePacksResponse(body); + assertEquals(1, packs.size()); + + ServerPack pack = packs.get(0); + assertEquals("minimal-pack", pack.getName()); + assertEquals(0, pack.getVersion()); // default + assertEquals("unknown", pack.getMinecraftVersion()); // default + assertEquals("vanilla", pack.getLoaderType()); // default + assertEquals("", pack.getLoaderVersion()); // default + assertEquals(0, pack.getFilesCount()); // default + assertNull(pack.getUpdatedAt()); // default + } + + @Test + void parsePacksResponse_emptyList() { + String body = "{\"packs\":[]}"; + List packs = parsePacksResponse(body); + assertTrue(packs.isEmpty()); + } + + // ===== PackManifest parsing ===== + + @Test + void parsePackManifest_withFiles() { + String body = "{" + + "\"pack_name\":\"my-pack\"," + + "\"version\":5," + + "\"minecraft_version\":\"1.20.4\"," + + "\"loader_type\":\"fabric\"," + + "\"loader_version\":\"0.15.6\"," + + "\"asset_index\":\"1.20.4\"," + + "\"files\":{" + + "\"mods/sodium.jar\":{\"path\":\"mods/sodium.jar\",\"url\":\"/pack/my-pack/file/mods/sodium.jar\",\"size\":1024000,\"hash\":\"abc123\"}," + + "\"mods/fabric-api.jar\":{\"path\":\"mods/fabric-api.jar\",\"url\":\"/pack/my-pack/file/mods/fabric-api.jar\",\"size\":2048000,\"hash\":\"def456\"}" + + "}" + + "}"; + + PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class); + + assertEquals("my-pack", manifest.getPackName()); + assertEquals(5, manifest.getVersion()); + assertEquals("1.20.4", manifest.getMinecraftVersion()); + assertEquals("fabric", manifest.getLoaderType()); + assertEquals("0.15.6", manifest.getLoaderVersion()); + assertEquals("1.20.4", manifest.getAssetIndex()); + assertFalse(manifest.isEmpty()); + assertEquals(2, manifest.getFiles().size()); + } + + @Test + void parsePackManifest_nullAssetIndex_defaultsToMinecraftVersion() { + String body = "{" + + "\"pack_name\":\"no-asset\"," + + "\"version\":1," + + "\"minecraft_version\":\"1.19.4\"," + + "\"loader_type\":\"vanilla\"," + + "\"loader_version\":null" + + "}"; + + PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class); + assertEquals("1.19.4", manifest.getAssetIndex()); // defaults to minecraft_version + } + + @Test + void parsePackManifest_noFiles_isEmpty() { + String body = "{" + + "\"pack_name\":\"empty-pack\"," + + "\"version\":1," + + "\"minecraft_version\":\"1.20.1\"," + + "\"loader_type\":\"vanilla\"," + + "\"loader_version\":null" + + "}"; + + PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class); + assertTrue(manifest.isEmpty()); + } + + // ===== DiffResponse parsing ===== + + @Test + void parseDiffResponse_allFields() { + String body = "{" + + "\"version\":6," + + "\"to_download\":[" + + "{\"path\":\"mods/new-mod.jar\",\"url\":\"/pack/test/file/mods/new-mod.jar\",\"size\":512000,\"hash\":\"aaa111\"}" + + "]," + + "\"to_delete\":[\"mods/old-mod.jar\"]," + + "\"to_update\":[\"mods/updated-mod.jar\"]" + + "}"; + + PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class); + + assertEquals(6, diff.getVersion()); + assertEquals(1, diff.getToDownload().size()); + assertEquals(1, diff.getToDelete().size()); + assertEquals(1, diff.getToUpdate().size()); + + PackDownloader.FileInfo fileInfo = diff.getToDownload().get(0); + assertEquals("mods/new-mod.jar", fileInfo.getPath()); + assertEquals("/pack/test/file/mods/new-mod.jar", fileInfo.getUrl()); + assertEquals(512000, fileInfo.getSize()); + assertEquals("aaa111", fileInfo.getHash()); + } + + @Test + void parseDiffResponse_emptyArrays() { + String body = "{" + + "\"version\":1," + + "\"to_download\":[]," + + "\"to_delete\":[]," + + "\"to_update\":[]" + + "}"; + + PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class); + assertTrue(diff.getToDownload().isEmpty()); + assertTrue(diff.getToDelete().isEmpty()); + assertTrue(diff.getToUpdate().isEmpty()); + } + + @Test + void parseDiffResponse_nullArrays_returnsEmpty() { + String body = "{\"version\":1}"; + + PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class); + assertNotNull(diff.getToDownload()); + assertNotNull(diff.getToDelete()); + assertNotNull(diff.getToUpdate()); + assertTrue(diff.getToDownload().isEmpty()); + assertTrue(diff.getToDelete().isEmpty()); + } + + // ===== ServerPack toString ===== + + @Test + void serverPack_toString_withDate() { + java.time.LocalDateTime date = java.time.LocalDateTime.of(2024, 3, 15, 12, 0); + ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", date, 25); + + String str = pack.toString(); + assertTrue(str.contains("my-pack")); + assertTrue(str.contains("1.20.4")); + assertTrue(str.contains("fabric")); + assertTrue(str.contains("25 файлов")); + assertTrue(str.contains("15.03.2024")); + } + + @Test + void serverPack_toString_withoutDate() { + ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", null, 25); + + String str = pack.toString(); + assertTrue(str.contains("my-pack")); + assertTrue(str.contains("25 файлов")); + assertFalse(str.contains("обновлен")); + } + + // ===== Helper: replicates PackDownloader.parsePacksResponse() ===== + + private static List parsePacksResponse(String responseBody) { + JsonObject root = com.google.gson.JsonParser.parseString(responseBody).getAsJsonObject(); + JsonArray packsArray = root.getAsJsonArray("packs"); + List result = new ArrayList<>(); + + for (var elem : packsArray) { + JsonObject pack = elem.getAsJsonObject(); + + if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) { + continue; + } + + try { + String name = pack.get("name").getAsString(); + int version = pack.has("version") ? pack.get("version").getAsInt() : 0; + String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown"; + String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla"; + String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() + ? pack.get("loader_version").getAsString() : ""; + int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0; + + java.time.LocalDateTime updatedAt = null; + if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) { + try { + updatedAt = java.time.LocalDateTime.parse(pack.get("updated_at").getAsString(), + java.time.format.DateTimeFormatter.ISO_DATE_TIME); + } catch (Exception ignored) {} + } + + result.add(new ServerPack(name, version, minecraftVersion, loaderType, + loaderVersion, updatedAt, filesCount)); + } catch (Exception e) { + System.err.println("Ошибка парсинга пака: " + e.getMessage()); + } + } + + return result; + } +} diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java new file mode 100644 index 0000000..561c98d --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java @@ -0,0 +1,563 @@ +package me.sashegdev.zernmc.launcher.minecraft.launch; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class LaunchCommandBuilderTest { + + @TempDir + Path tempDir; + + // ================================================================ + // Helpers + // ================================================================ + + private Path createJar(Path path) throws Exception { + Files.createDirectories(path.getParent()); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(path))) { + zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + zos.write("Manifest-Version: 1.0\n".getBytes()); + zos.closeEntry(); + } + return path; + } + + private Path createJarWithModule(Path path, String moduleName) throws Exception { + Files.createDirectories(path.getParent()); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(path))) { + zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + zos.write(("Manifest-Version: 1.0\nAutomatic-Module-Name: " + moduleName + "\n").getBytes()); + zos.closeEntry(); + } + return path; + } + + private void writeVersionJson(Path path, String content) throws Exception { + Files.createDirectories(path.getParent()); + Files.writeString(path, content); + } + + // ================================================================ + // Fixture setup + // ================================================================ + + private Instance createForgeFixture(Path dir) throws Exception { + String mcVer = "1.20.1"; + String forgeVer = "47.3.0"; + String versionId = mcVer + "-forge-" + forgeVer; + + // Vanilla parent + String vanillaJson = """ + { + "id": "1.20.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": { + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"], + "game": ["--username", "${auth_player_name}", "--version", "${version_name}", "--gameDir", "${game_directory}", "--assetsDir", "${assets_root}", "--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}", "--userType", "${user_type}", "--versionType", "${version_type}"] + }, + "assetIndex": {"id": "1.20.1"}, + "libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}] + } + """; + writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson); + createJarWithModule(dir.resolve("versions/1.20.1/1.20.1.jar"), "minecraft"); + createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar")); + + // Forge child — real 1.20.1-47.3.0 format: -p is a fixed bootstrap list, + // NO ${classpath} and NO -cp (the launcher must add -cp itself). + String forgeJson = """ + { + "id": "1.20.1-forge-47.3.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "arguments": { + "jvm": ["-Djava.net.preferIPv6Addresses=system", + "-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-,${version_name}.jar", + "-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar", + "-DlibraryDirectory=${library_directory}", + "-p", "${library_directory}/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar${classpath_separator}${library_directory}/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar", + "--add-modules", "ALL-MODULE-PATH", + "--add-opens", "java.base/java.util.jar=cpw.mods.securejarhandler", + "--add-opens", "java.base/java.lang.invoke=cpw.mods.securejarhandler"], + "game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0", + "--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge", + "--fml.mcpVersion", "20230612.114412"] + }, + "libraries": [ + {"name": "cpw.mods:bootstraplauncher:1.1.2", "downloads": {"artifact": {"path": "cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar"}}}, + {"name": "cpw.mods:securejarhandler:2.1.10", "downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar"}}}, + {"name": "net.minecraftforge:forge:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar"}}} + ] + } + """; + writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), forgeJson); + createJar(dir.resolve("libraries/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar")); + createJar(dir.resolve("libraries/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar")); + createJar(dir.resolve("libraries/net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar")); + + Files.createDirectories(dir.resolve("natives")); + + Instance instance = new Instance("test-forge", dir); + instance.setMinecraftVersion(mcVer); + instance.setLoaderType("forge"); + instance.setLoaderVersion(forgeVer); + instance.setAssetIndex(mcVer); + return instance; + } + + private Instance createNeoForgeFixture(Path dir) throws Exception { + String mcVer = "1.20.1"; + String neoVer = "47.1.106"; + String versionId = mcVer + "-neoforge-" + neoVer; + + String vanillaJson = """ + { + "id": "1.20.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": { + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"], + "game": ["--username", "${auth_player_name}", "--version", "${version_name}"] + }, + "assetIndex": {"id": "1.20.1"}, + "libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}] + } + """; + writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson); + createJarWithModule(dir.resolve("versions/1.20.1/1.20.1.jar"), "minecraft"); + createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar")); + + String neoJson = """ + { + "id": "1.20.1-neoforge-47.1.106", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "arguments": { + "jvm": ["-p", "${classpath}", "--add-modules=ALL-MODULE-PATH", "--add-opens=java.base/java.util.jar=ALL-UNNAMED"], + "game": ["--launchTarget", "neoforgeclient", "--fml.neoForgeVersion", "47.1.106", "--fml.mcVersion", "1.20.1"] + }, + "libraries": [{"name": "net.neoforged:neoforge:1.20.1-47.1.106", "downloads": {"artifact": {"path": "net/neoforged/neoforge/1.20.1-47.1.106/neoforge-1.20.1-47.1.106.jar"}}}] + } + """; + writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), neoJson); + createJar(dir.resolve("libraries/net/neoforged/neoforge/1.20.1-47.1.106/neoforge-1.20.1-47.1.106.jar")); + + Files.createDirectories(dir.resolve("natives")); + + Instance instance = new Instance("test-neoforge", dir); + instance.setMinecraftVersion(mcVer); + instance.setLoaderType("neoforge"); + instance.setLoaderVersion(neoVer); + instance.setAssetIndex(mcVer); + instance.setFabricVersionId(versionId); + return instance; + } + + private Instance createFabricFixture(Path dir) throws Exception { + String mcVer = "1.20.1"; + String loaderVer = "0.16.10"; + String versionId = "fabric-loader-" + loaderVer + "-" + mcVer; + + String vanillaJson = """ + { + "id": "1.20.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": { + "game": ["--username", "${auth_player_name}", "--version", "${version_name}"] + }, + "assetIndex": {"id": "1.20.1"}, + "libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}] + } + """; + writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson); + createJar(dir.resolve("versions/1.20.1/1.20.1.jar")); + + String fabricJson = """ + { + "id": "fabric-loader-0.16.10-1.20.1", + "inheritsFrom": "1.20.1", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "arguments": { + "game": ["--username", "${auth_player_name}"] + }, + "libraries": [{"name": "net.fabricmc:fabric-loader:0.16.10", "downloads": {"artifact": {"path": "net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"}}}] + } + """; + writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), fabricJson); + createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar")); + createJar(dir.resolve("libraries/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar")); + + Files.createDirectories(dir.resolve("natives")); + + Instance instance = new Instance("test-fabric", dir); + instance.setMinecraftVersion(mcVer); + instance.setLoaderType("fabric"); + instance.setLoaderVersion(loaderVer); + instance.setAssetIndex(mcVer); + instance.setFabricVersionId(versionId); + return instance; + } + + private Instance createVanillaFixture(Path dir) throws Exception { + Files.createDirectories(dir.resolve("natives")); + // No version.json — forces vanilla fallback + Instance instance = new Instance("test-vanilla", dir); + instance.setMinecraftVersion("1.21"); + instance.setLoaderType("vanilla"); + instance.setAssetIndex("1.21"); + return instance; + } + + // ================================================================ + // Forge tests + // ================================================================ + + @Test + void forge_noParentJvmArgs() throws Exception { + Instance instance = createForgeFixture(tempDir); + LaunchOptions options = new LaunchOptions(); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + + List command = builder.build(options); + + // Real Forge version.json has no -cp (and no ${classpath}), so the launcher MUST add + // -cp with version jar + all libraries for the JVM to find BootstrapLauncher. + int cpIdx = command.indexOf("-cp"); + assertTrue(cpIdx >= 0, "Launcher must add -cp for Forge (version.json has none)"); + assertTrue(cpIdx + 1 < command.size(), "-cp must have a value"); + String classpath = command.get(cpIdx + 1); + assertNotNull(classpath, "-cp value must not be null"); + + // -Djava.library.path must be added manually (not in child args) + String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath(); + assertTrue(command.contains(expectedLibPath), "Forge must have -Djava.library.path"); + + // Child's -p (module path) must be the fixed bootstrap list passed through unchanged + int pIndex = command.indexOf("-p"); + assertTrue(pIndex >= 0, "Forge must have -p"); + assertTrue(pIndex + 1 < command.size(), "-p must have a value"); + String modulePath = command.get(pIndex + 1); + assertNotNull(modulePath, "-p value must not be null"); + assertTrue(modulePath.contains("bootstraplauncher-1.1.2.jar"), "-p must contain bootstrap jar"); + assertTrue(modulePath.contains("securejarhandler-2.1.10.jar"), "-p must contain bootstrap jar"); + + // ${classpath} must NOT be substituted / referenced in the real-json JVM args + assertFalse(classpath.contains("${classpath}"), "-cp must not contain unresolved ${classpath} placeholder"); + + // Child's --add-modules must be present (two-arg format) + int amIdx = command.indexOf("--add-modules"); + assertTrue(amIdx >= 0, "Forge must have --add-modules"); + assertTrue(amIdx + 1 < command.size(), "--add-modules must have a value"); + assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1), "--add-modules must be ALL-MODULE-PATH"); + + // Memory/GC args + assertTrue(command.contains("-Xmx4096M"), "Forge must have memory args"); + assertTrue(command.contains("-XX:+UseG1GC"), "Forge must have GC args"); + + // Main class + assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"), "Forge main class must be BootstrapLauncher"); + + // Game args: parent + child + int launchTargetIdx = command.indexOf("--launchTarget"); + assertTrue(launchTargetIdx >= 0, "Game args must include child's --launchTarget"); + assertEquals("forgeclient", command.get(launchTargetIdx + 1), "Launch target must be forgeclient"); + + int usernameIdx = command.indexOf("--username"); + assertTrue(usernameIdx >= 0, "Game args must include parent's --username"); + } + + @Test + void forge_classpathContainsBothVanillaAndForgeLibraries() throws Exception { + Instance instance = createForgeFixture(tempDir); + LaunchOptions options = new LaunchOptions(); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + + List command = builder.build(options); + + // Find -cp's classpath value (launcher-added; version.json has no -cp) + int cpIdx = command.indexOf("-cp"); + assertTrue(cpIdx >= 0, "Launcher must add -cp for Forge"); + String cpValue = command.get(cpIdx + 1); + + // Version jar from ensureVersionJarForForge + assertTrue(cpValue.contains("1.20.1-forge-47.3.0.jar") || cpValue.contains("1.20.1.jar"), + "Classpath must include version jar"); + + // Forge library + assertTrue(cpValue.contains("forge-1.20.1-47.3.0.jar"), + "Classpath must include Forge library"); + + // Vanilla library + assertTrue(cpValue.contains("client-1.20.1.jar"), + "Classpath must include vanilla client library"); + } + + @Test + void forge_noClasspathRef_buildsLegacyClassPath() throws Exception { + // Regression guard for the 1.0.14.x regression: the launcher must NOT inject the + // self-invented `-DlegacyClassPath` flag. Modern Forge/NeoForge derive the MC-BOOTSTRAP + // module layer from the module path / modlauncher classpath (the version.json `-p + // ${classpath}` placeholder, resolved by the launcher), NOT from `-DlegacyClassPath` + // (a 1.12-era mechanism that, when duplicated here, causes split-package / duplicate- + // entry failures and prevents the game from starting). This matches the 1.0.13 + // behaviour and AstralRinth's real Forge launch, where the classpath is supplied + // through `${classpath}` substitution only. + String mcVer = "1.20.1"; + String forgeVer = "47.3.0"; + String versionId = mcVer + "-forge-" + forgeVer; + + String vanillaJson = """ + { + "id": "1.20.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": {"game": ["--username", "${auth_player_name}"]}, + "assetIndex": {"id": "1.20.1"}, + "libraries": [ + {"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}, + {"name": "com.google.guava:guava:31.1-jre", "downloads": {"artifact": {"path": "com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"}}} + ] + } + """; + writeVersionJson(tempDir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson); + createJar(tempDir.resolve("versions/1.20.1/1.20.1.jar")); + createJar(tempDir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar")); + createJar(tempDir.resolve("libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar")); + + // Realistic Forge child: references ${classpath} (serves as module path + classpath, + // exactly like AstralRinth / 1.0.13), plus the standard Forge bootstrap args. + String forgeJson = """ + { + "id": "1.20.1-forge-47.3.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "arguments": { + "jvm": ["-DlibraryDirectory=${library_directory}", + "-DignoreList=bootstraplauncher,forge-,${version_name}.jar", + "-p", "${classpath}", + "--add-modules", "ALL-MODULE-PATH"], + "game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0", + "--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge"] + }, + "libraries": [ + {"name": "cpw.mods:modlauncher:10.0.9", "downloads": {"artifact": {"path": "cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar"}}}, + {"name": "net.minecraftforge:fmlloader:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/fmlloader/1.20.1-47.3.0/fmlloader-1.20.1-47.3.0.jar"}}} + ] + } + """; + writeVersionJson(tempDir.resolve("versions/" + versionId + "/" + versionId + ".json"), forgeJson); + Files.createDirectories(tempDir.resolve("natives")); + createJar(tempDir.resolve("libraries/cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar")); + createJar(tempDir.resolve("libraries/net/minecraftforge/fmlloader/1.20.1-47.3.0/fmlloader-1.20.1-47.3.0.jar")); + + Instance instance = new Instance("test-forge-realjson", tempDir); + instance.setMinecraftVersion(mcVer); + instance.setLoaderType("forge"); + instance.setLoaderVersion(forgeVer); + instance.setAssetIndex(mcVer); + + LaunchOptions options = new LaunchOptions(); + List command = new LaunchCommandBuilder(instance).build(options); + + // No self-invented -DlegacyClassPath must ever be emitted for Forge. + assertFalse(command.stream().anyMatch(a -> a != null && a.startsWith("-DlegacyClassPath=")), + "Forge must NOT have a -DlegacyClassPath (1.0.13/AstralRinth rely on ${classpath})"); + + // ${classpath} must have been substituted into the -p module path, carrying all libs. + int pIdx = command.indexOf("-p"); + assertTrue(pIdx >= 0, "Forge must have -p"); + assertTrue(pIdx + 1 < command.size(), "-p must have a value"); + String modulePath = command.get(pIdx + 1); + assertNotNull(modulePath, "-p value must not be null"); + assertTrue(modulePath.contains("modlauncher-10.0.9.jar"), + "-p (resolved classpath) must contain modlauncher, got: " + modulePath); + assertTrue(modulePath.contains("fmlloader-1.20.1-47.3.0.jar"), + "-p (resolved classpath) must contain forge libraries, got: " + modulePath); + + // version_name resolves to the version id (not the instance name) in -DignoreList. + String ignoreList = null; + for (String arg : command) { + if (arg != null && arg.startsWith("-DignoreList=")) ignoreList = arg; + } + assertNotNull(ignoreList, "Must have -DignoreList"); + assertTrue(ignoreList.contains("1.20.1-forge-47.3.0.jar"), + "-DignoreList must use version id, got: " + ignoreList); + + // --add-modules ALL-MODULE-PATH as two args (forge format) + int amIdx = command.indexOf("--add-modules"); + assertTrue(amIdx >= 0, "Must have --add-modules"); + assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1)); + + // Main class + game args + assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher")); + assertTrue(command.contains("--launchTarget")); + } + + @Test + void forge_versionNameIsVersionIdNotInstanceName() throws Exception { + Instance instance = createForgeFixture(tempDir); + LaunchOptions options = new LaunchOptions(); + List command = new LaunchCommandBuilder(instance).build(options); + + String ignoreList = null; + for (String arg : command) { + if (arg != null && arg.startsWith("-DignoreList=")) ignoreList = arg; + } + if (ignoreList != null) { + assertTrue(ignoreList.contains("1.20.1-forge-47.3.0.jar"), + "-DignoreList must use version id, got: " + ignoreList); + } + } + + // ================================================================ + // NeoForge tests + // ================================================================ + + @Test + void neoforge_noParentJvmArgs() throws Exception { + Instance instance = createNeoForgeFixture(tempDir); + LaunchOptions options = new LaunchOptions(); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + + List command = builder.build(options); + + // NeoForge child jvm args reference ${classpath} (substituted into -p); version.json + // declares no -cp, so the launcher adds -cp with version jar + all libraries. + assertTrue(command.contains("-cp"), "NeoForge must have -cp (added by launcher)"); + String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath(); + assertTrue(command.contains(expectedLibPath), "NeoForge must have -Djava.library.path"); + assertTrue(command.contains("-p"), "NeoForge must have -p"); + assertTrue(command.contains("--add-modules=ALL-MODULE-PATH"), "NeoForge must have --add-modules=ALL-MODULE-PATH"); + assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"), "NeoForge main class must be BootstrapLauncher"); + + // Game args: parent + child + int launchTargetIdx = command.indexOf("--launchTarget"); + assertTrue(launchTargetIdx >= 0, "Game args must include child's --launchTarget"); + assertEquals("neoforgeclient", command.get(launchTargetIdx + 1), "Launch target must be neoforgeclient"); + assertTrue(command.contains("--username"), "Game args must include parent's --username"); + } + + // ================================================================ + // Fabric tests + // ================================================================ + + @Test + void fabric_followsVanillaArgPattern() throws Exception { + Instance instance = createFabricFixture(tempDir); + LaunchOptions options = new LaunchOptions(); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + + List command = builder.build(options); + + // Fabric uses -cp (vanilla pattern), not -p + assertTrue(command.contains("-cp"), "Fabric must have -cp"); + assertFalse(command.contains("-p"), "Fabric must not have -p"); + + // -Djava.library.path present + String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath(); + assertTrue(command.contains(expectedLibPath), "Fabric must have -Djava.library.path"); + + // Main class is KnotClient + assertTrue(command.contains("net.fabricmc.loader.impl.launch.knot.KnotClient"), + "Fabric main class must be KnotClient"); + + // Game args from version.json (merged with parent) + assertTrue(command.contains("--username"), "Fabric must have game args"); + } + + // ================================================================ + // Vanilla tests + // ================================================================ + + @Test + void vanilla_usesVanillaArgs() throws Exception { + Instance instance = createVanillaFixture(tempDir); + LaunchOptions options = new LaunchOptions(); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + + List command = builder.build(options); + + // Vanilla uses -cp + assertTrue(command.contains("-cp"), "Vanilla must have -cp"); + assertFalse(command.contains("-p"), "Vanilla must not have -p"); + + // -Djava.library.path present + String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath(); + assertTrue(command.contains(expectedLibPath), "Vanilla must have -Djava.library.path"); + + // Memory/GC args + assertTrue(command.contains("-Xmx4096M"), "Vanilla must have memory args"); + assertTrue(command.contains("-XX:+UseG1GC"), "Vanilla must have GC args"); + + // Main class + assertTrue(command.contains("net.minecraft.client.main.Main"), + "Vanilla main class must be net.minecraft.client.main.Main"); + + // Game args + assertTrue(command.contains("--username"), "Vanilla must have --username"); + } + + // ================================================================ + // Headless smoke tests — run Java with constructed args to verify + // module path validity. No display needed, uses -version. + // ================================================================ + + @Test + void forge_modulePathIsValid_smoke() throws Exception { + Instance instance = createForgeFixture(tempDir); + LaunchOptions options = new LaunchOptions(); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + + List command = builder.build(options); + + // Extract -p (module path) and its value + int pIdx = command.indexOf("-p"); + assertTrue(pIdx >= 0, "Forge must have -p"); + String modulePath = command.get(pIdx + 1); + + // Extract --add-modules, -Djava.library.path + String libPath = null; + for (String arg : command) { + if (arg.startsWith("-Djava.library.path=")) { + libPath = arg; + } + } + String addModules = null; + for (int i = 0; i < command.size(); i++) { + if ("--add-modules".equals(command.get(i)) && i + 1 < command.size()) { + addModules = command.get(i + 1); + } + } + assertNotNull(addModules, "Must have --add-modules"); + assertEquals("ALL-MODULE-PATH", addModules, "--add-modules must be ALL-MODULE-PATH"); + + // Build minimal Java command that validates the module path + // java -Djava.library.path= -p --add-modules ALL-MODULE-PATH -version + ProcessBuilder pb = new ProcessBuilder( + "java", + libPath, + "-p", modulePath, + "--add-modules", addModules, + "-version" + ); + pb.redirectErrorStream(true); + + Process process = pb.start(); + String output = new String(process.getInputStream().readAllBytes()); + int exitCode = process.waitFor(); + + assertTrue(exitCode == 0, + "Java should accept module path without errors. Exit code: " + exitCode + + "\nOutput: " + output); + } +} diff --git a/launcher/pom.xml b/launcher/pom.xml index e51e2fa..26429df 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -1,156 +1,185 @@ - - - - 4.0.0 - me.sashegdev - ZernMCLauncher - 1.0.7 - jar - - - 21 - 21 - UTF-8 - me.sashegdev.zernmc.launcher.Main - - - - - org.apache.httpcomponents - httpclient - 4.5.14 - - - com.fasterxml.jackson.core - jackson-databind - 2.15.2 - - - com.google.code.gson - gson - 2.10.1 - - - org.json - json - 20231013 - - - org.fusesource.jansi - jansi - 2.4.1 - - - me.tongfei - progressbar - 0.9.5 - - - commons-io - commons-io - 2.15.1 - - - - - - - - org.apache.maven.plugins - maven-shade-plugin - 3.5.0 - - - package - - shade - - - ../server/builds/ZernMCLauncher.jar - - - ${mainClass} - - ${project.version} - ZernMC Launcher - SashegDev - Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном) - https://github.com/SashegDev/launcher - - - - - - - - - - - com.akathist.maven.plugins.launch4j - launch4j-maven-plugin - 2.5.0 - - - l4j - package - - launch4j - - - ../server/builds/ZernMCLauncher.exe - ../server/builds/ZernMCLauncher.jar - console - false - - jre21 - 21 - - - ${project.version}.0 - ${project.version} - ZernMC Launcher — самописный Minecraft лаунчер - ${project.version}.0 - ${project.version} - ZernMC Launcher - ZernMC(SashegDev) - ZernMCLauncher - ZernMCLauncher.exe - - - - - - - - - org.apache.maven.plugins - maven-antrun-plugin - 3.1.0 - - - package - run - - - ${project.version} - - - - - - - - - - - - - - - + + + + 4.0.0 + me.sashegdev + ZernMCLauncher + ${revision} + pom + + ZernMC Launcher Parent + ZernMC Launcher - Multi-module project + + + bootstrap + diag + launcher + + + + 1.1.0 + 0 + 21 + 21 + UTF-8 + ZernMC + 2026 + ZernMC Launcher - Multi-module project + + + + + + org.apache.httpcomponents + httpclient + 4.5.14 + + + com.fasterxml.jackson.core + jackson-databind + 2.15.2 + + + com.google.code.gson + gson + 2.10.1 + + + org.json + json + 20231013 + + + org.fusesource.jansi + jansi + 2.4.1 + + + org.jline + jline + 3.24.1 + + + me.tongfei + progressbar + 0.9.5 + + + commons-io + commons-io + 2.15.1 + + + + org.openjfx + javafx-controls + 23.0.1 + win + + + org.openjfx + javafx-web + 23.0.1 + win + + + org.openjfx + javafx-graphics + 23.0.1 + win + + + org.openjfx + javafx-base + 23.0.1 + win + + + dnsjava + dnsjava + 3.6.5 + + + org.openjfx + javafx-media + 23.0.1 + win + + + + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.6.0 + + true + resolveCiFriendliesOnly + + + + flatten + process-resources + flatten + + + flatten-clean + clean + clean + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.3 + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + ../../server/builds/ZernMCLauncher.jar + + + ${mainClass} + + ${project.version}.${hotfix} + ZernMC Launcher + SashegDev + Samopisnui Minecraft-launcher. by SashegDev + https://github.com/SashegDev/launcher + + + + + + + + + + + + global + + true + + + ZernMC Launcher + http://87.120.187.36:1582 + + + \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java deleted file mode 100644 index fb73deb..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java +++ /dev/null @@ -1,182 +0,0 @@ -package me.sashegdev.zernmc.launcher; - -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.utils.*; - -import java.io.IOException; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.List; - -public class Main { - - private static final String CURRENT_VERSION = Version.getCurrentVersion(); - - public static void main(String[] args) throws IOException { - System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true"); - System.setProperty("file.encoding", "UTF-8"); - System.setProperty("sun.err.encoding", "UTF-8"); - System.setProperty("sun.stdout.encoding", "UTF-8"); - java.nio.charset.Charset.defaultCharset(); - ZAnsi.install(); - - System.out.print("\033[H\033[2J"); - System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION)); - - //проверка всех сервисов при старте - ZHttpClient.checkAllServicesOnStartup(); - - checkAndAutoUpdateLauncher(); - - // === АВТОРИЗАЦИЯ === - System.out.println(ZAnsi.cyan("Проверка авторизации...")); - boolean sessionRestored = AuthManager.loadSavedSession(); - - if (!sessionRestored) { - LoginMenu loginMenu = new LoginMenu(); - boolean loggedIn = loginMenu.show(); - if (!loggedIn) { - System.out.println(ZAnsi.yellow("До свидания!")); - ZAnsi.uninstall(); - System.exit(0); - } - } else { - System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!")); - } - // === КОНЕЦ АВТОРИЗАЦИИ === - - try { - mainLoop(); - } catch (Exception e) { - System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage())); - e.printStackTrace(); - } finally { - ZAnsi.uninstall(); - } - } - - private static void checkAndAutoUpdateLauncher() { - System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера...")); - - try { - String json = ZHttpClient.getLauncherVersionInfo(); - String serverVersion = extractVersion(json); - - System.out.println(ZAnsi.white("Текущая версия: ") + CURRENT_VERSION); - System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion); - - if (Version.isNewer(CURRENT_VERSION, serverVersion)) { - System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")")); - System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n")); - - performAutoUpdate(serverVersion); - restartLauncher(); - } else { - System.out.println(ZAnsi.brightGreen("Лаунчер актуален.")); - } - - } catch (Exception e) { - System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера.")); - System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); - } - } - - private static void performAutoUpdate(String newVersion) throws Exception { - String downloadUrl = ZHttpClient.getBaseUrl() + "/launcher/download?type=jar"; - Path currentJar = getCurrentJarPath(); - Path tempJar = currentJar.getParent().resolve("zernmc-launcher-new.jar"); - - System.out.println(ZAnsi.cyan("Скачивание версии " + newVersion + "...")); - - HttpClient client = HttpClient.newBuilder().build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(java.net.URI.create(downloadUrl)) - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(tempJar)); - - if (response.statusCode() != 200) { - throw new IOException("Сервер вернул код: " + response.statusCode()); - } - - long size = Files.size(tempJar); - System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)")); - - // Заменяем текущий jar - Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING); - - System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!")); - } - - private static void restartLauncher() { - try { - String javaPath = System.getProperty("java.home") + "/bin/java"; - String jarPath = getCurrentJarPath().toAbsolutePath().toString(); - - System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией...")); - - new ProcessBuilder(javaPath, "-jar", jarPath) - .inheritIO() - .start(); - - System.exit(0); - } catch (Exception e) { - System.err.println(ZAnsi.brightRed("Не удалось перезапустить лаунчер.")); - System.exit(1); - } - } - - private static String extractVersion(String json) { - try { - return json.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1"); - } catch (Exception e) { - return "unknown"; - } - } - - private static Path getCurrentJarPath() { - try { - return Path.of(Main.class.getProtectionDomain() - .getCodeSource() - .getLocation() - .toURI()); - } catch (Exception e) { - return Path.of("zernmc-launcher-1.0-jar-with-dependencies.jar"); - } - } - - private static void mainLoop() throws Exception { - while (true) { - List options = List.of( - "Запустить игру", - "Проверка обновлений", - "Настройки", - "Проверка подключения к серверам Zern", - "Выход" - ); - - ArrowMenu menu = new ArrowMenu("Главное меню", options); - int choice = menu.show(); - - if (choice == -1 || choice == 4) { - System.out.print("\033[H\033[2J"); - System.out.println(ZAnsi.yellow("До свидания!")); - break; - } - - switch (choice) { - case 0 -> new LaunchMenu().show(); - case 1 -> new UpdateMenu().show(); - case 2 -> new SettingsMenu().show(); - case 3 -> new ServerCheckMenu().show(); - } - } - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java deleted file mode 100644 index f6cd5b3..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java +++ /dev/null @@ -1,272 +0,0 @@ -package me.sashegdev.zernmc.launcher.auth; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.annotations.SerializedName; -import me.sashegdev.zernmc.launcher.utils.Config; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; -import me.sashegdev.zernmc.launcher.utils.ZHttpClient; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class AuthManager { - - private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json"); - private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); - - private static volatile AuthSession session = null; - - public static boolean loadSavedSession() { - if (!Files.exists(AUTH_FILE)) return false; - try { - String json = Files.readString(AUTH_FILE); - AuthSession loaded = GSON.fromJson(json, AuthSession.class); - if (loaded == null || loaded.accessToken == null) return false; - - session = loaded; - if (isAccessTokenExpired()) { - return tryRefresh(); - } - return true; - } catch (Exception e) { - return false; - } - } - - public static AuthResult login(String username, String password) { - return authRequest("/auth/login", username, password); - } - - public static AuthResult register(String username, String password) { - return authRequest("/auth/register", username, password); - } - - private static AuthResult authRequest(String endpoint, String username, String password) { - try { - String body = GSON.toJson(new LoginRequest(username, password)); - - //System.out.println(ZAnsi.cyan("[AUTH] Отправка запроса: " + endpoint)); - - SimpleHttpResponse resp = post(endpoint, body); - - //System.out.println(ZAnsi.cyan("[AUTH] Ответ: HTTP " + resp.statusCode())); - - if (resp.statusCode() == 200) { - session = GSON.fromJson(resp.body(), AuthSession.class); - session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn; - saveSession(); - return AuthResult.ok(); - } else if (resp.statusCode() == 422) { - return AuthResult.fail("Ошибка валидации: " + extractError(resp.body())); - } else { - return AuthResult.fail(extractError(resp.body())); - } - } catch (Exception e) { - //System.err.println(ZAnsi.red("[AUTH] Исключение: " + e.getMessage())); - e.printStackTrace(); - return AuthResult.fail("Ошибка соединения: " + e.getMessage()); - } - } - - public static void logout() { - if (session != null && session.refreshToken != null) { - try { - post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}"); - } catch (Exception ignored) {} - } - session = null; - try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} - } - - public static boolean isLoggedIn() { - return session != null && session.accessToken != null; - } - - public static String getUsername() { - return session != null ? session.username : "Player"; - } - - public static String getUuid() { - return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000"; - } - - public static String getAccessToken() { - if (session == null) return "0"; - if (isAccessTokenExpired()) { - tryRefresh(); - } - return session != null && session.accessToken != null ? session.accessToken : "0"; - } - - private static boolean isAccessTokenExpired() { - if (session == null) return true; - return System.currentTimeMillis() / 1000L >= session.expiresAt - 300; - } - - private static boolean tryRefresh() { - if (session == null || session.refreshToken == null) return false; - try { - String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}"; - SimpleHttpResponse resp = post("/auth/refresh", body); - - if (resp.statusCode() == 200) { - AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class); - newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn; - session = newSession; - saveSession(); - return true; - } - } catch (Exception ignored) {} - session = null; - try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} - return false; - } - - private static void saveSession() { - try { - Files.createDirectories(AUTH_FILE.getParent()); - Files.writeString(AUTH_FILE, GSON.toJson(session)); - } catch (IOException e) { - System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage())); - } - } - - private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception { - String fullUrl = ZHttpClient.getBaseUrl() + endpoint; - - java.net.HttpURLConnection conn = null; - try { - java.net.URL url = java.net.URI.create(fullUrl).toURL(); - conn = (java.net.HttpURLConnection) url.openConnection(); - conn.setRequestMethod("POST"); - conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); - conn.setRequestProperty("Accept", "application/json"); - conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); - - // Добавляем токен авторизации, если есть сессия - if (session != null && session.accessToken != null) { - conn.setRequestProperty("Authorization", "Bearer " + session.accessToken); - } - - conn.setDoOutput(true); - conn.setConnectTimeout(15000); - conn.setReadTimeout(15000); - - try (java.io.OutputStream os = conn.getOutputStream()) { - byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); - } - - int statusCode = conn.getResponseCode(); - - java.io.InputStream is = (statusCode >= 200 && statusCode < 300) - ? conn.getInputStream() - : conn.getErrorStream(); - - String responseBody; - try (java.util.Scanner scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) { - responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; - } - - return new SimpleHttpResponse(statusCode, responseBody); - - } finally { - if (conn != null) conn.disconnect(); - } - } - - private static String extractError(String body) { - try { - JsonObject json = JsonParser.parseString(body).getAsJsonObject(); - - if (json.has("detail")) { - if (json.get("detail").isJsonArray()) { - return json.getAsJsonArray("detail").get(0).getAsJsonObject() - .get("msg").getAsString(); - } - return json.get("detail").getAsString(); - } - if (json.has("error")) { - return json.get("error").getAsString(); - } - } catch (Exception ignored) {} - - return body.length() > 200 ? body.substring(0, 200) + "..." : body; - } - - public static boolean hasActivePass() { - if (!isLoggedIn()) return false; - try { - String response = ZHttpClient.get("/auth/pass/my"); - return response.contains("\"is_active\":true"); - } catch (Exception e) { - System.err.println("Не удалось проверить проходки: " + e.getMessage()); - return false; - } - } - - public static String activatePass(String passCode) { - try { - String json = "{\"pass_code\":\"" + passCode.toUpperCase() + "\"}"; - SimpleHttpResponse resp = post("/auth/pass/activate", json); - - System.out.println(ZAnsi.cyan("[AUTH] Активация проходки: HTTP " + resp.statusCode())); - - if (resp.statusCode() == 200) { - return "Проходка успешно активирована!"; - } else if (resp.statusCode() == 401) { - return "Ошибка: Требуется авторизация. Перезайдите в аккаунт."; - } else { - String error = extractError(resp.body()); - return "Ошибка: " + error; - } - } catch (Exception e) { - e.printStackTrace(); - return "Ошибка соединения: " + e.getMessage(); - } - } - - // ====================== ВНУТРЕННИЕ КЛАССЫ ====================== - - public static class AuthSession { - @SerializedName("access_token") public String accessToken; - @SerializedName("refresh_token") public String refreshToken; - @SerializedName("expires_in") public int expiresIn; - public transient long expiresAt; - public String username; - public String uuid; - } - - private static class LoginRequest { - final String username; - final String password; - LoginRequest(String u, String p) { this.username = u; this.password = p; } - } - - public static class AuthResult { - public final boolean success; - public final String error; - private AuthResult(boolean s, String e) { success = s; error = e; } - public static AuthResult ok() { return new AuthResult(true, null); } - public static AuthResult fail(String msg) { return new AuthResult(false, msg); } - } -} - -// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ====================== -class SimpleHttpResponse { - final int statusCode; - final String body; - - SimpleHttpResponse(int statusCode, String body) { - this.statusCode = statusCode; - this.body = body; - } - - int statusCode() { return statusCode; } - String body() { return body; } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java deleted file mode 100644 index 8c22af4..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java +++ /dev/null @@ -1,581 +0,0 @@ -package me.sashegdev.zernmc.launcher.menu; - -import me.sashegdev.zernmc.launcher.auth.AuthManager; -import me.sashegdev.zernmc.launcher.minecraft.Instance; -import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; -import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib; -import me.sashegdev.zernmc.launcher.minecraft.PackDownloader; -import me.sashegdev.zernmc.launcher.minecraft.ServerPack; -import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; -import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; -import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion; -import me.sashegdev.zernmc.launcher.ui.ArrowMenu; -import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; -import me.sashegdev.zernmc.launcher.utils.Input; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; -import me.sashegdev.zernmc.launcher.utils.ZHttpClient; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -public class LaunchMenu { - - public void show() throws Exception { - while (true) { - ConsoleUtils.clearScreen(); - List instances = InstanceManager.getAllInstances(); - - List options = instances.stream() - .map(Instance::toString) - .collect(Collectors.toList()); - - options.add("Установить новую сборку"); - options.add("Назад в главное меню"); - - ArrowMenu menu = new ArrowMenu("Управление сборками", options); - int choice = menu.show(); - - if (choice == -1) break; - if (choice == options.size() - 1) break; - - if (choice == instances.size()) { - installNewPack(); - continue; - } - - Instance selected = instances.get(choice); - manageInstance(selected); - } - } - - private void installNewPack() throws Exception { - ConsoleUtils.clearScreen(); - - List options = List.of( - "Установить сборку с сервера ZernMC", - "Установить Vanilla Minecraft", - "Создать сборку вручную (Fabric/Forge)", - "Назад" - ); - - ArrowMenu menu = new ArrowMenu("Установка новой сборки", options); - int choice = menu.show(); - - if (choice == -1 || choice == 3) return; - - switch (choice) { - case 0 -> { - try { - installServerPack(); - } catch (Exception e) { - System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage())); - e.printStackTrace(); - ConsoleUtils.pause(); - } - } - case 1 -> createVanillaInstance(); - case 2 -> createCustomInstance(); - } - } - - private void installServerPack() throws Exception { - if (!AuthManager.hasActivePass()) { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.brightRed("У вас нет активной проходки!")); - System.out.println(ZAnsi.white("Чтобы скачивать сборки с сервера ZernMC, необходимо активировать проходку.")); - System.out.println(); - System.out.print(ZAnsi.white("Введите код проходки (ZERN-XXXXXXX) или Enter для отмены: ")); - - String code = Input.readLine(); - if (code.isEmpty()) return; - - String result = AuthManager.activatePass(code); - System.out.println(ZAnsi.cyan(result)); - - if (!result.contains("успешно")) { - ConsoleUtils.pause(); - return; - } - - // Повторная проверка - if (!AuthManager.hasActivePass()) { - System.out.println(ZAnsi.brightRed("Не удалось активировать проходку.")); - ConsoleUtils.pause(); - return; - } - } - - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.cyan("Получение списка доступных сборок с сервера...")); - - PackDownloader tempDownloader = new PackDownloader(null); - List availablePacks = tempDownloader.getAvailablePacks(); - - if (availablePacks.isEmpty()) { - System.out.println(ZAnsi.yellow("Нет доступных сборок на сервере.")); - ConsoleUtils.pause(); - return; - } - - // Исправлено: убраны спецсимволы для Windows - List options = availablePacks.stream() - .map(p -> String.format("%s [%s + %s v%d] - %d файлов", - p.getName(), - p.getMinecraftVersion(), - p.getLoaderType(), - p.getVersion(), - p.getFilesCount())) - .collect(Collectors.toList()); - options.add("Назад"); - - ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options); - int choice = menu.show(); - - if (choice == -1 || choice == options.size() - 1) return; - - ServerPack selected = availablePacks.get(choice); - - // Запрашиваем имя для локальной сборки - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.header("Установка сборки: " + selected.getName())); - System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion()); - System.out.println(ZAnsi.white(" Лоадер: ") + selected.getLoaderType() + " " + selected.getLoaderVersion()); - System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion()); - System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount()); - System.out.println(); - - System.out.print(ZAnsi.white("Введите название локальной сборки (Enter = использовать имя пака): ")); - String localName = Input.readLine(); - if (localName.isEmpty()) { - localName = selected.getName(); - } - - // Проверяем, существует ли уже такая сборка - if (InstanceManager.getInstance(localName) != null) { - System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); - ConsoleUtils.pause(); - return; - } - - // Создаем инстанс - InstanceManager.createInstanceFolder(localName); - Instance newInstance = InstanceManager.getInstance(localName); - - // Устанавливаем сборку - PackDownloader packDownloader = new PackDownloader(newInstance); - boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected); - - if (success) { - System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!")); - } else { - System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку.")); - } - - ConsoleUtils.pause(); - } - - private void createVanillaInstance() throws Exception { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.cyan("Получение списка версий Minecraft...")); - - VersionInstaller versionInstaller = new VersionInstaller(null); - List allVersions = versionInstaller.getAvailableVersions(); - - List versionOptions = allVersions.stream() - .map(v -> v.getId() + " (" + v.getType() + ")") - .collect(Collectors.toList()); - versionOptions.add("Назад"); - - ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions); - int versionChoice = versionMenu.show(); - - if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; - - MinecraftVersion selectedMc = allVersions.get(versionChoice); - String mcVersion = selectedMc.getId(); - - String packName = askPackName(); - if (packName == null) return; - - if (InstanceManager.getInstance(packName) != null) { - System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); - ConsoleUtils.pause(); - return; - } - - InstanceManager.createInstanceFolder(packName); - Instance newInstance = InstanceManager.getInstance(packName); - - MinecraftLib lib = new MinecraftLib(newInstance); - boolean success = lib.installMinecraft(mcVersion); - - if (success) { - System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!")); - } else { - System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку.")); - } - - ConsoleUtils.pause(); - } - - private void createCustomInstance() throws Exception { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.cyan("Получение списка версий Minecraft...")); - - VersionInstaller versionInstaller = new VersionInstaller(null); - List allVersions = versionInstaller.getAvailableVersions(); - - List versionOptions = allVersions.stream() - .map(v -> v.getId() + " (" + v.getType() + ")") - .collect(Collectors.toList()); - versionOptions.add("Назад"); - - ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions); - int versionChoice = versionMenu.show(); - - if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; - - MinecraftVersion selectedMc = allVersions.get(versionChoice); - String mcVersion = selectedMc.getId(); - - // === Выбор лоадера с правильной проверкой поддержки === - List loaderOptions = buildLoaderOptions(mcVersion); - ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions); - int loaderChoice = loaderMenu.show(); - - if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return; - - String selectedLoader = loaderOptions.get(loaderChoice); - - if (selectedLoader.contains("Vanilla")) { - createVanillaInstance(); - return; - } - - String loaderType = selectedLoader.contains("Fabric") ? "fabric" : "forge"; - - String loaderVersion; - if (loaderType.equals("fabric")) { - loaderVersion = askFabricLoaderVersion(); - } else { - loaderVersion = askForgeVersion(mcVersion); - } - - if (loaderVersion == null) return; - - String packName = askPackName(); - if (packName == null) return; - - if (InstanceManager.getInstance(packName) != null) { - System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); - ConsoleUtils.pause(); - return; - } - - InstanceManager.createInstanceFolder(packName); - Instance newInstance = InstanceManager.getInstance(packName); - - MinecraftLib lib = new MinecraftLib(newInstance); - - boolean success = loaderType.equals("fabric") - ? lib.installFabric(mcVersion, loaderVersion) - : lib.installForge(mcVersion, loaderVersion); - - if (success) { - System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!")); - } else { - System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку.")); - } - - ConsoleUtils.pause(); - } - - // ====================== Вспомогательные методы ====================== - - private List buildLoaderOptions(String mcVersion) { - List options = new ArrayList<>(); - - if (isFabricSupported(mcVersion)) { - options.add("Fabric"); - } - if (isForgeSupported(mcVersion)) { - options.add("Forge"); - } - options.add("Vanilla"); - options.add("Назад"); - - return options; - } - - private boolean isFabricSupported(String version) { - return version.matches("^1\\.(1[4-9]|[2-9]\\d).*"); - } - - private boolean isForgeSupported(String version) { - if (version.matches("^1\\.2[2-9].*") || version.matches("^\\d{2}.*")) { - return false; - } - return version.matches("^1\\.(1[2-9]|[2-9]\\d).*") || - version.matches("^1\\.20.*") || version.matches("^1\\.21.*"); - } - - private void manageInstance(Instance instance) throws Exception { - while (true) { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName())); - System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion())); - System.out.println(ZAnsi.white("Лоадер: " + instance.getLoaderType() + - (instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : ""))); - - if (instance.isServerPack()) { - System.out.println(ZAnsi.green("Серверная сборка: v" + instance.getServerVersion())); - } - - List options = new ArrayList<>(); - options.add("Запустить сборку"); - if (instance.isServerPack()) { - options.add("Проверить обновления"); - } - options.add("Изменить версию лоадера"); - options.add("Удалить сборку"); - options.add("Назад"); - - ArrowMenu menu = new ArrowMenu("Действия", options); - int choice = menu.show(); - - if (choice == -1 || choice == options.size() - 1) return; - - switch (choice) { - case 0 -> launchExistingInstance(instance); - case 1 -> { - if (instance.isServerPack()) { - checkAndUpdateServerPack(instance); - } else { - changeLoaderVersion(instance); - } - } - case 2 -> { - if (instance.isServerPack()) { - changeLoaderVersion(instance); - } else { - deleteInstance(instance); - } - } - case 3 -> deleteInstance(instance); - } - } - } - - private void checkAndUpdateServerPack(Instance instance) throws Exception { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName())); - - PackDownloader downloader = new PackDownloader(instance); - boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); - - if (!hasUpdate) { - System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")")); - ConsoleUtils.pause(); - return; - } - - System.out.println(ZAnsi.brightYellow("Доступно обновление!")); - if (Input.confirm("Обновить сборку")) { - boolean success = downloader.updatePack(instance.getServerPackName()); - if (success) { - System.out.println(ZAnsi.brightGreen("Сборка успешно обновлена!")); - } else { - System.out.println(ZAnsi.brightRed("Не удалось обновить сборку.")); - } - } else { - System.out.println(ZAnsi.yellow("Обновление отменено.")); - } - - ConsoleUtils.pause(); - } - - private void changeLoaderVersion(Instance instance) throws Exception { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.cyan("Изменение версии лоадера для " + instance.getName())); - - String currentLoader = instance.getLoaderType(); - String mcVersion = instance.getMinecraftVersion(); - - if ("vanilla".equalsIgnoreCase(currentLoader)) { - System.out.println(ZAnsi.yellow("Это vanilla сборка. Нельзя изменить лоадер.")); - ConsoleUtils.pause(); - return; - } - - String newLoaderVersion; - if ("fabric".equalsIgnoreCase(currentLoader)) { - newLoaderVersion = askFabricLoaderVersion(); - } else { - newLoaderVersion = askForgeVersion(mcVersion); - } - - if (newLoaderVersion == null) return; - - System.out.println(ZAnsi.cyan("Переустановка лоадера " + currentLoader + " -> " + newLoaderVersion + "...")); - - MinecraftLib lib = new MinecraftLib(instance); - boolean success; - - try { - if ("fabric".equalsIgnoreCase(currentLoader)) { - success = lib.installFabric(mcVersion, newLoaderVersion); - } else { - success = lib.installForge(mcVersion, newLoaderVersion); - } - - if (success) { - System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!")); - } else { - System.out.println(ZAnsi.brightRed("Не удалось изменить версию лоадера.")); - } - } catch (Exception e) { - System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage())); - } - - ConsoleUtils.pause(); - } - - private void deleteInstance(Instance instance) throws IOException { - ConsoleUtils.clearScreen(); - - List confirmOptions = List.of( - "Да, удалить сборку", - "Нет, отменить" - ); - - ArrowMenu confirmMenu = new ArrowMenu( - "Вы действительно хотите удалить сборку '" + instance.getName() + "'?", - confirmOptions - ); - - int choice = confirmMenu.show(); - - if (choice == 0) { - boolean deleted = InstanceManager.deleteInstance(instance.getName()); - if (deleted) { - System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена.")); - } else { - System.out.println(ZAnsi.brightRed("Не удалось удалить сборку.")); - } - } else { - System.out.println(ZAnsi.yellow("Удаление отменено.")); - } - - ConsoleUtils.pause(); - } - - private String askFabricLoaderVersion() throws Exception { - System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader...")); - List versions = ZHttpClient.getFabricLoaderVersions(); - - List options = versions.stream() - .limit(30) - .map(v -> "Fabric Loader " + v) - .collect(Collectors.toList()); - options.add("Назад"); - - ArrowMenu menu = new ArrowMenu("Выбор версии Fabric Loader", options); - int choice = menu.show(); - - if (choice == -1 || choice == options.size() - 1) return null; - return versions.get(choice); - } - - private String askForgeVersion(String mcVersion) throws Exception { - System.out.println(ZAnsi.cyan("Получение списка версий Forge для " + mcVersion + "...")); - - List allForgeVersions = getAllForgeVersions(); - - List compatibleVersions = allForgeVersions.stream() - .filter(v -> v.startsWith(mcVersion + "-")) - .map(v -> v.substring(mcVersion.length() + 1)) - .collect(Collectors.toList()); - - if (compatibleVersions.isEmpty()) { - System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion)); - ConsoleUtils.pause(); - return null; - } - - List options = compatibleVersions.stream() - .limit(30) - .map(v -> "Forge " + v) - .collect(Collectors.toList()); - options.add("Назад"); - - ArrowMenu menu = new ArrowMenu("Выбор версии Forge для " + mcVersion, options); - int choice = menu.show(); - - if (choice == -1 || choice == options.size() - 1) return null; - - return compatibleVersions.get(choice); - } - - private String askPackName() { - System.out.print(ZAnsi.white("\nВведите название новой сборки: ")); - String name = Input.readLine(); - if (name.isEmpty()) { - System.out.println(ZAnsi.yellow("Отменено.")); - return null; - } - return name; - } - - private void launchExistingInstance(Instance instance) { - if (instance.isServerPack() && !AuthManager.hasActivePass()) { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!")); - ConsoleUtils.pause(); - return; - } - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName())); - - MinecraftLib lib = new MinecraftLib(instance); - LaunchOptions options = new LaunchOptions(); - - // Авторизация Minecraft - options.setUsername(AuthManager.getUsername()); - options.setUuid(AuthManager.getUuid()); - options.setAccessToken(AuthManager.getAccessToken()); - - try { - lib.launch(options); - } catch (Exception e) { - System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage())); - e.printStackTrace(); - } - - ConsoleUtils.pause(); - } - - private List getAllForgeVersions() throws Exception { - String metadataUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"; - - String xml = ZHttpClient.downloadString(metadataUrl); - - List versions = new ArrayList<>(); - int index = 0; - - while ((index = xml.indexOf("", index)) != -1) { - int start = index + 9; - int end = xml.indexOf("", start); - if (end == -1) break; - - String version = xml.substring(start, end).trim(); - versions.add(version); - index = end; - } - - versions.sort((a, b) -> b.compareTo(a)); - - return versions; - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java deleted file mode 100644 index 20482a0..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java +++ /dev/null @@ -1,167 +0,0 @@ -package me.sashegdev.zernmc.launcher.menu; - -import me.sashegdev.zernmc.launcher.auth.AuthManager; -import me.sashegdev.zernmc.launcher.auth.AuthManager.AuthResult; -import me.sashegdev.zernmc.launcher.ui.ArrowMenu; -import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; -import me.sashegdev.zernmc.launcher.utils.Input; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; - -import java.io.IOException; -import java.util.List; - -/** - * Экран входа/регистрации. - * Показывается при старте лаунчера, если нет сохранённой сессии. - * - * show() возвращает true — пользователь вошёл/зарегистрировался - * false — пользователь выбрал выход из лаунчера - */ -public class LoginMenu { - - /** - * Главный экран выбора действия. - */ - public boolean show() throws IOException { - while (true) { - ConsoleUtils.clearScreen(); - printBanner(); - - List options = List.of( - "Войти в аккаунт", - "Создать аккаунт", - "Выйти из лаунчера" - ); - - ArrowMenu menu = new ArrowMenu("Добро пожаловать в ZernMC!", options); - int choice = menu.show(); - - if (choice == -1 || choice == 2) return false; - - boolean success = switch (choice) { - case 0 -> doLogin(); - case 1 -> doRegister(); - default -> false; - }; - - if (success) return true; - // Если не успех — покажем меню снова (ошибка уже напечатана внутри методов) - } - } - - /** - * Показывается когда пользователь уже вошёл — предлагает выйти из аккаунта. - */ - public void showAccountMenu() throws IOException { - ConsoleUtils.clearScreen(); - - System.out.println(ZAnsi.header("=== Аккаунт ===")); - System.out.println(); - System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername())); - System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid())); - System.out.println(); - - List options = List.of( - "Выйти из аккаунта", - "Назад" - ); - - ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options); - int choice = menu.show(); - - if (choice == 0) { - AuthManager.logout(); - System.out.println(ZAnsi.yellow("Вы вышли из аккаунта.")); - ConsoleUtils.pause(); - } - } - - // ====================== ПРИВАТНЫЕ МЕТОДЫ ====================== - - private boolean doLogin() throws IOException { - ConsoleUtils.clearScreen(); - printBanner(); - System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]")); - System.out.println(); - - String username = Input.readLine(ZAnsi.white(" Имя пользователя: ")); - if (username.isEmpty()) return false; - - String password = readPassword(" Пароль: "); - if (password.isEmpty()) return false; - - System.out.println(); - System.out.print(ZAnsi.cyan(" Выполняем вход...")); - - AuthResult result = AuthManager.login(username, password); - - if (result.success) { - System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! ")); - ConsoleUtils.pause(); - return true; - } else { - System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " ")); - ConsoleUtils.pause(); - return false; - } - } - - private boolean doRegister() throws IOException { - ConsoleUtils.clearScreen(); - printBanner(); - System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]")); - System.out.println(); - System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _")); - System.out.println(ZAnsi.yellow(" Длина имени: 3-16 символов | Длина пароля: от 6 символов")); - System.out.println(); - - String username = Input.readLine(ZAnsi.white(" Имя пользователя: ")); - if (username.isEmpty()) return false; - - String password = readPassword(" Пароль: "); - if (password.isEmpty()) return false; - - String confirm = readPassword(" Повторите пароль: "); - if (!password.equals(confirm)) { - System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!")); - ConsoleUtils.pause(); - return false; - } - - System.out.println(); - System.out.print(ZAnsi.cyan(" Создаём аккаунт...")); - - AuthResult result = AuthManager.register(username, password); - - if (result.success) { - System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! ")); - ConsoleUtils.pause(); - return true; - } else { - System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " ")); - ConsoleUtils.pause(); - return false; - } - } - - /** - * Читаем пароль — стараемся скрыть вывод через Console, - * если недоступно (IDE/терминал без TTY) — читаем обычным способом. - */ - private String readPassword(String prompt) { - java.io.Console console = System.console(); - if (console != null) { - char[] chars = console.readPassword(prompt); - return chars != null ? new String(chars) : ""; - } - // Fallback: в IDE пароль будет виден - return Input.readLine(prompt); - } - - private void printBanner() { - System.out.println(ZAnsi.header("╔══════════════════════════════╗")); - System.out.println(ZAnsi.header("║ ZernMC Launcher ║")); - System.out.println(ZAnsi.header("╚══════════════════════════════╝")); - System.out.println(); - } -} diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java deleted file mode 100644 index 68bd045..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java +++ /dev/null @@ -1,103 +0,0 @@ -package me.sashegdev.zernmc.launcher.menu; - -import me.sashegdev.zernmc.launcher.ui.ArrowMenu; -import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; -import me.sashegdev.zernmc.launcher.utils.ZHttpClient; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.List; - -public class ServerCheckMenu { - - public void show() throws IOException { - List options = List.of( - "Проверить подключение к ZernMC серверу", - "Проверить доступ к Mojang (Minecraft)", - "Проверить доступ к Fabric Meta", - "Назад в главное меню" - ); - - ArrowMenu menu = new ArrowMenu("Диагностика подключения", options); - int choice = menu.show(); - - if (choice == -1 || choice == 4) return; - - ConsoleUtils.clearScreen(); - - switch (choice) { - case 0 -> checkZernServer(); - case 1 -> checkMojang(); - case 2 -> checkFabric(); - } - - ConsoleUtils.pause(); - } - - private void checkZernServer() { - System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу...")); - try { - String response = ZHttpClient.get("/health"); - System.out.println(ZAnsi.brightGreen("Сервер успешно подключён!")); - System.out.println("Ответ: " + response); - } catch (Exception e) { - System.out.println(ZAnsi.brightRed("Не удалось подключиться к ZernMC серверу")); - System.out.println("Ошибка: " + e.getMessage()); - } - } - - private void checkMojang() { - System.out.println(ZAnsi.cyan("Проверка доступа к Mojang...")); - try { - HttpClient client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(8)) - .build(); - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json")) - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() == 200) { - System.out.println(ZAnsi.brightGreen("Mojang доступен")); - } else { - System.out.println(ZAnsi.brightRed("Mojang вернул код " + response.statusCode())); - } - } catch (Exception e) { - System.out.println(ZAnsi.brightRed("Нет доступа к Mojang")); - System.out.println("Ошибка: " + e.getMessage()); - } - } - - private void checkFabric() { - System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta...")); - try { - HttpClient client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(8)) - .build(); - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("https://meta.fabricmc.net/v2/versions")) - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() == 200) { - System.out.println(ZAnsi.brightGreen("Fabric Meta доступен")); - } else { - System.out.println(ZAnsi.brightRed("Fabric Meta вернул код " + response.statusCode())); - } - } catch (Exception e) { - System.out.println(ZAnsi.brightRed("Нет доступа к Fabric Meta")); - System.out.println("Ошибка: " + e.getMessage()); - } - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/SettingsMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/SettingsMenu.java deleted file mode 100644 index 7c16845..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/SettingsMenu.java +++ /dev/null @@ -1,68 +0,0 @@ -package me.sashegdev.zernmc.launcher.menu; - -import me.sashegdev.zernmc.launcher.ui.ArrowMenu; -import me.sashegdev.zernmc.launcher.utils.Config; -import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; -import me.sashegdev.zernmc.launcher.utils.Input; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; - -import java.io.IOException; -import java.util.List; - -public class SettingsMenu { - - public void show() throws IOException { - List options = List.of( - "Настроить путь к Java", - "Настроить выделенную память (RAM)", - "Дополнительные JVM параметры", - "Назад в главное меню" - ); - - ArrowMenu menu = new ArrowMenu("Настройки лаунчера", options); - int choice = menu.show(); - - if (choice == -1 || choice == 3) return; - - ConsoleUtils.clearScreen(); - - switch (choice) { - case 0 -> configureJava(); - case 1 -> configureRam(); - case 2 -> configureJvmArgs(); - } - - ConsoleUtils.pause(); - } - - private void configureJava() { - System.out.println(ZAnsi.cyan("Путь к Java:")); - System.out.println(" " + Config.getJreDir().toAbsolutePath()); - System.out.println(ZAnsi.white("\nJava будет искаться автоматически в папке ~/.zernmc/jre/")); - System.out.println("Если нужно — положите туда свою версию Java."); - } - - private void configureRam() { - System.out.println(ZAnsi.cyan("Настройка выделенной памяти")); - System.out.println(Config.getRamInfo()); - - int newRam = Input.readInt( - ZAnsi.white("\nВведите новое значение RAM в MB (или 0 для отмены): "), - 0, 32768 - ); - - if (newRam == 0) { - System.out.println(ZAnsi.yellow("Настройка отменена.")); - return; - } - - Config.setMaxMemory(newRam); - System.out.println(ZAnsi.brightGreen("Выделенная память изменена на " + newRam + " MB")); - } - - private void configureJvmArgs() { - System.out.println(ZAnsi.yellow("Дополнительные JVM параметры")); - System.out.println("Пока в разработке."); - System.out.println("В будущем здесь будет список предустановленных оптимизаций."); - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java deleted file mode 100644 index 0839625..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java +++ /dev/null @@ -1,178 +0,0 @@ -package me.sashegdev.zernmc.launcher.minecraft; - -import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller; -import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller; -import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; -import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; -import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; -import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -public class MinecraftLib { - - private final Instance instance; - - public MinecraftLib(Instance instance) { - this.instance = instance; - } - - //Установка - public boolean installMinecraft(String versionId) throws Exception { - VersionInstaller installer = new VersionInstaller(instance.getPath()); - - String assetIndex = installer.install(versionId); // ← теперь возвращается String - - if (assetIndex != null && !assetIndex.isEmpty()) { - instance.setMinecraftVersion(versionId); - instance.setAssetIndex(assetIndex); // ← сохраняем правильный индекс! - instance.setLoaderType("vanilla"); - return true; - } - return false; - } - - public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception { - ForgeInstaller installer = new ForgeInstaller(instance); - return installer.install(minecraftVersion, forgeVersion); - } - - public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception { - FabricInstaller installer = new FabricInstaller(instance); - boolean success = installer.install(minecraftVersion, loaderVersion); - - if (success) { - // Сохраняем информацию в Instance - instance.setMinecraftVersion(minecraftVersion); - instance.setLoaderType("fabric"); - instance.setLoaderVersion(loaderVersion); - } - return success; - } - - /** - * Полная установка сборки (vanilla + loader + моды) - * Пока заглушка — будем расширять - */ - public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception { - System.out.println(ZAnsi.cyan("Начинается полная установка сборки: " + packName)); - - // 1. Устанавливаем Minecraft - boolean mcInstalled = installMinecraft(minecraftVersion); - if (!mcInstalled) { - System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion)); - return false; - } - - // 2. Устанавливаем лоадер - if ("fabric".equalsIgnoreCase(loaderType)) { - boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion); - if (!fabricInstalled) { - System.out.println(ZAnsi.brightRed("Не удалось установить Fabric")); - return false; - } - } else if ("forge".equalsIgnoreCase(loaderType)) { - System.out.println(ZAnsi.yellow("Forge пока не поддерживается")); - return false; - } - - // 3. В будущем здесь будет diff и скачивание модов - - System.out.println(ZAnsi.brightGreen("Базовая установка сборки завершена!")); - return true; - } - - //Запуск - public void launch(LaunchOptions options) throws Exception { - System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName())); - cleanupOldLoaders(); - - LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); - List command = builder.build(options); - - System.out.println(ZAnsi.cyan("Команда запуска (" + command.size() + " аргументов):")); - command.forEach(arg -> System.out.println(" " + arg)); - - ProcessBuilder pb = new ProcessBuilder(command); - pb.directory(instance.getPath().toFile()); - pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); - pb.redirectError(ProcessBuilder.Redirect.INHERIT); - pb.redirectInput(ProcessBuilder.Redirect.INHERIT); - - System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n")); - ConsoleUtils.clearScreen(); - - Process process = pb.start(); - int exitCode = process.waitFor(); - - System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode)); - } - - private void safeDeleteDirectory(Path dir) { - try { - Files.walk(dir) - .sorted((a, b) -> b.compareTo(a)) - .forEach(p -> { - try { Files.deleteIfExists(p); } - catch (IOException ignored) {} - }); - } catch (IOException ignored) {} - } - - private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException { - if (!Files.exists(versionsDir)) return; - - try (var stream = Files.walk(versionsDir)) { - stream.filter(Files::isDirectory) - .filter(dir -> dir.getFileName().toString().contains("fabric-loader") || - dir.getFileName().toString().contains("forge")) - .filter(dir -> !dir.getFileName().toString().contains(keepVersion)) - .forEach(this::safeDeleteDirectory); - } - } - - private void deleteAllExcept(Path baseDir, String keepVersion) throws IOException { - if (!Files.exists(baseDir)) return; - - try (var stream = Files.walk(baseDir)) { - stream.filter(Files::isDirectory) - .filter(dir -> { - String name = dir.getFileName().toString(); - return name.contains(".") && !name.contains(keepVersion); - }) - .forEach(this::safeDeleteDirectory); - } - } - - private void cleanupOldLoaders() throws IOException { - String loaderType = instance.getLoaderType().toLowerCase(); - String currentLoaderVer = instance.getLoaderVersion(); - - if (currentLoaderVer == null) return; - - System.out.println(ZAnsi.yellow("Выполняем очистку старых версий лоадера...")); - - // Удаляем все старые fabric-loader / forge - Path libraries = instance.getPath().resolve("libraries"); - - if ("fabric".equals(loaderType)) { - deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer); - } else if ("forge".equals(loaderType)) { - deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer); - } - - // Также чистим versions/ от старых fabric/forge версий - Path versionsDir = instance.getPath().resolve("versions"); - deleteOldVersionDirs(versionsDir, currentLoaderVer); - } - - - - public Instance getInstance() { - return instance; - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java deleted file mode 100644 index 85d42b5..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java +++ /dev/null @@ -1,544 +0,0 @@ -package me.sashegdev.zernmc.launcher.minecraft; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import me.sashegdev.zernmc.launcher.utils.ProgressBar; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; -import me.sashegdev.zernmc.launcher.utils.ZHttpClient; - -import java.io.*; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.MessageDigest; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; - -public class PackDownloader { - - private final Instance instance; - private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - private final HttpClient httpClient = HttpClient.newHttpClient(); - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; - - public PackDownloader(Instance instance) { - this.instance = instance; - } - - /** - * Получить список доступных паков с сервера - */ - public List getAvailablePacks() throws Exception { - String response = ZHttpClient.get("/packs"); - - // Для отладки - выведем ответ сервера - System.out.println(ZAnsi.cyan("Ответ сервера: " + response)); - - JsonObject root = JsonParser.parseString(response).getAsJsonObject(); - - // Проверяем, есть ли поле "packs" - if (!root.has("packs")) { - System.out.println(ZAnsi.yellow("Сервер вернул неожиданный формат ответа")); - return new ArrayList<>(); - } - - JsonArray packsArray = root.getAsJsonArray("packs"); - List result = new ArrayList<>(); - - for (JsonElement elem : packsArray) { - JsonObject pack = elem.getAsJsonObject(); - - // Пропускаем паки с ошибками - if (pack.has("error")) { - System.out.println(ZAnsi.yellow("Пак имеет ошибку: " + pack.get("error").getAsString())); - continue; - } - - // Пропускаем паки со статусом not_scanned - if (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString())) { - System.out.println(ZAnsi.yellow("Пак " + pack.get("name").getAsString() + " не отсканирован на сервере")); - continue; - } - - try { - // Пробуем получить name или pack_name (разные форматы) - String name = null; - if (pack.has("name")) { - name = pack.get("name").getAsString(); - } else if (pack.has("pack_name")) { - name = pack.get("pack_name").getAsString(); - } else { - continue; // Пропускаем если нет имени - } - - int version = pack.has("version") ? pack.get("version").getAsInt() : 0; - - // Получаем остальные поля (могут отсутствовать) - String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown"; - String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla"; - String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() ? pack.get("loader_version").getAsString() : ""; - int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0; - - // Парсим дату, если есть - LocalDateTime updatedAt = null; - if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) { - try { - updatedAt = parseDateTime(pack.get("updated_at").getAsString()); - } catch (Exception e) { - // Игнорируем ошибки парсинга даты - } - } - - result.add(new ServerPack(name, version, minecraftVersion, - loaderType, loaderVersion, updatedAt, filesCount)); - } catch (Exception e) { - System.err.println(ZAnsi.yellow("Ошибка парсинга пака: " + e.getMessage())); - } - } - - return result; - } - - /** - * Получить манифест пака - */ - public PackManifest getPackManifest(String packName) throws Exception { - String response = ZHttpClient.get("/pack/" + packName); - return gson.fromJson(response, PackManifest.class); - } - - /** - * Установить или обновить сборку с сервера - */ - public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception { - System.out.println(ZAnsi.cyan("Установка сборки " + packName + " с сервера...")); - - // 1. Получаем манифест - PackManifest manifest = getPackManifest(packName); - - // 2. Сначала устанавливаем Minecraft + Loader через MinecraftLib - MinecraftLib lib = new MinecraftLib(instance); - - System.out.println(ZAnsi.cyan("Установка Minecraft " + manifest.getMinecraftVersion() + "...")); - - boolean needsMinecraftInstall = instance.getMinecraftVersion() == null || - !instance.getMinecraftVersion().equals(manifest.getMinecraftVersion()); - - if (needsMinecraftInstall) { - if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) { - boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); - if (!success) { - System.err.println(ZAnsi.brightRed("Не удалось установить Fabric")); - return false; - } - } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { - boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); - if (!success) { - System.err.println(ZAnsi.brightRed("Не удалось установить Forge")); - return false; - } - } else { - boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); - if (!success) { - System.err.println(ZAnsi.brightRed("Не удалось установить Vanilla Minecraft")); - return false; - } - } - } else { - System.out.println(ZAnsi.green("Minecraft уже установлен, пропускаем...")); - } - - // 3. Сканируем локальные файлы ТОЛЬКО если есть файлы для скачивания - Map localFiles = scanLocalFiles(); - - // Если в сборке нет файлов (только vanilla/loader), пропускаем diff - if (manifest.files == null || manifest.files.isEmpty()) { - System.out.println(ZAnsi.green("Сборка не содержит дополнительных файлов")); - - // Обновляем метаданные инстанса - instance.setServerPack(true); - instance.setServerPackName(packName); - instance.setServerVersion(manifest.getVersion()); - instance.setMinecraftVersion(manifest.getMinecraftVersion()); - instance.setLoaderType(manifest.getLoaderType()); - instance.setLoaderVersion(manifest.getLoaderVersion()); - instance.setAssetIndex(manifest.getAssetIndex()); - - System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!")); - return true; - } - - // 4. Отправляем diff запрос - System.out.println(ZAnsi.cyan("Проверка файлов сборки...")); - DiffResponse diff = getDiff(packName, localFiles); - - // 5. Применяем изменения - boolean success = applyDiff(diff, packName); - - if (success) { - // 6. Обновляем метаданные инстанса - instance.setServerPack(true); - instance.setServerPackName(packName); - instance.setServerVersion(manifest.getVersion()); - instance.setMinecraftVersion(manifest.getMinecraftVersion()); - instance.setLoaderType(manifest.getLoaderType()); - instance.setLoaderVersion(manifest.getLoaderVersion()); - instance.setAssetIndex(manifest.getAssetIndex()); - - System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!")); - } - - return success; - } - - /** - * Проверить наличие обновлений для серверной сборки - */ - public boolean checkForUpdates(String packName) throws Exception { - if (!instance.isServerPack()) return false; - - PackManifest manifest = getPackManifest(packName); - int serverVersion = manifest.getVersion(); - int localVersion = instance.getServerVersion(); - - return serverVersion > localVersion; - } - - /** - * Обновить существующую серверную сборку - */ - public boolean updatePack(String packName) throws Exception { - System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName() + "...")); - - PackManifest manifest = getPackManifest(packName); - int serverVersion = manifest.getVersion(); - - if (serverVersion <= instance.getServerVersion()) { - System.out.println(ZAnsi.green("Сборка уже актуальна (v" + instance.getServerVersion() + ")")); - return true; - } - - System.out.println(ZAnsi.yellow("Доступно обновление: v" + instance.getServerVersion() + " → v" + serverVersion)); - - // Сканируем локальные файлы - Map localFiles = scanLocalFiles(); - - // Получаем diff - DiffResponse diff = getDiff(packName, localFiles); - - // Применяем изменения - boolean success = applyDiff(diff, packName); - - if (success) { - instance.setServerVersion(serverVersion); - System.out.println(ZAnsi.brightGreen("Сборка обновлена до v" + serverVersion)); - } - - return success; - } - - /** - * Сканирование локальных файлов и вычисление хешей - */ - private Map scanLocalFiles() throws IOException { - Map files = new HashMap<>(); - Path instancePath = instance.getPath(); - - // Игнорируемые директории - Set ignoredDirs = Set.of( - "resourcepacks", "shaderpacks", "saves", "logs", - "crash-reports", "screenshots", "journeymap", "config", - "natives", "assets", "libraries", "versions", "cache" - ); - - if (!Files.exists(instancePath)) { - return files; - } - - Files.walk(instancePath) - .filter(Files::isRegularFile) - .forEach(file -> { - Path relative = instancePath.relativize(file); - String path = relative.toString().replace("\\", "/"); - - // Проверяем, не в игнорируемой ли директории - for (String ignored : ignoredDirs) { - if (path.startsWith(ignored + "/") || path.startsWith(ignored + "\\")) { - return; - } - } - - try { - String hash = calculateHash(file); - files.put(path, hash); - } catch (Exception e) { - // Пропускаем файлы, которые не можем прочитать - } - }); - - return files; - } - - /** - * Отправить diff запрос на сервер - */ - private DiffResponse getDiff(String packName, Map localFiles) throws Exception { - String json = gson.toJson(localFiles); - - System.out.println(ZAnsi.cyan("Отправка diff запроса для " + packName)); - System.out.println(ZAnsi.cyan("JSON размер: " + json.length() + " байт")); - System.out.println(ZAnsi.cyan("JSON тело: " + json)); - - String baseUrl = ZHttpClient.getBaseUrl(); - if (baseUrl.endsWith("/")) { - baseUrl = baseUrl.substring(0, baseUrl.length() - 1); - } - String url = baseUrl + "/pack/" + packName + "/diff"; - - System.out.println(ZAnsi.cyan("URL: " + url)); - - // ПРОБЛЕМА: стандартный HttpClient может отправлять chunked encoding - // РЕШЕНИЕ: используем HttpURLConnection вместо HttpClient - - java.net.HttpURLConnection connection = null; - try { - java.net.URL urlObj = new java.net.URL(url); - connection = (java.net.HttpURLConnection) urlObj.openConnection(); - connection.setRequestMethod("POST"); - connection.setRequestProperty("Content-Type", "application/json"); - connection.setRequestProperty("Accept", "application/json"); - connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length)); - connection.setDoOutput(true); - connection.setConnectTimeout(30000); - connection.setReadTimeout(30000); - - // Отправляем JSON - try (java.io.OutputStream os = connection.getOutputStream()) { - byte[] input = json.getBytes("UTF-8"); - os.write(input, 0, input.length); - os.flush(); - } - - int responseCode = connection.getResponseCode(); - System.out.println(ZAnsi.cyan("Diff ответ: HTTP " + responseCode)); - - // Читаем ответ - StringBuilder response = new StringBuilder(); - try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); - java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) { - String line; - while ((line = reader.readLine()) != null) { - response.append(line); - } - } - - String responseBody = response.toString(); - System.out.println(ZAnsi.cyan("Тело ответа: " + responseBody)); - - if (responseCode != 200) { - throw new IOException("HTTP " + responseCode + ": " + responseBody); - } - - return gson.fromJson(responseBody, DiffResponse.class); - - } finally { - if (connection != null) { - connection.disconnect(); - } - } - } - - /** - * Применить diff (скачать новые файлы, удалить старые) - */ - private boolean applyDiff(DiffResponse diff, String packName) { - System.out.println(ZAnsi.cyan("\nПрименение изменений:")); - System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов"); - System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов"); - - // Создаем директории если нужно - try { - Files.createDirectories(instance.getPath()); - } catch (IOException e) { - System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage())); - return false; - } - - // Удаляем файлы - for (String filePath : diff.getToDelete()) { - Path fullPath = instance.getPath().resolve(filePath); - try { - if (Files.deleteIfExists(fullPath)) { - System.out.println(ZAnsi.yellow(" Удален: " + filePath)); - } - } catch (IOException e) { - System.err.println(ZAnsi.red(" Ошибка удаления " + filePath + ": " + e.getMessage())); - } - } - - // Скачиваем файлы - AtomicInteger downloaded = new AtomicInteger(0); - int total = diff.getToDownload().size(); - - for (FileInfo file : diff.getToDownload()) { - String path = file.getPath(); - Path fullPath = instance.getPath().resolve(path); - - try { - // Создаем директории - Files.createDirectories(fullPath.getParent()); - - // Скачиваем файл - downloadFile(file, fullPath); - - // Проверяем хеш - String actualHash = calculateHash(fullPath); - if (!actualHash.equals(file.getHash())) { - throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() + - ", получен: " + actualHash); - } - - downloaded.incrementAndGet(); - if (total > 0) { - ProgressBar.show("Скачивание", downloaded.get(), total, "файлов"); - } - - } catch (Exception e) { - System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage())); - return false; - } - } - - if (total > 0) { - ProgressBar.finish("Скачивание"); - } - - return true; - } - - /** - * Скачать один файл с сервера - */ - private void downloadFile(FileInfo file, Path destination) throws Exception { - String url = ZHttpClient.getBaseUrl() + file.getUrl(); - - HttpRequest request = HttpRequest.newBuilder() - .uri(java.net.URI.create(url)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, - HttpResponse.BodyHandlers.ofInputStream()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); - } - - // Скачиваем с прогрессом - try (InputStream in = response.body(); - FileOutputStream out = new FileOutputStream(destination.toFile())) { - - byte[] buffer = new byte[8192]; - int bytesRead; - long totalRead = 0; - long fileSize = file.getSize(); - - while ((bytesRead = in.read(buffer)) != -1) { - out.write(buffer, 0, bytesRead); - totalRead += bytesRead; - - if (fileSize > 0 && totalRead % 8192 == 0) { - ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize); - } - } - } - - ProgressBar.clearLine(); - } - - /** - * Вычисление SHA256 хеша файла - */ - private String calculateHash(Path file) throws Exception { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - - try (InputStream in = Files.newInputStream(file)) { - byte[] buffer = new byte[8192]; - int bytesRead; - while ((bytesRead = in.read(buffer)) != -1) { - digest.update(buffer, 0, bytesRead); - } - } - - byte[] hashBytes = digest.digest(); - StringBuilder sb = new StringBuilder(); - for (byte b : hashBytes) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } - - /** - * Парсинг даты из строки - */ - private LocalDateTime parseDateTime(String dateTimeStr) { - try { - return LocalDateTime.parse(dateTimeStr, DATE_FORMATTER); - } catch (Exception e) { - return null; - } - } - - // ====================== Вложенные классы ====================== - - public static class PackManifest { - private String pack_name; - private int version; - private String minecraft_version; - private String loader_type; - private String loader_version; - private String asset_index; - private Map files; - - public String getPackName() { return pack_name; } - public int getVersion() { return version; } - public String getMinecraftVersion() { return minecraft_version; } - public String getLoaderType() { return loader_type; } - public String getLoaderVersion() { return loader_version; } - public String getAssetIndex() { return asset_index != null ? asset_index : minecraft_version; } - public Map getFiles() { return files; } - public boolean isEmpty() { return files == null || files.isEmpty(); } - } - - public static class DiffResponse { - private int version; - private List to_download; - private List to_delete; - private List to_update; - - public int getVersion() { return version; } - public List getToDownload() { return to_download != null ? to_download : new ArrayList<>(); } - public List getToDelete() { return to_delete != null ? to_delete : new ArrayList<>(); } - public List getToUpdate() { return to_update != null ? to_update : new ArrayList<>(); } - } - - public static class FileInfo { - private String path; - private String url; - private long size; - private String hash; - - public String getPath() { return path; } - public String getUrl() { return url; } - public long getSize() { return size; } - public String getHash() { return hash; } - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java deleted file mode 100644 index ebfb896..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java +++ /dev/null @@ -1,213 +0,0 @@ -package me.sashegdev.zernmc.launcher.minecraft.installer; - -import me.sashegdev.zernmc.launcher.minecraft.Instance; -import me.sashegdev.zernmc.launcher.utils.ProgressBar; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; -import me.sashegdev.zernmc.launcher.utils.ZHttpClient; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; - -public class FabricInstaller { - - private final Instance instance; - private final HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(15)) - .build(); - - public FabricInstaller(Instance instance) { - this.instance = instance; - } - - public boolean install(String minecraftVersion, String loaderVersion) throws Exception { - System.out.println(ZAnsi.cyan("Установка Fabric " + loaderVersion + " для Minecraft " + minecraftVersion)); - - Path instancePath = instance.getPath(); - cleanOldFabricLoaders(); - - VersionInstaller versionInstaller = new VersionInstaller(instancePath); - String assetIndex = versionInstaller.install(minecraftVersion); - - System.out.println(ZAnsi.green("Asset index получен: " + assetIndex)); - - instance.setAssetIndex(assetIndex); - instance.setMinecraftVersion(minecraftVersion); - - String installerVersion = getLatestInstallerVersion(); - String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/" - + installerVersion + "/fabric-installer-" + installerVersion + ".jar"; - - Path installerJar = instancePath.resolve("fabric-installer.jar"); - - if (!Files.exists(installerJar)) { - ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%"); - downloadFileWithFallback(installerUrl, installerJar); - ProgressBar.finish("Fabric Installer скачан"); - } - - System.out.println(ZAnsi.cyan("Запуск Fabric Installer...")); - - String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion; - - ProcessBuilder pb = new ProcessBuilder( - "java", "-jar", installerJar.toAbsolutePath().toString(), - "client", - "-dir", instancePath.toAbsolutePath().toString(), - "-mcversion", minecraftVersion, - "-loader", loaderVersion, - "-noprofile" - ); - - pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); - pb.redirectError(ProcessBuilder.Redirect.INHERIT); - - Process process = pb.start(); - int exitCode = process.waitFor(); - - if (exitCode != 0) { - System.out.println(ZAnsi.brightRed("Fabric Installer завершился с ошибкой (код " + exitCode + ")")); - return false; - } - - Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId); - - if (Files.exists(fabricVersionDir)) { - System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!")); - - instance.setLoaderType("fabric"); - instance.setLoaderVersion(loaderVersion); - instance.setFabricVersionId(fabricVersionId); // ← СОХРАНЯЕМ - - ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex); - - return true; - } else { - System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена.")); - return false; - } - } - - private void downloadFileWithFallback(String url, Path target) throws Exception { - try { - ZHttpClient.downloadFile(url, target); - } catch (Exception e) { - System.out.println(ZAnsi.yellow("Не удалось скачать Fabric Installer: " + e.getMessage())); - throw e; - } - } - - private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException { - Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json"); - - if (!Files.exists(versionJson)) { - System.out.println(ZAnsi.yellow("JSON файл версии не найден: " + versionJson)); - return; - } - - String content = Files.readString(versionJson); - - // Проверяем и исправляем asset index - if (!content.contains("\"assets\":\"" + assetIndex + "\"")) { - System.out.println(ZAnsi.yellow("Исправляем asset index в JSON файле версии...")); - - // Заменяем assets на правильное значение - content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\""); - - // Также проверяем assetIndex - if (content.contains("\"assetIndex\"")) { - content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\""); - } - - Files.writeString(versionJson, content); - System.out.println(ZAnsi.green("Asset index исправлен на: " + assetIndex)); - } else { - System.out.println(ZAnsi.green("Asset index в JSON версии правильный: " + assetIndex)); - } - } - - private void cleanOldFabricLoaders() throws IOException { - Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader"); - if (!Files.exists(librariesDir)) return; - - System.out.println(ZAnsi.yellow("Очистка старых версий Fabric Loader...")); - - try (var stream = Files.walk(librariesDir)) { - stream.filter(Files::isDirectory) - .filter(dir -> dir.getFileName().toString().matches("\\d+\\.\\d+\\.\\d+.*")) - .forEach(dir -> { - try { - Files.walk(dir) - .sorted((a,b) -> b.compareTo(a)) - .forEach(p -> { - try { Files.deleteIfExists(p); } - catch (IOException ignored) {} - }); - } catch (IOException ignored) {} - }); - } - } - - private String getLatestInstallerVersion() throws Exception { - try { - // Используем ZHttpClient с умным прокси - String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml"); - int start = xml.indexOf("") + 8; - int end = xml.indexOf("", start); - return xml.substring(start, end).trim(); - } catch (Exception e) { - System.out.println(ZAnsi.yellow("Ошибка получения версии Fabric Installer: " + e.getMessage())); - throw new Exception("Не удалось получить версию Fabric Installer", e); - } - } - - // под рефактор оставить - private String downloadString(String url) throws Exception { - Exception lastException = null; - - for (int attempt = 1; attempt <= 3; attempt++) { - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(30 * attempt)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); - - HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() == 200) { - return resp.body(); - } - throw new IOException("HTTP " + resp.statusCode()); - } catch (Exception e) { - lastException = e; - System.out.println(ZAnsi.yellow("Попытка " + attempt + " не удалась: " + e.getMessage())); - if (attempt < 3) { - Thread.sleep(1000 * attempt); - } - } - } - - throw lastException; - } - - private void downloadFile(String url, Path target) throws Exception { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(60)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, - HttpResponse.BodyHandlers.ofFile(target)); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode() + " при скачивании " + url); - } - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java deleted file mode 100644 index 2330fd6..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java +++ /dev/null @@ -1,270 +0,0 @@ -package me.sashegdev.zernmc.launcher.minecraft.installer; - -import me.sashegdev.zernmc.launcher.minecraft.Instance; -import me.sashegdev.zernmc.launcher.utils.ProgressBar; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; -import java.io.*; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.HashMap; -import java.util.Map; - -public class ForgeInstaller { - - private final Instance instance; - private final HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(java.time.Duration.ofSeconds(30)) - .build(); - - public ForgeInstaller(Instance instance) { - this.instance = instance; - } - - public boolean install(String mcVersion, String forgeVersion) throws Exception { - System.out.println(ZAnsi.cyan("Установка Forge " + forgeVersion + " для Minecraft " + mcVersion)); - - // Шаг 1: Устанавливаем vanilla и получаем настоящий assetIndex - System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "...")); - VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); - - String assetIndex = vanillaInstaller.install(mcVersion); - - if (assetIndex == null || assetIndex.isEmpty()) { - System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft")); - return false; - } - - instance.setAssetIndex(assetIndex); - - // Шаг 2: Создаём launcher_profiles.json - createLauncherProfile(); - - // Шаг 3: Скачиваем Forge Installer с прогресс-баром - String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/" - + mcVersion + "-" + forgeVersion - + "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar"; - - Path installerJar = instance.getPath().resolve("forge-installer.jar"); - - System.out.println(ZAnsi.cyan("Скачивание Forge Installer...")); - downloadFileWithProgress(installerUrl, installerJar); - - // Шаг 4: Запускаем Forge Installer и показываем его вывод - System.out.println(ZAnsi.cyan("Запуск Forge Installer...")); - System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n")); - - boolean success = runForgeInstaller(installerJar); - - // После успешной установки Forge, но перед сохранением метаданных - if (success) { - // Докачиваем пропущенные библиотеки - try { - downloadMissingLibraries(mcVersion, forgeVersion); - } catch (Exception e) { - System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage())); - } - - System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " успешно установлен!")); - instance.setMinecraftVersion(mcVersion); - instance.setLoaderType("forge"); - instance.setLoaderVersion(forgeVersion); - - // Очищаем временный файл установщика - Files.deleteIfExists(installerJar); - return true; - } else { - System.out.println(ZAnsi.brightRed("\nОшибка при установке Forge!")); - return false; - } - } - - private void createLauncherProfile() throws IOException { - Path profilePath = instance.getPath().resolve("launcher_profiles.json"); - if (Files.exists(profilePath)) return; - - String minimalProfile = """ - { - "profiles": {}, - "selectedProfile": "Default" - } - """; - Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - System.out.println(ZAnsi.yellow("Создан launcher_profiles.json")); - } - - private void downloadFileWithProgress(String url, Path target) throws Exception { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); - } - - long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1); - - try (InputStream in = response.body(); - FileOutputStream out = new FileOutputStream(target.toFile())) { - - byte[] buffer = new byte[8192]; - int bytesRead; - long totalRead = 0; - int lastPercent = -1; - - while ((bytesRead = in.read(buffer)) != -1) { - out.write(buffer, 0, bytesRead); - totalRead += bytesRead; - - if (contentLength > 0) { - int percent = (int) ((totalRead * 100) / contentLength); - if (percent != lastPercent) { - String downloaded = ProgressBar.formatBytes(totalRead); - String total = ProgressBar.formatBytes(contentLength); - ProgressBar.show("Forge Installer", percent, 100, "% (" + downloaded + "/" + total + ")"); - lastPercent = percent; - } - } else { - // Если размер неизвестен, показываем анимацию - char[] spinner = {'|', '/', '-', '\\'}; - int idx = (int) (totalRead / 1024) % 4; - System.out.print("\rСкачивание Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]); - } - } - } - - ProgressBar.finish("Forge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")"); - } - - private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException { - // Пробуем до 3 раз с разными опциями - int maxRetries = 3; - int attempt = 1; - - while (attempt <= maxRetries) { - System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries)); - - ProcessBuilder pb = new ProcessBuilder( - "java", - "-jar", - installerJar.toAbsolutePath().toString(), - "--installClient" - ); - - // Добавляем JVM аргументы для увеличения таймаутов - pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); - - pb.directory(instance.getPath().toFile()); - pb.redirectErrorStream(true); - - Process process = pb.start(); - - // Читаем вывод в реальном времени - StringBuilder output = new StringBuilder(); - boolean hasErrors = false; - - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - output.append(line).append("\n"); - - // Форматируем вывод Forge Installer - if (line.contains("Downloading") || line.contains("Extracting")) { - System.out.println(ZAnsi.blue(" -> " + line)); - } else if (line.contains("SUCCESS") || line.contains("successfully")) { - System.out.println(ZAnsi.brightGreen(" + " + line)); - } else if (line.contains("WARNING") || line.contains("warning")) { - System.out.println(ZAnsi.yellow(" ! " + line)); - } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { - System.out.println(ZAnsi.brightRed(" X " + line)); - if (line.contains("timed out") || line.contains("failed to download")) { - hasErrors = true; - } - } else if (!line.isBlank()) { - System.out.println(" " + line); - } - } - } - - int exitCode = process.waitFor(); - - // Если успешно или нет ошибок скачивания - if (exitCode == 0 && !hasErrors) { - return true; - } - - // Если ошибка и это не последняя попытка - if (attempt < maxRetries) { - System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд...")); - Thread.sleep(5000); - - // Очищаем временные файлы перед повтором - Path librariesDir = instance.getPath().resolve("libraries"); - if (Files.exists(librariesDir)) { - // Удаляем только частично скачанные библиотеки Forge - try (var stream = Files.walk(librariesDir)) { - stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar")) - .forEach(p -> { - try { Files.deleteIfExists(p); } - catch (IOException e) { /* ignore */ } - }); - } - } - } else { - System.out.println(ZAnsi.brightRed("Forge Installer завершился с кодом ошибки: " + exitCode)); - - // Показываем возможное решение - if (output.toString().contains("timed out")) { - System.out.println(ZAnsi.yellow("\nВозможные решения:")); - System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение")); - System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора")); - System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр")); - System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию Forge")); - } - } - - attempt++; - } - - return false; - } - - private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception { - System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек...")); - - // Список проблемных библиотек и их альтернативные URL - Map alternativeUrls = new HashMap<>(); - alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", - "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar"); - alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", - "https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"); - - Path librariesDir = instance.getPath().resolve("libraries"); - - for (Map.Entry entry : alternativeUrls.entrySet()) { - Path target = librariesDir.resolve(entry.getKey()); - if (!Files.exists(target)) { - Files.createDirectories(target.getParent()); - System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName())); - - for (int attempt = 1; attempt <= 3; attempt++) { - try { - downloadFileWithProgress(entry.getValue(), target); - break; - } catch (Exception e) { - if (attempt == 3) throw e; - System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3...")); - Thread.sleep(2000); - } - } - } - } - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java deleted file mode 100644 index 3c76009..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java +++ /dev/null @@ -1,245 +0,0 @@ -package me.sashegdev.zernmc.launcher.minecraft.installer; - -import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion; -import me.sashegdev.zernmc.launcher.utils.ProgressBar; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; -import org.json.JSONArray; -import org.json.JSONObject; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class VersionInstaller { - - private final Path minecraftDir; - private final HttpClient httpClient; - private final ExecutorService executor = Executors.newFixedThreadPool(32); - - public VersionInstaller(Path minecraftDir) { - this.minecraftDir = minecraftDir; - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(15)) - .build(); - } - - public List getAvailableVersions() throws Exception { - String jsonString = downloadString("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"); - JSONObject root = new JSONObject(jsonString); - JSONArray versionsArray = root.getJSONArray("versions"); - - List versions = new ArrayList<>(); - - for (int i = 0; i < versionsArray.length(); i++) { - JSONObject v = versionsArray.getJSONObject(i); - String id = v.getString("id"); - String type = v.getString("type"); - String releaseTimeStr = v.getString("releaseTime").replace("Z", "").replace("+00:00", ""); - String url = v.getString("url"); - - LocalDateTime releaseTime = LocalDateTime.parse(releaseTimeStr); - versions.add(new MinecraftVersion(id, type, releaseTime, url)); - } - - versions.sort((a, b) -> b.getReleaseTime().compareTo(a.getReleaseTime())); - return versions; - } - - public String install(String versionId) throws Exception { - System.out.println(ZAnsi.cyan("Полная установка Minecraft " + versionId + "...")); - Path versionDir = minecraftDir.resolve("versions").resolve(versionId); - Files.createDirectories(versionDir); - - String versionUrl = getVersionUrl(versionId); - if (versionUrl == null) throw new Exception("Версия " + versionId + " не найдена"); - - String versionJson = downloadString(versionUrl); - Files.writeString(versionDir.resolve(versionId + ".json"), versionJson); - - JSONObject versionData = new JSONObject(versionJson); - - // client.jar - downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"), - versionDir.resolve(versionId + ".jar"), "client.jar"); - - // Библиотеки - System.out.println(ZAnsi.cyan("Скачивание библиотек...")); - downloadLibraries(versionData.getJSONArray("libraries")); - - String assetIndex; - if (versionData.has("assetIndex")) { - assetIndex = versionData.getJSONObject("assetIndex").getString("id"); - } else { - assetIndex = versionData.getString("assets"); - } - - System.out.println(ZAnsi.cyan("Asset index: " + assetIndex)); - - // Скачиваем ассеты используя правильный индекс - System.out.println(ZAnsi.cyan("Скачивание ассетов...")); - downloadAssets(versionData, assetIndex); - - System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " полностью установлен!")); - return assetIndex; // ← возвращаем "5" а не "1.20.1" - } - - private void downloadLibraries(JSONArray libraries) throws Exception { - int total = libraries.length(); - int count = 0; - - for (int i = 0; i < total; i++) { - JSONObject lib = libraries.getJSONObject(i); - if (lib.has("downloads") && lib.getJSONObject("downloads").has("artifact")) { - JSONObject art = lib.getJSONObject("downloads").getJSONObject("artifact"); - String url = art.getString("url"); - String path = art.getString("path"); - - Path target = minecraftDir.resolve("libraries").resolve(path); - Files.createDirectories(target.getParent()); - - try { - downloadFile(url, target, "library"); - } catch (Exception e) { - // Пропускаем проблемные библиотеки - } - } - count++; - ProgressBar.show("Библиотеки", count, total, "файлов"); - } - ProgressBar.finish("Библиотеки загружены"); - } - - private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception { - // Находим URL для asset index - JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex"); - String indexUrl = assetIndexInfo.getString("url"); - - Path indexesDir = minecraftDir.resolve("assets/indexes"); - Files.createDirectories(indexesDir); - Path indexPath = indexesDir.resolve(assetIndex + ".json"); // ← используем assetIndex - - System.out.println(ZAnsi.cyan("Скачивание asset index (" + assetIndex + ")...")); - downloadFile(indexUrl, indexPath, "asset index"); - - String jsonContent = Files.readString(indexPath); - JSONObject root = new JSONObject(jsonContent); - JSONObject objects = root.getJSONObject("objects"); - - System.out.println(ZAnsi.cyan("Скачивание " + objects.length() + " объектов ассетов (index: " + assetIndex + ")...")); - - int total = objects.length(); - int[] success = {0}; - int[] failed = {0}; - - List> futures = new ArrayList<>(); - - for (String key : objects.keySet()) { - JSONObject asset = objects.getJSONObject(key); - String hash = asset.getString("hash"); // ← вот это правильный хеш! - - String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; - Path target = minecraftDir.resolve("assets/objects") - .resolve(hash.substring(0, 2)) - .resolve(hash); - - Files.createDirectories(target.getParent()); - - CompletableFuture future = CompletableFuture.runAsync(() -> { - boolean downloaded = false; - for (int attempt = 1; attempt <= 3; attempt++) { - try { - downloadFile(url, target, ""); - synchronized (this) { - success[0]++; - ProgressBar.show("Ассеты", success[0], total, "файлов"); - } - downloaded = true; - break; - } catch (Exception e) { - if (attempt == 3) { - synchronized (this) { - failed[0]++; - } - System.err.println("Не удалось скачать " + hash); - } else { - try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {} - } - } - } - }, executor); - - futures.add(future); - } - - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - - ProgressBar.finish("Ассеты загружены (" + success[0] + " успешно, " + failed[0] + " пропущено)"); - - if (failed[0] > 0) { - System.out.println(ZAnsi.yellow("Предупреждение: " + failed[0] + " файлов ассетов не удалось скачать.")); - System.out.println(ZAnsi.yellow("Игра запустится, но некоторые текстуры/звуки могут отсутствовать.")); - } - } - - public String getAssetIndexId(String versionId) throws Exception { - String versionUrl = getVersionUrl(versionId); - if (versionUrl == null) throw new Exception("Версия не найдена"); - - String versionJson = downloadString(versionUrl); - JSONObject versionData = new JSONObject(versionJson); - - if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) { - return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1 - } - return versionData.getString("assets"); // fallback (очень старые версии) - } - - private String getVersionUrl(String versionId) throws Exception { - for (MinecraftVersion v : getAvailableVersions()) { - if (v.getId().equals(versionId)) return v.getUrl(); - } - return null; - } - - private String downloadString(String url) throws Exception { - HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); - HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() != 200) throw new IOException("HTTP " + resp.statusCode()); - return resp.body(); - } - - private void downloadFile(String url, Path target, String label) throws Exception { - if (!label.isEmpty()) { - ProgressBar.clearLine(); - System.out.println(ZAnsi.cyan("Скачивание " + label + "...")); - } - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target)); - - if (response.statusCode() != 200) { - if (label.isEmpty()) return; // для ассетов молча - throw new IOException("HTTP " + response.statusCode() + " при скачивании " + label); - } - - if (!label.isEmpty()) { - long size = Files.size(target); - ProgressBar.finish(label + " (" + ProgressBar.formatBytes(size) + ")"); - } - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java deleted file mode 100644 index 9d1f58a..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java +++ /dev/null @@ -1,373 +0,0 @@ -package me.sashegdev.zernmc.launcher.minecraft.launch; - -import me.sashegdev.zernmc.launcher.minecraft.Instance; -import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; -import me.sashegdev.zernmc.launcher.utils.ZAnsi; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -public class LaunchCommandBuilder { - - private final Instance instance; - - public LaunchCommandBuilder(Instance instance) { - this.instance = instance; - } - - public List build(LaunchOptions options) throws Exception { - System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "...")); - - List command = new ArrayList<>(); - - // 1. Путь к Java - String javaPath = getJavaPath(); - command.add(javaPath); - - // 2. JVM аргументы - command.addAll(getJvmArguments(options)); - - // 3. Natives - Path nativesDir = instance.getPath().resolve("natives"); - if (!Files.exists(nativesDir)) { - Files.createDirectories(nativesDir); - } - command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); - - String loaderType = instance.getLoaderType().toLowerCase(); - - if ("forge".equals(loaderType)) { - command.addAll(getForgeJvmArguments()); - command.add("-cp"); - command.add(buildForgeClasspath()); - command.add("cpw.mods.modlauncher.Launcher"); - command.addAll(getForgeArguments(options)); - } else { - command.add("-cp"); - command.add(buildClasspath()); - command.add(getMainClass()); - command.addAll(getMinecraftArguments(options)); - } - - return command; - } - - private String getJavaPath() { - return "java"; - } - - private List getJvmArguments(LaunchOptions options) { - List jvmArgs = new ArrayList<>(); - - int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096; - jvmArgs.add("-Xmx" + ramMB + "M"); - jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M"); - - jvmArgs.add("-XX:+UseG1GC"); - jvmArgs.add("-XX:+UnlockExperimentalVMOptions"); - jvmArgs.add("-XX:G1NewSizePercent=20"); - jvmArgs.add("-XX:G1ReservePercent=20"); - jvmArgs.add("-XX:MaxGCPauseMillis=50"); - jvmArgs.add("-XX:G1HeapRegionSize=32M"); - - String loaderType = instance.getLoaderType().toLowerCase(); - - if ("fabric".equals(loaderType)) { - jvmArgs.add("--add-modules=ALL-MODULE-PATH"); - jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); - jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED"); - } - - if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) { - jvmArgs.addAll(options.getExtraJvmArgs()); - } - - return jvmArgs; - } - - private List getForgeJvmArguments() { - List jvmArgs = new ArrayList<>(); - - jvmArgs.add("--add-modules=ALL-MODULE-PATH"); - jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); - jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); - jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED"); - - jvmArgs.add("-Dforge.logging.console.level=debug"); - jvmArgs.add("-Dforge.logging.mojang.level=info"); - jvmArgs.add("-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-"); - jvmArgs.add("-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar"); - - return jvmArgs; - } - - private String buildClasspath() throws Exception { - List paths = new ArrayList<>(); - - String versionId = getVersionId(); - - Path versionJar = instance.getPath() - .resolve("versions") - .resolve(versionId) - .resolve(versionId + ".jar"); - - if (Files.exists(versionJar)) { - paths.add(versionJar.toAbsolutePath().toString()); - } else { - String mcVersion = instance.getMinecraftVersion(); - Path fallbackJar = instance.getPath() - .resolve("versions") - .resolve(mcVersion) - .resolve(mcVersion + ".jar"); - if (Files.exists(fallbackJar)) { - paths.add(fallbackJar.toAbsolutePath().toString()); - } - } - - Path librariesDir = instance.getPath().resolve("libraries"); - if (Files.exists(librariesDir)) { - try (var stream = Files.walk(librariesDir)) { - stream.filter(p -> p.toString().endsWith(".jar")) - .map(p -> p.toAbsolutePath().toString()) - .forEach(paths::add); - } - } - - String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; - return String.join(separator, paths); - } - - private String buildForgeClasspath() throws Exception { - List paths = new ArrayList<>(); - - String versionId = getVersionId(); - String mcVersion = instance.getMinecraftVersion(); - String forgeVersion = instance.getLoaderVersion(); - - Path librariesDir = instance.getPath().resolve("libraries"); - if (Files.exists(librariesDir)) { - try (var stream = Files.walk(librariesDir)) { - stream.filter(p -> p.toString().endsWith(".jar")) - .map(p -> p.toAbsolutePath().toString()) - .forEach(paths::add); - } - } - - Path versionJar = instance.getPath() - .resolve("versions") - .resolve(versionId) - .resolve(versionId + ".jar"); - if (Files.exists(versionJar)) { - paths.add(0, versionJar.toAbsolutePath().toString()); - } else { - Path vanillaJar = instance.getPath() - .resolve("versions") - .resolve(mcVersion) - .resolve(mcVersion + ".jar"); - if (Files.exists(vanillaJar)) { - paths.add(0, vanillaJar.toAbsolutePath().toString()); - } - } - - Path forgeUniversal = instance.getPath() - .resolve("libraries") - .resolve("net") - .resolve("minecraftforge") - .resolve("forge") - .resolve(mcVersion + "-" + forgeVersion) - .resolve("forge-" + mcVersion + "-" + forgeVersion + "-universal.jar"); - if (Files.exists(forgeUniversal)) { - paths.add(forgeUniversal.toAbsolutePath().toString()); - } - - Path forgeClient = instance.getPath() - .resolve("libraries") - .resolve("net") - .resolve("minecraftforge") - .resolve("forge") - .resolve(mcVersion + "-" + forgeVersion) - .resolve("forge-" + mcVersion + "-" + forgeVersion + "-client.jar"); - if (Files.exists(forgeClient)) { - paths.add(forgeClient.toAbsolutePath().toString()); - } - - String[] forgeModules = {"fmlcore", "javafmllanguage", "lowcodelanguage", "mclanguage"}; - for (String module : forgeModules) { - Path modulePath = instance.getPath() - .resolve("libraries") - .resolve("net") - .resolve("minecraftforge") - .resolve(module) - .resolve(mcVersion + "-" + forgeVersion) - .resolve(module + "-" + mcVersion + "-" + forgeVersion + ".jar"); - if (Files.exists(modulePath)) { - paths.add(modulePath.toAbsolutePath().toString()); - } - } - - String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; - return String.join(separator, paths); - } - - private String getMainClass() { - String loaderType = instance.getLoaderType().toLowerCase(); - - if ("fabric".equals(loaderType)) { - return "net.fabricmc.loader.impl.launch.knot.KnotClient"; - } - else if ("forge".equals(loaderType)) { - return "cpw.mods.modlauncher.Launcher"; - } - else { - return "net.minecraft.client.main.Main"; - } - } - - /** - * ИСПРАВЛЕНО: используем instance.getAssetIndex() вместо minecraftVersion - */ - private List getMinecraftArguments(LaunchOptions options) { - List args = new ArrayList<>(); - - args.add("--version"); - args.add(instance.getName()); - - args.add("--gameDir"); - args.add(instance.getPath().toAbsolutePath().toString()); - - args.add("--assetsDir"); - args.add(instance.getPath().resolve("assets").toAbsolutePath().toString()); - - // FIXED: Используем правильный assetIndex - args.add("--assetIndex"); - String assetIndex = instance.getAssetIndex(); - if (assetIndex == null || assetIndex.isEmpty()) { - assetIndex = instance.getMinecraftVersion(); - System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex)); - } else { - System.out.println(ZAnsi.green("Использую asset index: " + assetIndex)); - } - args.add(assetIndex); - - args.add("--username"); - args.add(options.getUsername() != null ? options.getUsername() : "Player"); - - args.add("--accessToken"); - args.add(options.getAccessToken() != null ? options.getAccessToken() : "0"); - - args.add("--uuid"); - args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000"); - - args.add("--userType"); - args.add("legacy"); - - if (options.getWidth() > 0) { - args.add("--width"); - args.add(String.valueOf(options.getWidth())); - } - if (options.getHeight() > 0) { - args.add("--height"); - args.add(String.valueOf(options.getHeight())); - } - - return args; - } - - /** - * ИСПРАВЛЕНО: для Forge тоже используем правильный assetIndex - */ - private List getForgeArguments(LaunchOptions options) { - List args = new ArrayList<>(); - - args.add("--launchTarget"); - args.add("forgeclient"); - - args.add("--fml.forgeVersion"); - args.add(instance.getLoaderVersion()); - - args.add("--fml.mcVersion"); - args.add(instance.getMinecraftVersion()); - - args.add("--fml.forgeGroup"); - args.add("net.minecraftforge"); - - args.add("--gameDir"); - args.add(instance.getPath().toAbsolutePath().toString()); - - args.add("--assetsDir"); - args.add(instance.getPath().resolve("assets").toAbsolutePath().toString()); - - // FIXED: Используем правильный assetIndex для Forge - args.add("--assetIndex"); - String assetIndex = instance.getAssetIndex(); - if (assetIndex == null || assetIndex.isEmpty()) { - assetIndex = instance.getMinecraftVersion(); - } - args.add(assetIndex); - - args.add("--username"); - args.add(options.getUsername() != null ? options.getUsername() : "Player"); - - args.add("--accessToken"); - args.add(options.getAccessToken() != null ? options.getAccessToken() : "0"); - - args.add("--uuid"); - args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000"); - - args.add("--userType"); - args.add("legacy"); - - if (options.getWidth() > 0) { - args.add("--width"); - args.add(String.valueOf(options.getWidth())); - } - if (options.getHeight() > 0) { - args.add("--height"); - args.add(String.valueOf(options.getHeight())); - } - - return args; - } - - /** - * ИСПРАВЛЕНО: для Fabric используем сохраненный fabricVersionId - */ - private String getVersionId() { - String loaderType = instance.getLoaderType().toLowerCase(); - String mcVersion = instance.getMinecraftVersion(); - String loaderVer = instance.getLoaderVersion(); - - if ("vanilla".equals(loaderType)) { - return mcVersion; - } - else if ("fabric".equals(loaderType)) { - // Используем сохраненный fabricVersionId если есть - String fabricId = instance.getFabricVersionId(); - if (fabricId != null && !fabricId.isEmpty()) { - return fabricId; - } - // fallback - return "fabric-loader-" + loaderVer + "-" + mcVersion; - } - else if ("forge".equals(loaderType)) { - return mcVersion + "-forge-" + loaderVer; - } - - return mcVersion; - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/ArrowMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/ArrowMenu.java deleted file mode 100644 index 2d948b0..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/ArrowMenu.java +++ /dev/null @@ -1,90 +0,0 @@ -package me.sashegdev.zernmc.launcher.ui; - -import me.sashegdev.zernmc.launcher.utils.ZAnsi; -import org.jline.terminal.Terminal; -import org.jline.terminal.TerminalBuilder; -import org.jline.utils.InfoCmp; - -import java.io.IOException; -import java.util.List; - -public class ArrowMenu { - - private final String title; - private final List options; - private int selected = 0; - private final Terminal terminal; - - private static final int VISIBLE_ITEMS = 7; // сколько строк показывать в списке - - public ArrowMenu(String title, List options) throws IOException { - this.title = title; - this.options = options; - this.terminal = TerminalBuilder.builder() - .system(true) - .jna(true) - .build(); - } - - public int show() throws IOException { - terminal.enterRawMode(); - terminal.puts(InfoCmp.Capability.clear_screen); - terminal.puts(InfoCmp.Capability.cursor_invisible); - - try { - while (true) { - printPagedMenu(); - int key = terminal.reader().read(); - - if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц') { // Up - selected = (selected - 1 + options.size()) % options.size(); - } - else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы') { // Down - selected = (selected + 1) % options.size(); - } - else if (key == 13 || key == 10) { // Enter - return selected; - } - else if (key == 27) { // Esc - return -1; - } - } - } finally { - terminal.puts(InfoCmp.Capability.cursor_visible); - terminal.close(); - } - } - - private void printPagedMenu() { - StringBuilder sb = new StringBuilder(); - sb.append("\033[H\033[2J"); - - // Заголовок (фиксированный) - sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n"); - sb.append(ZAnsi.yellow(title)).append("\n\n"); - - // Вычисляем диапазон отображаемых элементов - int start = Math.max(0, selected - (VISIBLE_ITEMS / 2)); - int end = Math.min(options.size(), start + VISIBLE_ITEMS); - - // Если в конце списка — подтягиваем вверх - if (end - start < VISIBLE_ITEMS && start > 0) { - start = Math.max(0, end - VISIBLE_ITEMS); - } - - for (int i = start; i < end; i++) { - String line = options.get(i); - if (i == selected) { - sb.append(ZAnsi.selected(line)).append("\n"); - } else { - sb.append(ZAnsi.white(" " + line)).append("\n"); - } - } - - // Подсказка внизу (фиксированная) - sb.append("\n") - .append(ZAnsi.white("W/S (Ц/Ы) - перемещение | Enter - выбрать | Esc - назад")); - - System.out.print(sb); - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java deleted file mode 100644 index 942fc16..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java +++ /dev/null @@ -1,127 +0,0 @@ -package me.sashegdev.zernmc.launcher.utils; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Properties; - -public class Config { - - private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc"); - private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties"); - - private static final Properties props = new Properties(); - - // Настройки - private static int maxMemory = 4096; // будет перезаписано умной логикой - private static String serverUrl = "http://87.120.187.36:1582"; - private static String lastUsername = "Player"; - - static { - load(); - applySmartRamRecommendation(); - } - - private static void load() { - try { - Files.createDirectories(CONFIG_DIR); - if (Files.exists(CONFIG_FILE)) { - try (var is = Files.newInputStream(CONFIG_FILE)) { - props.load(is); - } - } - - maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096")); - serverUrl = props.getProperty("serverUrl", serverUrl); - lastUsername = props.getProperty("lastUsername", lastUsername); - - } catch (Exception e) { - System.err.println(ZAnsi.brightRed("Не удалось загрузить конфиг: ") + e.getMessage()); - } - } - - public static void save() { - try { - props.setProperty("maxMemory", String.valueOf(maxMemory)); - props.setProperty("serverUrl", serverUrl); - props.setProperty("lastUsername", lastUsername); - - try (var os = Files.newOutputStream(CONFIG_FILE)) { - props.store(os, "ZernMC Launcher Configuration"); - } - } catch (IOException e) { - System.err.println(ZAnsi.brightRed("Не удалось сохранить конфиг: ") + e.getMessage()); - } - } - - /** - * Умная рекомендация RAM: - * - минимум 1.5 GB - * - рекомендуется totalRAM - 30% - * - максимум 70% от доступной RAM - */ - private static void applySmartRamRecommendation() { - long totalRamMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); // в MB - - // Рекомендуемое значение = total - 30% - long recommended = (long) (totalRamMB * 0.70); // 70% от доступной - - // Ограничения - recommended = Math.max(1536, recommended); // минимум 1.5 GB - recommended = Math.min(recommended, totalRamMB - 1024); // оставляем минимум 1 GB системе - - // Если текущее значение сильно отличается от рекомендуемого — корректируем - if (Math.abs(maxMemory - recommended) > 1024) { // разница больше 1 GB - maxMemory = (int) recommended; - save(); // сохраняем умную рекомендацию - System.out.println(ZAnsi.cyan("Автоматически рекомендовано RAM: " + maxMemory + " MB")); - } - } - - // Getters & Setters - public static int getMaxMemory() { - return maxMemory; - } - - public static void setMaxMemory(int memory) { - // Защита от слишком маленьких/больших значений - if (memory < 1024) memory = 1536; - if (memory > 32768) memory = 32768; - - maxMemory = memory; - save(); - } - - public static String getServerUrl() { - return serverUrl; - } - - public static String getLastUsername() { - return lastUsername; - } - - public static void setLastUsername(String username) { - lastUsername = username; - save(); - } - - public static Path getInstancesDir() { - return CONFIG_DIR.resolve("instances"); - } - - public static Path getJreDir() { - return CONFIG_DIR.resolve("jre"); - } - - public static Path getConfigDir() { - return CONFIG_DIR; - } - - /** - * Полезная информация для пользователя - */ - public static String getRamInfo() { - long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); - return "Доступно RAM: " + totalMB + " MB | Рекомендуется: " + maxMemory + " MB"; - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ProgressBar.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ProgressBar.java deleted file mode 100644 index d85bb2d..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ProgressBar.java +++ /dev/null @@ -1,81 +0,0 @@ -package me.sashegdev.zernmc.launcher.utils; - -import java.text.DecimalFormat; - -public class ProgressBar { - - private static final int BAR_LENGTH = 40; - private static final DecimalFormat DF = new DecimalFormat("#.##"); - - /** - * Прогресс по количеству файлов (для библиотек и общего прогресса) - */ - public static void show(String label, long current, long total, String unit) { - if (total <= 0) { - System.out.print("\r" + ZAnsi.cyan(label) + " ..."); - return; - } - double progress = (double) current / total; - int filled = (int) (progress * BAR_LENGTH); - String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled); - int percent = (int) (progress * 100); - - String text = String.format("%s [%s] %3d%% (%d/%d %s)", - ZAnsi.cyan(label), bar, percent, current, total, unit); - - System.out.print("\r" + text); - System.out.flush(); - } - - /** - * Прогресс по байтам для одного файла (реальный прогресс) - */ - public static void showDownload(String label, long downloaded, long totalBytes) { - if (totalBytes <= 0) { - System.out.print("\r" + ZAnsi.cyan(label) + " ..."); - return; - } - - double progress = (double) downloaded / totalBytes; - int filled = (int) (progress * BAR_LENGTH); - String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled); - String percent = DF.format(progress * 100); - - String text = String.format("%s [%s] %6s%% %s / %s", - ZAnsi.cyan(label), - bar, - percent, - formatBytes(downloaded), - formatBytes(totalBytes)); - - System.out.print("\r" + text); - System.out.flush(); - } - - public static void showAnimated(String label, long current, long total, String unit) { - if (total <= 0) { - // Анимация для неизвестного размера - char[] spinner = {'|', '/', '-', '\\'}; - int idx = (int) (current / 1024) % 4; - System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current)); - } else { - show(label, (int) ((current * 100) / total), 100, unit); - } - } - - public static void finish(String message) { - System.out.println("\r" + ZAnsi.brightGreen(message + " завершено ✓")); - System.out.flush(); - } - - public static void clearLine() { - System.out.print("\r" + " ".repeat(110) + "\r"); - System.out.flush(); - } - - public static String formatBytes(long bytes) { - if (bytes < 1024) return bytes + " B"; - if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB"; - return DF.format(bytes / (1024.0 * 1024)) + " MB"; - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java deleted file mode 100644 index 86e7b9c..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java +++ /dev/null @@ -1,80 +0,0 @@ -package me.sashegdev.zernmc.launcher.utils; - -import org.fusesource.jansi.Ansi; -import org.fusesource.jansi.AnsiConsole; - -public class ZAnsi { - - //поддержка ANSI епта - public static void install() { - AnsiConsole.systemInstall(); - } - - public static void uninstall() { - AnsiConsole.systemUninstall(); - } - - // === Основные цвета === - public static String green(String text) { - return Ansi.ansi().fg(Ansi.Color.GREEN).a(text).reset().toString(); - } - - public static String brightGreen(String text) { - return Ansi.ansi().fgBright(Ansi.Color.GREEN).a(text).reset().toString(); - } - - public static String cyan(String text) { - return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString(); - } - - public static String yellow(String text) { - return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString(); - } - - public static String brightYellow(String text) { - return Ansi.ansi().fgBright(Ansi.Color.YELLOW).a(text).reset().toString(); - } - - public static String red(String text) { - return Ansi.ansi().fg(Ansi.Color.RED).a(text).reset().toString(); - } - - public static String brightRed(String text) { - return Ansi.ansi().fgBright(Ansi.Color.RED).a(text).reset().toString(); - } - - public static String blue(String text) { - return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString(); - } - - public static String white(String text) { - return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString(); - } - - public static String brightWhite(String text) { - return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString(); - } - - // Стили - public static String bold(String text) { - return Ansi.ansi().bold().a(text).reset().toString(); - } - - public static String reset() { - return Ansi.ansi().reset().toString(); - } - - // Комбинированные удобные методы - public static String header(String text) { - return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString(); - } - - public static String selected(String text) { - return Ansi.ansi() - .bgBright(Ansi.Color.WHITE) - .fgBlack() - .a(" > " + text + " ") - .reset() - .toString(); - } -} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java deleted file mode 100644 index 3e0bff0..0000000 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ /dev/null @@ -1,525 +0,0 @@ -package me.sashegdev.zernmc.launcher.utils; - -import org.json.JSONArray; -import org.json.JSONObject; - -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; - -public class ZHttpClient { - - private static final HttpClient client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(15)) - .version(HttpClient.Version.HTTP_1_1) - .build(); - - private static final String BASE_URL = "http://87.120.187.36:1582"; - - // Глобальный прокси режим (для обратной совместимости) - private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); - private static final AtomicBoolean proxyTested = new AtomicBoolean(false); - - // Умное проксирование по сервисам - public enum ServiceType { - ZERN_SERVER(BASE_URL, true), - FABRIC_META("https://meta.fabricmc.net", false), - FABRIC_MAVEN("https://maven.fabricmc.net", false), - MOJANG_META("https://piston-meta.mojang.com", false), - MOJANG_RESOURCES("https://resources.download.minecraft.net", false), - FORGE_MAVEN("https://maven.minecraftforge.net", false), - GOOGLE("https://google.com", false), - CLOUDFLARE("https://cloudflare.com", false); - - private final String baseUrl; - private final boolean alwaysDirect; - - ServiceType(String baseUrl, boolean alwaysDirect) { - this.baseUrl = baseUrl; - this.alwaysDirect = alwaysDirect; - } - - public String getBaseUrl() { return baseUrl; } - public boolean isAlwaysDirect() { return alwaysDirect; } - } - - // Статусы сервисов - private static final Map serviceProxyMode = new ConcurrentHashMap<>(); - private static final Map serviceFailCount = new ConcurrentHashMap<>(); - private static final Map serviceLastCheckTime = new ConcurrentHashMap<>(); - private static final Map serviceHealthy = new ConcurrentHashMap<>(); - - private static final int MAX_FAILS_BEFORE_PROXY = 2; - private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута - private static final long CHECK_TIMEOUT_MS = 7000; // 7 секунд на проверку - - // Статистика - private static int directSuccessCount = 0; - private static int proxySuccessCount = 0; - private static int directFailCount = 0; - - static { - for (ServiceType type : ServiceType.values()) { - serviceProxyMode.put(type, false); - serviceFailCount.put(type, 0); - serviceHealthy.put(type, false); - } - } - - /** - * Вызывать один раз при запуске лаунчера - */ - public static void checkAllServicesOnStartup() { - if (proxyTested.get()) return; - - System.out.println(ZAnsi.cyan("Проверка доступности сервисов...")); - - List servicesToCheck = List.of( - ServiceType.ZERN_SERVER, - ServiceType.GOOGLE, - ServiceType.FABRIC_META, - ServiceType.FABRIC_MAVEN, - ServiceType.MOJANG_META, - ServiceType.MOJANG_RESOURCES, - ServiceType.FORGE_MAVEN - ); - - for (ServiceType service : servicesToCheck) { - boolean isHealthy = checkServiceHealth(service); - serviceHealthy.put(service, isHealthy); - - if (service.isAlwaysDirect()) { - System.out.println(isHealthy ? - ZAnsi.green(" " + service.name() + " - OK") : - ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); - } else { - if (isHealthy) { - System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает")); - } else { - System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси")); - serviceProxyMode.put(service, true); - serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); - } - } - } - - if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { - System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!")); - } - - proxyTested.set(true); - startHealthCheckThread(); - printStats(); - } - - /** - * Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки) - */ - public static void forceCheckMojangServices() { - System.out.println(ZAnsi.cyan("Принудительная проверка Mojang сервисов...")); - - for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) { - boolean healthy = checkServiceHealth(service); - serviceHealthy.put(service, healthy); - - if (healthy) { - System.out.println(ZAnsi.green(" " + service.name() + " доступен напрямую")); - serviceProxyMode.put(service, false); - serviceFailCount.put(service, 0); - } else { - System.out.println(ZAnsi.yellow(" " + service.name() + " недоступен → прокси режим активирован")); - serviceProxyMode.put(service, true); - serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); - } - } - } - - private static boolean checkServiceHealth(ServiceType service) { - return checkDirectConnection(service.getBaseUrl()); - } - - /** - * Улучшенная проверка прямого подключения - */ - private static boolean checkDirectConnection(String baseUrl) { - String testUrl = baseUrl; - - if (baseUrl.contains("piston-meta.mojang.com") || baseUrl.contains("launchermeta.mojang.com")) { - testUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; - } else if (baseUrl.contains("resources.download.minecraft.net")) { - testUrl = "https://resources.download.minecraft.net/00/0000000000000000000000000000000000000000"; - } - - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(testUrl)) - .timeout(Duration.ofMillis(CHECK_TIMEOUT_MS)) - .GET() - .header("User-Agent", "ZernMC-Launcher/HealthCheck") - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - int code = response.statusCode(); - return code == 200 || code == 404; // 404 для ресурсов — нормально - } catch (Exception e) { - return false; - } - } - - private static void startHealthCheckThread() { - Thread healthThread = new Thread(() -> { - while (true) { - try { - Thread.sleep(HEALTH_CHECK_INTERVAL_MS); - performHealthCheck(); - } catch (InterruptedException e) { - break; - } - } - }); - healthThread.setDaemon(true); - healthThread.start(); - } - - private static void performHealthCheck() { - for (ServiceType service : ServiceType.values()) { - if (service.isAlwaysDirect()) continue; - - boolean isHealthy = checkServiceHealth(service); - serviceHealthy.put(service, isHealthy); - - if (isHealthy && serviceProxyMode.get(service)) { - serviceProxyMode.put(service, false); - serviceFailCount.put(service, 0); - System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение")); - } else if (!isHealthy && !serviceProxyMode.get(service)) { - int fails = serviceFailCount.getOrDefault(service, 0) + 1; - serviceFailCount.put(service, fails); - serviceLastCheckTime.put(service, System.currentTimeMillis()); - - if (fails >= MAX_FAILS_BEFORE_PROXY) { - serviceProxyMode.put(service, true); - System.out.println(ZAnsi.yellow("[NET] " + service.name() + " недоступен, включен прокси режим")); - } - } - } - } - - private static ServiceType detectService(String url) { - if (url.contains("meta.fabricmc.net")) return ServiceType.FABRIC_META; - if (url.contains("maven.fabricmc.net")) return ServiceType.FABRIC_MAVEN; - if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com")) - return ServiceType.MOJANG_META; - if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES; - if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN; - if (url.contains("google.com")) return ServiceType.GOOGLE; - if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE; - return null; - } - - private static boolean shouldUseProxyForUrl(String url) { - if (useProxyMode.get()) return true; - - ServiceType service = detectService(url); - if (service == null || service.isAlwaysDirect()) return false; - - return serviceProxyMode.getOrDefault(service, false); - } - - private static boolean isConnectionError(Throwable e) { - Throwable cause = e.getCause() != null ? e.getCause() : e; - String msg = cause.getMessage() != null ? cause.getMessage().toLowerCase() : ""; - - return cause instanceof java.net.ConnectException || - cause instanceof java.net.UnknownHostException || - cause instanceof java.nio.channels.ClosedChannelException || - msg.contains("connection") || - msg.contains("timeout") || - msg.contains("refused") || - msg.contains("closed"); - } - - private static void markServiceAsBlocked(String url) { - ServiceType service = detectService(url); - if (service == null || service.isAlwaysDirect()) return; - - int fails = serviceFailCount.getOrDefault(service, 0) + 1; - serviceFailCount.put(service, fails); - serviceLastCheckTime.put(service, System.currentTimeMillis()); - - if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) { - serviceProxyMode.put(service, true); - System.out.println(ZAnsi.yellow("[NET] " + service.name() + " заблокирован, переключаемся на прокси")); - } - } - /** - * Универсальный GET с умным прокси + автоматическим fallback - */ - public static String getWithSmartProxy(String url) throws IOException, InterruptedException { - // Попытка прямого подключения - if (!shouldUseProxyForUrl(url)) { - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(25)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() == 200) { - directSuccessCount++; - return response.body(); - } - - if (response.statusCode() >= 400) { - throw new IOException("HTTP " + response.statusCode()); - } - } catch (Exception e) { - if (isConnectionError(e)) { - directFailCount++; - markServiceAsBlocked(url); - } - // Если ошибка соединения — пробуем через прокси - } - } - - // Через прокси - try { - String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); - String proxyUrl = BASE_URL + "/download?url=" + encodedUrl; - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(proxyUrl)) - .timeout(Duration.ofSeconds(40)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - throw new IOException("Proxy HTTP " + response.statusCode()); - } - - proxySuccessCount++; - return response.body(); - - } catch (Exception e) { - throw new IOException("Не удалось получить данные ни напрямую, ни через прокси: " + e.getMessage(), e); - } - } - - /** - * Скачивание файла с умным прокси + fallback - */ - public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { - if (!shouldUseProxyForUrl(url)) { - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(40)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); - - if (response.statusCode() == 200) { - directSuccessCount++; - return; - } - } catch (Exception e) { - if (isConnectionError(e)) { - directFailCount++; - markServiceAsBlocked(url); - } - // fallback на прокси ниже - } - } - - // Скачивание через прокси - String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); - String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(proxyUrl)) - .timeout(Duration.ofMinutes(5)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); - - if (response.statusCode() != 200) { - throw new IOException("Proxy download failed: HTTP " + response.statusCode()); - } - - proxySuccessCount++; - } - - // ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ====================== - - public static String get(String endpoint) throws IOException, InterruptedException { - checkAllServicesOnStartup(); - - if (useProxyMode.get()) { - return proxyGet(endpoint); - } - - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + endpoint)) - .timeout(Duration.ofSeconds(15)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); - } - return response.body(); - } catch (Exception e) { - directFailCount++; - throw e; - } - } - - private static String proxyGet(String endpoint) throws IOException { - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/proxy" + endpoint)) - .timeout(Duration.ofSeconds(30)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); - } - - proxySuccessCount++; - return response.body(); - } catch (Exception e) { - throw new IOException("Ошибка прокси: " + e.getMessage(), e); - } - } - - // ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ====================== - - public static List getFabricLoaderVersions() throws IOException, InterruptedException { - String url = "https://meta.fabricmc.net/v2/versions/loader"; - return parseFabricVersionsFromJson(getWithSmartProxy(url)); - } - - public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException { - String url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; - String response = getWithSmartProxy(url); - return new JSONObject(response); - } - - public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException { - JSONObject manifest = getMojangVersionManifest(); - JSONArray versions = manifest.getJSONArray("versions"); - - for (int i = 0; i < versions.length(); i++) { - JSONObject v = versions.getJSONObject(i); - if (v.getString("id").equals(versionId)) { - return new JSONObject(getWithSmartProxy(v.getString("url"))); - } - } - throw new IOException("Version " + versionId + " not found"); - } - - public static String getForgeVersionsXml() throws IOException, InterruptedException { - String url = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"; - return getWithSmartProxy(url); - } - - public static void downloadFile(String url, Path target) throws Exception { - downloadFileWithSmartProxy(url, target); - } - - public static void downloadAsset(String hash, Path target) throws Exception { - String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; - downloadFileWithSmartProxy(url, target); - } - - public static String downloadString(String url) throws IOException, InterruptedException { - return getWithSmartProxy(url); - } - - private static List parseFabricVersionsFromJson(String json) { - JSONArray array = new JSONArray(json); - List versions = new ArrayList<>(); - for (int i = 0; i < array.length(); i++) { - JSONObject obj = array.getJSONObject(i); - if (obj.has("version")) { - versions.add(obj.getString("version")); - } - } - return versions; - } - - // ====================== ВСПОМОГАТЕЛЬНЫЕ ====================== - - public static String getBaseUrl() { - return BASE_URL; - } - - public static String getLauncherVersionInfo() throws IOException, InterruptedException { - return get("/launcher/version"); - } - - public static void forceProxyMode() { - useProxyMode.set(true); - System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим")); - } - - public static void disableProxyMode() { - useProxyMode.set(false); - for (ServiceType type : ServiceType.values()) { - if (!type.isAlwaysDirect()) { - serviceProxyMode.put(type, false); - serviceFailCount.put(type, 0); - } - } - System.out.println(ZAnsi.green("Режим прокси выключен")); - } - - public static boolean isProxyMode() { - return useProxyMode.get(); - } - - public static void printStats() { - System.out.println(ZAnsi.cyan("\n=== Статистика сети ===")); - System.out.println(ZAnsi.white("Глобальный прокси: ") + (useProxyMode.get() ? "ВКЛ" : "ВЫКЛ")); - System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount); - System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount); - System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount); - - System.out.println(ZAnsi.cyan("\nСтатус сервисов:")); - for (ServiceType type : ServiceType.values()) { - if (type.isAlwaysDirect()) continue; - String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО"); - String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]"); - System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health); - } - } -} \ No newline at end of file diff --git a/run_test.py b/run_test.py new file mode 100644 index 0000000..fbde336 --- /dev/null +++ b/run_test.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +import subprocess, sys, os, json, time, signal + +os.chdir(os.path.join(os.path.dirname(__file__), "server")) +workdir = os.path.join(os.path.dirname(__file__), "server") + +# Start server +print("Starting server...", flush=True) +proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "main:app", + "--host", "127.0.0.1", "--port", "1590", + "--log-level", "warning"], + cwd=workdir, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, +) + +import httpx + +for i in range(30): + try: + r = httpx.get("http://127.0.0.1:1590/news", timeout=2) + if r.status_code == 200: + print("Server ready!", flush=True) + break + except Exception as e: + if i == 0: + print(f"Waiting for server ({e})...", flush=True) + time.sleep(1) +else: + print("Server failed to start", flush=True) + proc.terminate() + proc.wait(timeout=5) + sys.exit(1) + +passed = 0 +failed = 0 +def check(name, ok, detail=""): + global passed, failed + if ok: + print(f" PASS {name}") + passed += 1 + else: + print(f" FAIL {name} {detail}") + failed += 1 + +BASE = "http://127.0.0.1:1590" +client = httpx.Client(base_url=BASE, timeout=10) + +# ===== NEWS ===== +print("\n=== NEWS ===") +r = client.get("/news") +check("GET /news status", r.status_code == 200, str(r.status_code)) +data = r.json() +check("news has 'news' key", "news" in data) +check("news is array", isinstance(data.get("news"), list)) +check("news has items", len(data["news"]) > 0, f"got {len(data['news'])} items") +if data.get("news"): + n = data["news"][0] + check("news item has title", "title" in n) + check("news item has body", "body" in n) + check("news item has type", "type" in n) + +# ===== AUTH ===== +print("\n=== AUTH ===") +ts = str(int(time.time())) +tu = f"test_{ts}" +r = client.post("/auth/register", json={"username": tu, "password": "pass123"}) +check("register new user", r.status_code == 200, str(r.status_code)) +reg = r.json() +check("register returns access_token", "access_token" in reg) + +r2 = client.post("/auth/register", json={"username": tu, "password": "pass123"}) +check("register existing returns 409", r2.status_code == 409, str(r2.status_code)) + +r3 = client.post("/auth/login", json={"username": tu, "password": "pass123"}) +check("login valid", r3.status_code == 200, str(r3.status_code)) +tok = r3.json().get("access_token", "") +if not tok: tok = reg.get("access_token", "") +check("login returns token", bool(tok)) + +r4 = client.post("/auth/login", json={"username": tu, "password": "wrongpass123"}) +check("login wrong pass", r4.status_code == 401, str(r4.status_code)) + +r5 = client.post("/auth/login", json={"username": "nobody_valid", "password": "pass123456"}) +check("login nonexistent", r5.status_code == 401, str(r5.status_code)) + +# ===== TOKEN VALIDATION ===== +print("\n=== TOKEN ===") +ah = {"Authorization": f"Bearer {tok}"} +authed = httpx.Client(base_url=BASE, timeout=10, headers=ah) +r = authed.get("/auth/pass/my") +check("pass/my authed", r.status_code in (200, 404), str(r.status_code)) + +r2 = client.get("/auth/pass/my") +check("pass/my unauthed", r2.status_code == 401, str(r2.status_code)) + +# ===== PACKS ===== +print("\n=== PACKS ===") +r = client.get("/packs") +check("packs unauthed rejects", r.status_code in (401, 403), str(r.status_code)) +r2 = authed.get("/packs") +check("packs authed succeeds", r2.status_code in (200, 403), str(r2.status_code)) +if r2.status_code == 200: + pdata = r2.json() + check("packs has 'packs' key", "packs" in pdata) + +# ===== FRIENDS ===== +print("\n=== FRIENDS ===") +r = client.get("/api/friends/list") +check("friends/list unauthed", r.status_code == 401, str(r.status_code)) +r = authed.get("/api/friends/list") +check("friends/list authed", r.status_code == 200, str(r.status_code)) +check("friends/list has friends key", "friends" in r.json()) + +r = authed.get("/api/friends/requests") +check("friends/requests authed", r.status_code == 200, str(r.status_code)) +check("friends/requests has requests key", "requests" in r.json()) + +# Register friend user +fts = f"friend_{ts}" +r = client.post("/auth/register", json={"username": fts, "password": "pass123"}) +check("register friend user", r.status_code == 200, str(r.status_code)) +ftok = r.json().get("access_token", "") +fauthed = httpx.Client(base_url=BASE, timeout=10, + headers={"Authorization": f"Bearer {ftok}"}) +# Parse user id from JWT (no account endpoint on Python server) +import base64 +def parse_user_id(token): + try: + payload_b64 = token.split('.')[1] + payload_b64 += '=' * (4 - len(payload_b64) % 4) + return json.loads(base64.urlsafe_b64decode(payload_b64))["sub"] + except: + return 0 +friend_id = parse_user_id(ftok) +my_id = parse_user_id(tok) + +# Add friend +r = authed.post("/api/friends/add", json={"username": fts}) +check("friends add", r.status_code == 200, f"{r.status_code}: {r.text[:80]}") +ad = r.json() +check("friends add returns message", "message" in ad) + +# Duplicate add +r = authed.post("/api/friends/add", json={"username": fts}) +check("friends add duplicate", r.status_code == 400, str(r.status_code)) + +# Add self +r = authed.post("/api/friends/add", json={"username": tu}) +check("friends add self", r.status_code == 400, str(r.status_code)) + +# Add nonexistent +r = authed.post("/api/friends/add", json={"username": "nobody_xyz_123"}) +check("friends add nonexistent", r.status_code == 404, str(r.status_code)) + +# Accept friend request (as friend) +r = fauthed.post("/api/friends/accept", json={"user_id": my_id}) +check("friends accept", r.status_code == 200, f"{r.status_code}: {r.text[:80]}") + +# Remove nonexistent +r = authed.post("/api/friends/remove", json={"user_id": 99999}) +check("friends remove nonexistent", r.status_code == 404, str(r.status_code)) + +# Remove friend +r = authed.post("/api/friends/remove", json={"user_id": friend_id}) +check("friends remove", r.status_code == 200, f"{r.status_code}: {r.text[:80]}") + +# ===== PLAYTIME ===== +print("\n=== PLAYTIME ===") +r = authed.post("/api/playtime/sync", json={"pack_name": "test_pack", "minutes": 30}) +check("playtime sync", r.status_code == 200, f"{r.status_code}: {r.text[:80]}") + +r = authed.get("/api/playtime/stats") +check("playtime stats", r.status_code == 200, str(r.status_code)) +st = r.json() +check("playtime has total_minutes", "total_minutes" in st) +check("playtime has packs", "packs" in st) + +r = client.get("/api/playtime/stats") +check("playtime stats unauthed", r.status_code == 401, str(r.status_code)) + +# ===== SUMMARY ===== +total = passed + failed +print(f"\n{'='*40}") +print(f"RESULTS: {passed}/{total} PASSED, {failed}/{total} FAILED") +if failed > 0: + print("SOME TESTS FAILED!") + +proc.terminate() +proc.wait(timeout=5) +sys.exit(1 if failed else 0) diff --git a/server/admin_router.py b/server/admin_router.py new file mode 100644 index 0000000..409a825 --- /dev/null +++ b/server/admin_router.py @@ -0,0 +1,965 @@ +# admin_router.py +from fastapi import APIRouter, HTTPException, Depends, Request, status, UploadFile, File +from pydantic import BaseModel, Field +from typing import Optional, List +import structlog +import time +import secrets +import hashlib +from datetime import datetime +from pathlib import Path + +from pack_manager import PACKS_DIR, load_packs_config, get_pack_presets, toggle_pack_disabled + +from auth import get_db, require_role, log_audit, get_current_user, hash_password +from roles import ( + ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions, + ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR +) + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix="/admin", tags=["admin"]) + +# ====================== МОДЕЛИ ====================== +class UpdateRoleRequest(BaseModel): + user_id: int + role: int = Field(..., ge=0, le=4) + +class PassRequest(BaseModel): + username: str + reason: Optional[str] = None + +class PassDecision(BaseModel): + request_id: int + approved: bool + reason: Optional[str] = None + +class CreatePassDirectRequest(BaseModel): + username: str + expires_days: Optional[int] = Field(None, ge=1, le=365) + max_uses: int = Field(1, ge=1, le=10) + +class BanUserRequest(BaseModel): + user_id: int + days: int = Field(..., ge=1, le=365) + reason: str + +# ====================== ЭНДПОИНТЫ ====================== + +@router.get("/users") +async def list_users( + current_user: dict = Depends(require_role(ROLE_MODERATOR)), + search: Optional[str] = None +): + """Список пользователей (модераторы видят всех, но без sensitive данных)""" + with get_db() as conn: + query = "SELECT id, username, uuid, role, created_at, last_login, is_active" + params = [] + + if current_user["role"] < ROLE_ELDER: + # Модераторы не видят забаненных + query += " FROM users WHERE is_active = 1" + else: + query += " FROM users" + + if search: + query += " AND username LIKE ?" + params.append(f"%{search}%") + + query += " ORDER BY role DESC, username" + + rows = conn.execute(query, params).fetchall() + + users = [] + for row in rows: + user_data = { + "id": row["id"], + "username": row["username"], + "uuid": row["uuid"], + "role": row["role"], + "role_name": ROLE_NAMES.get(row["role"], "Неизвестно"), + "created_at": row["created_at"], + "last_login": row["last_login"], + } + + # Elder и Creator видят больше информации + if current_user["role"] >= ROLE_ELDER: + user_data["is_active"] = row["is_active"] + # Получаем информацию о проходке + pass_info = conn.execute(""" + SELECT p.code, p.expires_at, up.activated_at + FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + LIMIT 1 + """, (row["id"], time.time())).fetchone() + + if pass_info: + user_data["has_pass"] = True + user_data["pass_expires"] = pass_info["expires_at"] + + users.append(user_data) + + return {"users": users, "total": len(users)} + + +@router.get("/users/{user_id}") +async def get_user_detail( + user_id: int, + current_user: dict = Depends(require_role(ROLE_MODERATOR)) +): + """Детальная информация о пользователе""" + with get_db() as conn: + row = conn.execute(""" + SELECT id, username, uuid, role, created_at, last_login, is_active, banned_until + FROM users WHERE id = ? + """, (user_id,)).fetchone() + + if not row: + raise HTTPException(404, "Пользователь не найден") + + # Получаем активную проходку + pass_info = None + if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER: + pass_row = conn.execute(""" + SELECT p.code, p.expires_at, up.activated_at + FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + LIMIT 1 + """, (user_id, time.time())).fetchone() + + if pass_row: + pass_info = { + "code": pass_row["code"][:8] + "..." if current_user["role"] < ROLE_ELDER else pass_row["code"], + "expires_at": pass_row["expires_at"], + "activated_at": pass_row["activated_at"] + } + + # Логи действий (только для Elder+) + actions = [] + if current_user["role"] >= ROLE_ELDER: + action_rows = conn.execute(""" + SELECT action, details, timestamp FROM audit_log + WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20 + """, (user_id,)).fetchall() + actions = [dict(row) for row in action_rows] + + return { + "id": row["id"], + "username": row["username"], + "uuid": row["uuid"], + "role": row["role"], + "role_name": ROLE_NAMES.get(row["role"], "Неизвестно"), + "created_at": row["created_at"], + "last_login": row["last_login"], + "is_active": row["is_active"], + "banned_until": row["banned_until"], + "has_pass": pass_info is not None, + "pass_info": pass_info, + "recent_actions": actions if current_user["role"] >= ROLE_ELDER else None + } + + +@router.put("/users/{user_id}/role") +async def update_user_role( + user_id: int, + body: UpdateRoleRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Изменение роли пользователя""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE id = ?", + (user_id,) + ).fetchone() + + if not target: + raise HTTPException(404, "Пользователь не найден") + + # Проверки прав + if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Нельзя изменить роль создателя") + + if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Нельзя изменять роль пользователя с равным или высшим уровнем") + + if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, f"Нельзя назначить роль выше своей ({ROLE_NAMES[current_user['role']]})") + + # Elder не может создавать других Elder (только Creator) + if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Только создатель может назначать Elder Moderator") + + # Проверяем, нужно ли выдать/отозвать проходку + old_role = target["role"] + new_role = body.role + + conn.execute( + "UPDATE users SET role = ? WHERE id = ?", + (new_role, user_id) + ) + + # Управление проходками при изменении роли + now = time.time() + + if new_role >= ROLE_PASS_HOLDER and old_role < ROLE_PASS_HOLDER: + # Выдаем проходку если её нет + existing = conn.execute(""" + SELECT 1 FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + """, (user_id, now)).fetchone() + + if not existing: + # Создаем автоматическую проходку + pass_code = f"AUTO_{secrets.token_hex(8).upper()}" + conn.execute(""" + INSERT INTO passes (code, owner, expires_at, max_uses, is_active) + VALUES (?, ?, NULL, 1, 1) + """, (pass_code, target["username"])) + + conn.execute(""" + INSERT INTO user_passes (user_id, pass_code, activated_at) + VALUES (?, ?, ?) + """, (user_id, pass_code, now)) + + logger.info("Auto-pass issued", user=target["username"], role=new_role) + + elif new_role < ROLE_PASS_HOLDER and old_role >= ROLE_PASS_HOLDER: + # Отзываем проходку + conn.execute(""" + UPDATE passes SET is_active = 0 + WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?) + """, (user_id,)) + + logger.info("Auto-pass revoked", user=target["username"]) + + conn.commit() + + log_audit( + current_user["id"], + "role_change", + f"Changed role of {target['username']} from {old_role} to {new_role}", + ip + ) + + logger.info("Role updated", admin=current_user["username"], target=target["username"], new_role=new_role) + + return { + "success": True, + "user_id": user_id, + "username": target["username"], + "old_role": old_role, + "old_role_name": ROLE_NAMES.get(old_role, "Неизвестно"), + "new_role": new_role, + "new_role_name": ROLE_NAMES.get(new_role, "Неизвестно") + } + + +@router.post("/pass/grant") +async def grant_pass( + body: CreatePassDirectRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Выдача проходки пользователю (Elder+ могут выдавать)""" + ip = request.client.host if request.client else "unknown" + + # Проверяем право на прямую выдачу + if current_user["role"] < ROLE_CREATOR and not has_permission(current_user["role"], Permissions.APPROVE_PASS): + raise HTTPException(403, "Недостаточно прав для выдачи проходки") + + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE", + (body.username,) + ).fetchone() + + if not target: + raise HTTPException(404, f"Пользователь {body.username} не найден") + + # Проверяем, есть ли уже активная проходка + existing = conn.execute(""" + SELECT p.code FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + """, (target["id"], time.time())).fetchone() + + if existing: + raise HTTPException(409, f"У пользователя {body.username} уже есть активная проходка") + + # Создаем проходку + pass_code = secrets.token_hex(12).upper() + now = time.time() + expires_at = now + (body.expires_days * 86400) if body.expires_days else None + + conn.execute(""" + INSERT INTO passes (code, owner, expires_at, max_uses, is_active) + VALUES (?, ?, ?, ?, 1) + """, (pass_code, target["username"], expires_at, body.max_uses)) + + conn.execute(""" + INSERT INTO user_passes (user_id, pass_code, activated_at) + VALUES (?, ?, ?) + """, (target["id"], pass_code, now)) + + # Обновляем роль если нужно + if target["role"] < ROLE_PASS_HOLDER: + conn.execute( + "UPDATE users SET role = ? WHERE id = ?", + (ROLE_PASS_HOLDER, target["id"]) + ) + + conn.commit() + + log_audit( + current_user["id"], + "grant_pass", + f"Granted pass to {target['username']} (expires: {body.expires_days}d, max_uses: {body.max_uses})", + ip + ) + + logger.info("Pass granted", admin=current_user["username"], target=target["username"], code=pass_code) + + return { + "success": True, + "pass_code": pass_code, + "username": target["username"], + "expires_at": expires_at, + "expires_days": body.expires_days, + "max_uses": body.max_uses + } + + +@router.delete("/pass/revoke/{username}") +async def revoke_pass( + username: str, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Отзыв проходки у пользователя""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE", + (username,) + ).fetchone() + + if not target: + raise HTTPException(404, f"Пользователь {username} не найден") + + # Отзываем проходку + conn.execute(""" + UPDATE passes SET is_active = 0 + WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?) + """, (target["id"],)) + + # Понижаем роль если она была только из-за проходки + if target["role"] == ROLE_PASS_HOLDER: + conn.execute( + "UPDATE users SET role = ? WHERE id = ?", + (ROLE_USER, target["id"]) + ) + + conn.commit() + + log_audit(current_user["id"], "revoke_pass", f"Revoked pass from {username}", ip) + logger.info("Pass revoked", admin=current_user["username"], target=username) + + return {"success": True, "message": f"Проходка {username} отозвана"} + + +@router.post("/user/ban") +async def ban_user( + body: BanUserRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Бан пользователя (Elder+ могут банить)""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE id = ?", + (body.user_id,) + ).fetchone() + + if not target: + raise HTTPException(404, "Пользователь не найден") + + # Нельзя забанить создателя + if target["role"] == ROLE_CREATOR: + raise HTTPException(403, "Нельзя забанить создателя") + + # Elder не может банить других Elder + if target["role"] >= ROLE_ELDER and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Недостаточно прав для бана этого пользователя") + + banned_until = time.time() + (body.days * 86400) + + conn.execute( + "UPDATE users SET is_active = 0, banned_until = ? WHERE id = ?", + (banned_until, target["id"]) + ) + + # Отзываем проходку при бане + conn.execute(""" + UPDATE passes SET is_active = 0 + WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?) + """, (target["id"],)) + + conn.commit() + + log_audit( + current_user["id"], + "ban_user", + f"Banned {target['username']} for {body.days} days. Reason: {body.reason}", + ip + ) + + logger.info("User banned", admin=current_user["username"], target=target["username"], days=body.days) + + return { + "success": True, + "username": target["username"], + "banned_until": banned_until, + "days": body.days + } + + +@router.post("/user/unban/{user_id}") +async def unban_user( + user_id: int, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Разбан пользователя""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + target = conn.execute( + "SELECT id, username FROM users WHERE id = ?", + (user_id,) + ).fetchone() + + if not target: + raise HTTPException(404, "Пользователь не найден") + + conn.execute( + "UPDATE users SET is_active = 1, banned_until = NULL WHERE id = ?", + (user_id,) + ) + + conn.commit() + + log_audit(current_user["id"], "unban_user", f"Unbanned {target['username']}", ip) + logger.info("User unbanned", admin=current_user["username"], target=target["username"]) + + return {"success": True, "username": target["username"]} + + +@router.get("/audit") +async def get_audit_log( + current_user: dict = Depends(require_role(ROLE_ELDER)), + limit: int = 50, + offset: int = 0, + user_id: Optional[int] = None +): + """Просмотр логов аудита (только Elder+)""" + with get_db() as conn: + query = """ + SELECT al.*, u.username + FROM audit_log al + LEFT JOIN users u ON al.user_id = u.id + """ + params = [] + + if user_id: + query += " WHERE al.user_id = ?" + params.append(user_id) + + query += " ORDER BY al.timestamp DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + rows = conn.execute(query, params).fetchall() + + total = conn.execute("SELECT COUNT(*) as count FROM audit_log").fetchone()["count"] + + return { + "logs": [dict(row) for row in rows], + "total": total, + "limit": limit, + "offset": offset + } + + +@router.get("/stats") +async def get_admin_stats( + current_user: dict = Depends(require_role(ROLE_MODERATOR)) +): + """Статистика для админов""" + with get_db() as conn: + # Общая статистика + total_users = conn.execute("SELECT COUNT(*) as count FROM users").fetchone()["count"] + + # Статистика по ролям + role_stats = conn.execute(""" + SELECT role, COUNT(*) as count + FROM users + GROUP BY role + ORDER BY role DESC + """).fetchall() + + # Активные проходки + active_passes = conn.execute(""" + SELECT COUNT(*) as count FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE p.expires_at IS NULL OR p.expires_at > ? + """, (time.time(),)).fetchone()["count"] + + # Забаненные пользователи + banned_users = conn.execute(""" + SELECT COUNT(*) as count FROM users + WHERE is_active = 0 AND banned_until > ? + """, (time.time(),)).fetchone()["count"] + + # Недавние регистрации (последние 7 дней) + week_ago = time.time() - (7 * 86400) + recent_registrations = conn.execute(""" + SELECT COUNT(*) as count FROM users WHERE created_at > ? + """, (week_ago,)).fetchone()["count"] + + return { + "total_users": total_users, + "active_passes": active_passes, + "banned_users": banned_users, + "recent_registrations_7d": recent_registrations, + "roles_distribution": [ + {"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]} + for r in role_stats + ], + "my_info": { + "role": current_user["role"], + "role_name": ROLE_NAMES.get(current_user["role"], "Неизвестно"), + "username": current_user["username"] + } + } + + +# ====================== WHITELIST MODS ====================== + +WHITELIST_DIR = Path(__file__).parent / "whitelist" +WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt" + + +def _read_mods_list() -> list[dict]: + """Parse mods.txt into list of {name, size, hash}""" + if not WHITELIST_MODS_FILE.exists(): + return [] + mods = [] + for line in WHITELIST_MODS_FILE.read_text("utf-8").strip().split("\n"): + line = line.strip() + if not line: + continue + parts = line.split("::") + if len(parts) >= 3: + mods.append({ + "name": parts[0].strip(), + "size": int(parts[1].strip()), + "hash": parts[2].strip() + }) + return mods + + +def _write_mods_list(mods: list[dict]): + """Write mods list to mods.txt""" + lines = [f"{m['name']} :: {m['size']} :: {m['hash']}" for m in mods] + WHITELIST_DIR.mkdir(parents=True, exist_ok=True) + WHITELIST_MODS_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +@router.get("/whitelist/mods") +async def admin_list_whitelist_mods(current_user: dict = Depends(require_role(ROLE_ELDER))): + """List all whitelist mods (JSON)""" + return {"mods": _read_mods_list()} + + +@router.post("/whitelist/mods") +async def add_whitelist_mod( + file: UploadFile = File(...), + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Upload a mod file — auto-computes hash, records size, adds to whitelist""" + if not file.filename or not file.filename.endswith(".jar"): + raise HTTPException(400, "Only .jar files are allowed") + + contents = await file.read() + if len(contents) == 0: + raise HTTPException(400, "Empty file") + + file_hash = hashlib.sha256(contents).hexdigest() + file_size = len(contents) + + WHITELIST_DIR.mkdir(parents=True, exist_ok=True) + + mod_path = WHITELIST_DIR / file_hash + if mod_path.exists(): + raise HTTPException(409, f"Mod with hash {file_hash} already exists") + + mod_path.write_bytes(contents) + + mods = _read_mods_list() + mods.append({"name": file.filename, "size": file_size, "hash": file_hash}) + _write_mods_list(mods) + + ip = request.client.host if request.client else "unknown" + log_audit(current_user["id"], "whitelist_add", f"Added {file.filename} ({file_hash})", ip) + + return {"success": True, "name": file.filename, "size": file_size, "hash": file_hash} + + +@router.delete("/whitelist/mods/{mod_hash}") +async def remove_whitelist_mod( + mod_hash: str, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Remove a mod from whitelist by hash""" + if ".." in mod_hash: + raise HTTPException(400, "Invalid hash") + + mod_path = WHITELIST_DIR / mod_hash + deleted_file = False + if mod_path.exists() and mod_path.is_file(): + mod_path.unlink() + deleted_file = True + + mods = _read_mods_list() + filtered = [m for m in mods if m["hash"] != mod_hash] + if len(filtered) == len(mods) and not deleted_file: + raise HTTPException(404, "Mod not found") + + _write_mods_list(filtered) + + ip = request.client.host if request.client else "unknown" + log_audit(current_user["id"], "whitelist_remove", f"Removed mod with hash {mod_hash}", ip) + + return {"success": True, "hash": mod_hash, "file_deleted": deleted_file} + + +# ====================== ADMIN CLIENTS, USERS, PASSES ====================== + +@router.get("/clients") +async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))): + """List online clients""" + with get_db() as conn: + rows = conn.execute(""" + SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address + FROM users u + LEFT JOIN user_status us ON u.id = us.user_id + LEFT JOIN user_sessions s ON u.id = s.user_id AND s.is_active = 1 + WHERE us.is_online = 1 + GROUP BY u.id + ORDER BY us.last_seen DESC + """).fetchall() + clients = [] + for row in rows: + clients.append({ + "id": row["id"], + "username": row["username"], + "online": bool(row["is_online"]), + "current_pack": row["current_pack"] or "", + "last_seen": str(row["last_seen"]) if row["last_seen"] else None, + "ip": row["ip_address"] or "" + }) + return {"success": True, "clients": clients} + + +class UserSearchRequest(BaseModel): + search: str + + +@router.post("/users/search") +async def admin_search_users( + req: UserSearchRequest, + current_user: dict = Depends(require_role(ROLE_MODERATOR)), +): + """Search users by username""" + if not req.search.strip(): + return {"users": []} + with get_db() as conn: + rows = conn.execute(""" + SELECT id, username, uuid, role, created_at, last_login, is_active + FROM users WHERE username LIKE ? + ORDER BY username LIMIT 20 + """, (f"%{req.search.strip()}%",)).fetchall() + users = [] + for row in rows: + users.append({ + "id": row["id"], + "username": row["username"], + "uuid": row["uuid"], + "role": row["role"], + "role_name": ROLE_NAMES.get(row["role"], "Unknown"), + "created_at": row["created_at"], + "last_login": row["last_login"], + "is_active": bool(row["is_active"]) + }) + return {"success": True, "users": users} + + +class RoleUpdateRequest(BaseModel): + user_id: int + role: int = Field(..., ge=0, le=4) + username: Optional[str] = None + + +@router.post("/users/role") +async def admin_update_user_role( + body: RoleUpdateRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Update user role""" + ip = request.client.host if request.client else "unknown" + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE id = ?", + (body.user_id,) + ).fetchone() + if not target: + raise HTTPException(404, "User not found") + if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Cannot change creator role") + if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Cannot modify equal or higher role") + if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Cannot assign role above your own") + if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Only creator can assign Elder") + + old_role = target["role"] + conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, body.user_id)) + conn.commit() + + log_audit(current_user["id"], "role_change", + f"Changed role of {target['username']} from {old_role} to {body.role}", ip) + + return {"success": True, "user_id": body.user_id, "username": target["username"], + "old_role": old_role, "new_role": body.role} + + +class ResetPasswordRequest(BaseModel): + user_id: int + username: Optional[str] = None + + +@router.post("/users/reset-password") +async def admin_reset_password( + body: ResetPasswordRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Reset user password""" + ip = request.client.host if request.client else "unknown" + new_password = secrets.token_hex(8) + pw_hash = hash_password(new_password) + with get_db() as conn: + target = conn.execute( + "SELECT id, username FROM users WHERE id = ?", + (body.user_id,) + ).fetchone() + if not target: + raise HTTPException(404, "User not found") + conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", + (pw_hash, body.user_id)) + conn.commit() + log_audit(current_user["id"], "password_reset", + f"Reset password for {target['username']}", ip) + return {"success": True, "new_password": new_password, "username": target["username"]} + + +@router.get("/passes") +async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODERATOR))): + """List all passes""" + with get_db() as conn: + rows = conn.execute(""" + SELECT p.code, p.is_active, p.owner, p.activated_by, p.activated_at, + p.expires_at, p.max_uses, p.uses, u.username as activated_username + FROM passes p + LEFT JOIN users u ON p.activated_by = u.id + ORDER BY p.activated_at DESC + """).fetchall() + passes = [] + for row in rows: + passes.append({ + "code": row["code"], + "owner": row["owner"], + "is_active": bool(row["is_active"]), + "activated_by": row["activated_by"], + "activated_username": row["activated_username"], + "activated_at": row["activated_at"], + "expires_at": row["expires_at"], + "max_uses": row["max_uses"], + "uses": row["uses"] + }) + return {"success": True, "passes": passes} + + +@router.post("/whitelist/mods/{mod_hash}/delete") +async def admin_delete_whitelist_mod_adapter( + mod_hash: str, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Delete whitelist mod (adapter for POST-based deletion)""" + return await remove_whitelist_mod(mod_hash, current_user, request) + + +# ====================== ADMIN NEWS ====================== + +ADMIN_NEWS_DIR = Path(__file__).parent / "news" + + +class CreateNewsRequest(BaseModel): + title: str + type: str = "Update" + version: str = "" + body: str + + +class DeleteNewsRequest(BaseModel): + index: int + + +@router.post("/news") +async def admin_create_news( + body: CreateNewsRequest, + current_user: dict = Depends(require_role(ROLE_MODERATOR)), + request: Request = None, +): + """Create a news item""" + if not body.title.strip() or not body.body.strip(): + raise HTTPException(400, "Title and body are required") + ADMIN_NEWS_DIR.mkdir(parents=True, exist_ok=True) + news_id = str(int(time.time())) + file_path = ADMIN_NEWS_DIR / f"{news_id}.txt" + content = f"{body.title.strip()}\n{body.type.strip()}\n{body.version.strip()}\n{body.body.strip()}" + file_path.write_text(content, encoding="utf-8") + ip = request.client.host if request.client else "unknown" + log_audit(current_user["id"], "news_create", f"Created news: {body.title}", ip) + return {"success": True, "id": news_id} + + +@router.post("/news/delete") +async def admin_delete_news( + body: DeleteNewsRequest, + current_user: dict = Depends(require_role(ROLE_MODERATOR)), + request: Request = None, +): + """Delete a news item by displayed index (0 = newest)""" + if not ADMIN_NEWS_DIR.exists(): + raise HTTPException(404, "No news to delete") + files = sorted(ADMIN_NEWS_DIR.iterdir()) + news_files = [f for f in files if f.is_file() and f.suffix == ".txt"] + if body.index < 0 or body.index >= len(news_files): + raise HTTPException(404, "News not found") + # Displayed list is reversed (newest first), so map index + target = news_files[len(news_files) - 1 - body.index] + target.unlink() + ip = request.client.host if request.client else "unknown" + log_audit(current_user["id"], "news_delete", f"Deleted news: {target.stem}", ip) + return {"success": True, "id": target.stem} + + +# ====================== PACK MANAGEMENT (ELDER+) ====================== + +@router.get("/packs") +async def admin_list_packs( + current_user: dict = Depends(require_role(ROLE_ELDER)), +): + """List all packs with disabled status""" + packs = [] + packs_config = load_packs_config() + + packs_dir = Path("packs") + if packs_dir.exists(): + for pack_dir in packs_dir.iterdir(): + if pack_dir.is_dir(): + entry = packs_config.get(pack_dir.name, {}) + packs.append({ + "name": pack_dir.name, + "disabled": entry.get("disabled", False), + "presets": get_pack_presets(pack_dir.name) + }) + + return {"packs": packs} + + +@router.post("/packs/{pack_name}/toggle") +async def admin_toggle_pack( + pack_name: str, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Toggle pack disabled/enabled status""" + pack_path = PACKS_DIR / pack_name + if not pack_path.exists() or not pack_path.is_dir(): + raise HTTPException(404, "Pack not found") + + new_state = toggle_pack_disabled(pack_name) + + ip = request.client.host if request.client else "unknown" + log_audit( + current_user["id"], + "pack_toggle", + f"{'Disabled' if new_state else 'Enabled'} pack {pack_name}", + ip + ) + + return { + "success": True, + "pack_name": pack_name, + "disabled": new_state + } + + +@router.get("/me") +async def get_my_info(current_user: dict = Depends(get_current_user)): + """Информация о текущем пользователе с правами""" + with get_db() as conn: + row = conn.execute(""" + SELECT id, username, uuid, role, created_at, last_login + FROM users WHERE id = ? + """, (current_user["id"],)).fetchone() + + # Проверяем наличие активной проходки + has_pass = False + if row["role"] >= ROLE_PASS_HOLDER: + pass_row = conn.execute(""" + SELECT 1 FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + """, (current_user["id"], time.time())).fetchone() + has_pass = pass_row is not None + + permissions = list(ROLE_PERMISSIONS.get(row["role"], set())) + + return { + "id": row["id"], + "username": row["username"], + "uuid": row["uuid"], + "role": row["role"], + "role_name": ROLE_NAMES.get(row["role"], "Неизвестно"), + "created_at": row["created_at"], + "last_login": row["last_login"], + "has_pass": has_pass, + "permissions": permissions + } \ No newline at end of file diff --git a/server/auth.py b/server/auth.py index eef9827..154291f 100644 --- a/server/auth.py +++ b/server/auth.py @@ -8,25 +8,36 @@ import time from datetime import datetime from pathlib import Path from typing import Optional +from contextlib import contextmanager import structlog -from fastapi import APIRouter, HTTPException, Request, Depends +from cachetools import TTLCache +from fastapi import APIRouter, HTTPException, Request, Depends, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator +import re logger = structlog.get_logger(__name__) # ====================== КОНФИГ ====================== AUTH_DB = Path("data/auth.db") AUTH_DB.parent.mkdir(exist_ok=True) - SECRET_KEY = Path("data/.secret_key") -ACCESS_TOKEN_EXPIRE_SECONDS = 24 * 3600 # 24 часа -REFRESH_TOKEN_EXPIRE_SECONDS = 30 * 86400 # 30 дней +# Токены +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа +REFRESH_TOKEN_EXPIRE_DAYS = 30 + +# Лимиты +MAX_LOGIN_ATTEMPTS = 5 +LOGIN_BLOCK_MINUTES = 15 + +# Rate limiting — in-memory TTL cache (1 hour TTL, max 1000 IPs) +_rate_limit_cache: TTLCache = TTLCache(maxsize=1000, ttl=LOGIN_BLOCK_MINUTES * 60 * 4) # ====================== СЕКРЕТНЫЙ КЛЮЧ ====================== def _get_secret() -> bytes: + """Безопасное получение/создание секретного ключа""" if SECRET_KEY.exists(): return SECRET_KEY.read_bytes() key = secrets.token_bytes(64) @@ -36,31 +47,46 @@ def _get_secret() -> bytes: _SECRET = _get_secret() -def create_jwt(payload: dict) -> str: +# ====================== JWT ФУНКЦИИ ====================== +def create_jwt(payload: dict, expires_in: int = None) -> str: + """Создание JWT токена""" + if expires_in is None: + expires_in = ACCESS_TOKEN_EXPIRE_MINUTES * 60 + + payload = payload.copy() + payload["exp"] = time.time() + expires_in + payload["iat"] = time.time() + payload["jti"] = secrets.token_hex(16) + header = base64.urlsafe_b64encode( json.dumps({"alg": "HS256", "typ": "JWT"}).encode() ).rstrip(b'=').decode() + body = base64.urlsafe_b64encode( json.dumps(payload).encode() ).rstrip(b'=').decode() + msg = f"{header}.{body}".encode() sig = hmac.new(_SECRET, msg, hashlib.sha256).digest() + return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}" def verify_jwt(token: str) -> Optional[dict]: + """Верификация JWT токена""" try: parts = token.split(".") if len(parts) != 3: return None - header, body, sig = parts - msg = f"{header}.{body}".encode() - expected = hmac.new(_SECRET, msg, hashlib.sha256).digest() - # Исправлено: правильный паддинг для base64url + header, body, sig = parts + sig_padded = sig + '=' * (4 - len(sig) % 4) + expected_sig = base64.urlsafe_b64decode(sig_padded) + + msg = f"{header}.{body}".encode() if not hmac.compare_digest( - base64.urlsafe_b64decode(sig_padded), - expected + hmac.new(_SECRET, msg, hashlib.sha256).digest(), + expected_sig ): return None @@ -69,75 +95,141 @@ def verify_jwt(token: str) -> Optional[dict]: if payload.get("exp", 0) < time.time(): return None + return payload except Exception: return None # ====================== БАЗА ДАННЫХ ====================== +@contextmanager def get_db(): - conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False) + """Контекстный менеджер для БД""" + conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False, timeout=10) conn.row_factory = sqlite3.Row - return conn + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() def init_db(): - conn = get_db() - conn.executescript(""" - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE COLLATE NOCASE, - password_hash TEXT NOT NULL, - uuid TEXT UNIQUE NOT NULL, - created_at REAL NOT NULL, - last_login REAL - ); + """Инициализация основной БД""" + with get_db() as conn: + conn.executescript("PRAGMA journal_mode=WAL;") + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + uuid TEXT UNIQUE NOT NULL, + role INTEGER DEFAULT 0, + created_at REAL NOT NULL, + last_login REAL, + is_active BOOLEAN DEFAULT 1, + banned_until REAL + ); - CREATE TABLE IF NOT EXISTS passes ( - code TEXT PRIMARY KEY, - owner TEXT, - is_active BOOLEAN DEFAULT 1, - activated_by INTEGER REFERENCES users(id), - activated_at REAL, - expires_at REAL, - max_uses INTEGER DEFAULT 1, - uses INTEGER DEFAULT 0 - ); + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + jti TEXT NOT NULL, + expires_at REAL NOT NULL, + revoked BOOLEAN DEFAULT 0, + created_at REAL NOT NULL + ); - CREATE TABLE IF NOT EXISTS user_passes ( - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - pass_code TEXT REFERENCES passes(code), - activated_at REAL NOT NULL, - PRIMARY KEY (user_id, pass_code) - ); - - CREATE TABLE IF NOT EXISTS refresh_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL, - expires_at REAL NOT NULL - ); - """) - conn.commit() - conn.close() + CREATE TABLE IF NOT EXISTS user_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + created_at REAL NOT NULL, + expires_at REAL NOT NULL, + is_active BOOLEAN DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS passes ( + code TEXT PRIMARY KEY, + owner TEXT, + is_active BOOLEAN DEFAULT 1, + activated_by INTEGER REFERENCES users(id), + activated_at REAL, + expires_at REAL, + max_uses INTEGER DEFAULT 1, + uses INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS user_passes ( + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + pass_code TEXT REFERENCES passes(code), + activated_at REAL NOT NULL, + PRIMARY KEY (user_id, pass_code) + ); + + CREATE TABLE IF NOT EXISTS pass_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + requester_id INTEGER NOT NULL REFERENCES users(id), + target_username TEXT NOT NULL, + reason TEXT, + status TEXT DEFAULT 'pending', + decision_reason TEXT, + created_at REAL NOT NULL, + reviewed_by INTEGER REFERENCES users(id), + reviewed_at REAL + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + action TEXT NOT NULL, + details TEXT, + ip_address TEXT, + timestamp REAL NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id); + CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); + CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id); + """) + + # Добавляем колонку role если её нет + cursor = conn.execute("PRAGMA table_info(users)") + columns = [col[1] for col in cursor.fetchall()] + + if "role" not in columns: + conn.execute("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 0") + logger.info("Added role column to users table") + logger.info("Auth database initialized") # ====================== ХЕЛПЕРЫ ====================== def hash_password(password: str) -> str: - salt = secrets.token_hex(16) + """Хэширование пароля""" + salt = secrets.token_hex(32) hash_obj = hashlib.pbkdf2_hmac( 'sha256', - password.encode(), - salt.encode(), + password.encode('utf-8'), + salt.encode('utf-8'), 300000 ) return f"{salt}${hash_obj.hex()}" def verify_password(password: str, stored: str) -> bool: + """Верификация пароля""" try: salt, stored_hash = stored.split('$') hash_obj = hashlib.pbkdf2_hmac( 'sha256', - password.encode(), - salt.encode(), + password.encode('utf-8'), + salt.encode('utf-8'), 300000 ) return hmac.compare_digest(hash_obj.hex(), stored_hash) @@ -145,283 +237,557 @@ def verify_password(password: str, stored: str) -> bool: return False def generate_uuid() -> str: + """Генерация UUID""" return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}" +def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]: + """Check rate limiting — blocks IP after MAX_LOGIN_ATTEMPTS failed attempts""" + now = time.time() + entry = _rate_limit_cache.get(ip) + + if entry is None: + return True, None + + # Check if currently blocked + if entry.get("blocked_until", 0) > now: + remaining = int(entry["blocked_until"] - now) + return False, remaining + + # Reset block if expired + if entry["attempts"] >= MAX_LOGIN_ATTEMPTS: + entry["blocked_until"] = now + (LOGIN_BLOCK_MINUTES * 60) + _rate_limit_cache[ip] = entry + return False, LOGIN_BLOCK_MINUTES * 60 + + return True, None + +def record_login_attempt(ip: str, success: bool): + """Record login attempt — resets on success, increments on failure""" + now = time.time() + + if success: + _rate_limit_cache.pop(ip, None) + return + + entry = _rate_limit_cache.get(ip, {"attempts": 0, "blocked_until": 0}) + entry["attempts"] += 1 + entry["last_attempt"] = now + + if entry["attempts"] >= MAX_LOGIN_ATTEMPTS: + entry["blocked_until"] = now + (LOGIN_BLOCK_MINUTES * 60) + + _rate_limit_cache[ip] = entry + +def log_audit(user_id: int, action: str, details: str, ip_address: str): + """Логирование действий""" + with get_db() as conn: + conn.execute( + "INSERT INTO audit_log (user_id, action, details, ip_address, timestamp) VALUES (?, ?, ?, ?, ?)", + (user_id, action, details, ip_address, time.time()) + ) + # ====================== МОДЕЛИ ====================== class LoginRequest(BaseModel): - username: str - password: str + username: str = Field(..., min_length=3, max_length=32) + password: str = Field(..., min_length=6, max_length=128) + + @field_validator('username') + def validate_username(cls, v): + if not re.match(r'^[a-zA-Z0-9_]+$', v): + raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания') + return v class RegisterRequest(BaseModel): - username: str = Field(..., min_length=3, max_length=16, pattern=r"^[a-zA-Z0-9_]+$") + username: str = Field(..., min_length=3, max_length=32) password: str = Field(..., min_length=6, max_length=128) + + @field_validator('username') + def validate_username(cls, v): + if not re.match(r'^[a-zA-Z0-9_]+$', v): + raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания') + return v class TokenResponse(BaseModel): access_token: str refresh_token: str expires_in: int + token_type: str = "bearer" username: str uuid: str + role: int + role_name: str -# ====================== ROUTER ====================== +# ====================== DEPENDENCIES ====================== router = APIRouter(prefix="/auth", tags=["auth"]) bearer = HTTPBearer(auto_error=False) -def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse: - now = time.time() +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer), + request: Request = None +) -> dict: + """Получение текущего пользователя""" + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Не авторизован", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = verify_jwt(credentials.credentials) + if not payload or payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный токен" + ) + + with get_db() as conn: + user = conn.execute( + "SELECT id, username, uuid, role, is_active, banned_until FROM users WHERE id = ?", + (payload["sub"],) + ).fetchone() + + if not user: + raise HTTPException(401, "Пользователь не найден") + + if not user["is_active"]: + raise HTTPException(403, "Аккаунт деактивирован") + + if user["banned_until"] and user["banned_until"] > time.time(): + raise HTTPException(403, "Аккаунт забанен") + + return { + "id": user["id"], + "username": user["username"], + "uuid": user["uuid"], + "role": user["role"] + } - access_token = create_jwt({ - "sub": user_id, - "username": username, - "uuid": uuid, - "type": "access", - "exp": now + ACCESS_TOKEN_EXPIRE_SECONDS - }) - - refresh_token = create_jwt({ - "sub": user_id, - "type": "refresh", - "exp": now + REFRESH_TOKEN_EXPIRE_SECONDS - }) - - token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() - - conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,)) - conn.execute( - "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)", - (user_id, token_hash, now + REFRESH_TOKEN_EXPIRE_SECONDS) - ) - conn.commit() - - return TokenResponse( - access_token=access_token, - refresh_token=refresh_token, - expires_in=ACCESS_TOKEN_EXPIRE_SECONDS, - username=username, - uuid=uuid - ) +def require_role(min_role: int): + """Dependency for checking minimum required role""" + from roles import UserRole + async def dependency(current_user: dict = Depends(get_current_user)): + if current_user["role"] < min_role: + from roles import ROLE_NAMES + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Требуется роль {ROLE_NAMES.get(min_role, 'неизвестная')}" + ) + return current_user + return dependency +# ====================== ЭНДПОИНТЫ ====================== @router.post("/register", response_model=TokenResponse) async def register(body: RegisterRequest, request: Request): - conn = get_db() - try: + """Регистрация нового пользователя""" + ip = request.client.host if request.client else "unknown" + + allowed, wait = check_rate_limit(ip) + if not allowed: + logger.warning("register rate limited", username=body.username, client_ip=ip) + raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") + + with get_db() as conn: existing = conn.execute( - "SELECT 1 FROM users WHERE username = ? COLLATE NOCASE", + "SELECT username FROM users WHERE username = ?", (body.username,) ).fetchone() if existing: - raise HTTPException(status_code=409, detail="Имя пользователя уже занято") - + logger.warning("register failed: username taken", username=body.username, client_ip=ip) + raise HTTPException(409, "Пользователь с таким именем уже существует") + uuid = generate_uuid() pw_hash = hash_password(body.password) now = time.time() - + cursor = conn.execute( - "INSERT INTO users (username, password_hash, uuid, created_at) VALUES (?, ?, ?, ?)", - (body.username, pw_hash, uuid, now) + """INSERT INTO users (username, password_hash, uuid, created_at, role) + VALUES (?, ?, ?, ?, ?)""", + (body.username, pw_hash, uuid, now, 0) # role 0 = обычный пользователь ) - conn.commit() user_id = cursor.lastrowid - tokens = _issue_tokens(conn, user_id, body.username, uuid) + logger.info("register ok", username=body.username, user_id=user_id, client_ip=ip) - logger.info("User registered", username=body.username, user_id=user_id) - return tokens - - except HTTPException: - raise - except Exception as e: - logger.error("Register error", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") - finally: - conn.close() + # Создаем сессию + session_token = secrets.token_urlsafe(32) + conn.execute( + "INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", + (user_id, session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)) + ) + + # Токены + access_token = create_jwt({ + "sub": user_id, + "username": body.username, + "uuid": uuid, + "role": 0, + "type": "access", + "jti": session_token + }) + + refresh_token = create_jwt({ + "sub": user_id, + "type": "refresh", + "jti": secrets.token_hex(16) + }, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400) + + refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + conn.execute( + "INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + (user_id, refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now) + ) + + log_audit(user_id, "register", f"User registered from {ip}", ip) + logger.info("User registered", username=body.username, user_id=user_id, ip=ip) + + from roles import ROLE_NAMES + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60, + username=body.username, + uuid=uuid, + role=0, + role_name=ROLE_NAMES[0] + ) @router.post("/login", response_model=TokenResponse) async def login(body: LoginRequest, request: Request): - conn = get_db() - try: - row = conn.execute( - "SELECT id, username, password_hash, uuid FROM users WHERE username = ? COLLATE NOCASE", + """Вход в систему""" + ip = request.client.host if request.client else "unknown" + + allowed, wait = check_rate_limit(ip) + if not allowed: + logger.warning("login rate limited", username=body.username, client_ip=ip) + raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") + + with get_db() as conn: + user = conn.execute( + "SELECT id, username, uuid, password_hash, role, is_active, banned_until FROM users WHERE username = ?", (body.username,) ).fetchone() - - if not row or not verify_password(body.password, row["password_hash"]): + + if not user or not verify_password(body.password, user["password_hash"]): + logger.warning("login failed: bad credentials", username=body.username, client_ip=ip) + record_login_attempt(ip, False) raise HTTPException(401, "Неверное имя пользователя или пароль") - + + if not user["is_active"]: + logger.warning("login failed: account deactivated", username=body.username, client_ip=ip) + raise HTTPException(403, "Аккаунт деактивирован") + + if user["banned_until"] and user["banned_until"] > time.time(): + logger.warning("login failed: account banned", username=body.username, client_ip=ip) + raise HTTPException(403, "Аккаунт забанен") + + logger.info("login ok", username=user["username"], user_id=user["id"], client_ip=ip) + + record_login_attempt(ip, True) + + now = time.time() conn.execute( "UPDATE users SET last_login = ? WHERE id = ?", - (time.time(), row["id"]) + (now, user["id"]) ) - conn.commit() + + # Создаем сессию + session_token = secrets.token_urlsafe(32) + conn.execute( + "INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", + (user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)) + ) + + access_token = create_jwt({ + "sub": user["id"], + "username": user["username"], + "uuid": user["uuid"], + "role": user["role"], + "type": "access", + "jti": session_token + }) + + refresh_token = create_jwt({ + "sub": user["id"], + "type": "refresh", + "jti": secrets.token_hex(16) + }, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400) + + refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + conn.execute( + "INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + (user["id"], refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now) + ) + + user_id = user["id"] + username = user["username"] + user_role = user["role"] + + log_audit(user_id, "login", f"User logged in from {ip}", ip) + logger.info("User logged in", username=username, user_id=user_id, ip=ip) + + from roles import ROLE_NAMES + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60, + username=username, + uuid=user["uuid"], + role=user_role, + role_name=ROLE_NAMES.get(user_role, "Неизвестно") + ) - logger.info("User logged in", username=body.username, user_id=row["id"]) - return _issue_tokens(conn, row["id"], row["username"], row["uuid"]) - finally: - conn.close() +@router.post("/logout") +async def logout(current_user: dict = Depends(get_current_user), request: Request = None): + """Выход из системы""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + conn.execute( + "UPDATE user_sessions SET is_active = 0 WHERE user_id = ?", + (current_user["id"],) + ) + conn.execute( + "UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?", + (current_user["id"],) + ) + + log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip) + + logger.info("User logged out", user_id=current_user["id"], ip=ip) + return {"success": True} @router.post("/refresh") -async def refresh(body: dict): +async def refresh(body: dict, request: Request): + """Обновление access токена""" refresh_token = body.get("refresh_token") if not refresh_token: raise HTTPException(400, "refresh_token обязателен") - + payload = verify_jwt(refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException(401, "Недействительный refresh token") - - conn = get_db() - try: + + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() - row = conn.execute( - "SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?", + token_row = conn.execute( + "SELECT user_id, revoked FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?", (token_hash, time.time()) ).fetchone() - - if not row: + + if not token_row or token_row["revoked"]: raise HTTPException(401, "Refresh token истёк или недействителен") - - user_row = conn.execute( - "SELECT id, username, uuid FROM users WHERE id = ?", - (row["user_id"],) + + user = conn.execute( + "SELECT id, username, uuid, role FROM users WHERE id = ? AND is_active = 1", + (token_row["user_id"],) ).fetchone() + + if not user: + raise HTTPException(401, "Пользователь не найден или заблокирован") + + now = time.time() + session_token = secrets.token_urlsafe(32) + conn.execute( + "INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", + (user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)) + ) + + new_access_token = create_jwt({ + "sub": user["id"], + "username": user["username"], + "uuid": user["uuid"], + "role": user["role"], + "type": "access", + "jti": session_token + }) + + new_refresh_token = create_jwt({ + "sub": user["id"], + "type": "refresh", + "jti": secrets.token_hex(16) + }, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400) + + new_refresh_hash = hashlib.sha256(new_refresh_token.encode()).hexdigest() + conn.execute( + "INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + (user["id"], new_refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now) + ) + + # Revoke old refresh token + conn.execute( + "UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?", + (token_hash,) + ) + + uid = user["id"] + uname = user["username"] + urole = user["role"] + uuuid = user["uuid"] + + log_audit(uid, "refresh_token", f"Token refreshed from {ip}", ip) + + from roles import ROLE_NAMES + return { + "access_token": new_access_token, + "refresh_token": new_refresh_token, + "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60, + "username": uname, + "uuid": uuuid, + "role": urole, + "role_name": ROLE_NAMES.get(urole, "Неизвестно"), + } - if not user_row: - raise HTTPException(401, "Пользователь не найден") - - return _issue_tokens(conn, user_row["id"], user_row["username"], user_row["uuid"]) - finally: - conn.close() - -@router.post("/logout") -async def logout(body: dict): - refresh_token = body.get("refresh_token") - if refresh_token: - conn = get_db() - try: - token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() - conn.execute( - "DELETE FROM refresh_tokens WHERE token_hash = ?", - (token_hash,) - ) - conn.commit() - finally: - conn.close() - return {"success": True} - -# ====================== ПРОХОДКИ ====================== - -class ActivatePassRequest(BaseModel): - pass_code: str = Field(..., min_length=8, max_length=20) - -@router.post("/pass/activate") -async def activate_pass_endpoint( - body: ActivatePassRequest, - credentials: HTTPAuthorizationCredentials = Depends(bearer) -): +@router.post("/validate") +async def validate_token(request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer)): + """Validate token endpoint for Minecraft server — checks ban status""" if not credentials: raise HTTPException(401, "Требуется авторизация") - + payload = verify_jwt(credentials.credentials) if not payload or payload.get("type") != "access": raise HTTPException(401, "Недействительный токен") - - user_id = payload["sub"] - username = payload["username"] - pass_code = body.pass_code.upper().strip() - - conn = get_db() + try: - pass_row = conn.execute( - "SELECT code, expires_at, uses, max_uses, owner FROM passes WHERE code = ?", - (pass_code,) - ).fetchone() - - if not pass_row: - raise HTTPException(404, "Проходка не найдена") - - # Проверка срока - if pass_row["expires_at"] and pass_row["expires_at"] < time.time(): - raise HTTPException(410, "Проходка истекла") - - # Проверка лимита использований - if pass_row["uses"] >= pass_row["max_uses"]: - raise HTTPException(410, "Проходка уже использована") - - # Проверка владельца - if pass_row["owner"] is not None: - if pass_row["owner"] != username: - raise HTTPException(409, "Проходка уже активирована другим пользователем") + body = await request.json() + username = body.get("username") + uuid = body.get("uuid") + + # Verify that token belongs to this user + if payload.get("username") != username or payload.get("uuid") != uuid: + raise HTTPException(403, "Token does not match user") + + # Check ban status in DB + with get_db() as conn: + user = conn.execute( + "SELECT is_active, banned_until FROM users WHERE id = ?", + (payload["sub"],), + ).fetchone() - # Уже активирована этим пользователем - return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"} - - now = time.time() - - # Активация - conn.execute( - "INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)", - (user_id, pass_code, now) - ) - - conn.execute( - """UPDATE passes - SET uses = uses + 1, - owner = ?, - activated_by = ?, - activated_at = ? - WHERE code = ?""", - (username, user_id, now, pass_code) - ) - - conn.commit() - - logger.info("Pass activated", user_id=user_id, username=username, pass_code=pass_code) - return {"success": True, "message": "Проходка успешно активирована!"} - + if not user: + return {"valid": False, "reason": "User not found"} + + if not user["is_active"]: + return {"valid": False, "reason": "Account deactivated"} + + if user["banned_until"] and user["banned_until"] > time.time(): + return {"valid": False, "reason": "Account banned"} + + return {"valid": True, "username": username, "uuid": uuid} + except HTTPException: raise except Exception as e: - logger.error("Pass activation error", exc_info=True) - raise HTTPException(500, f"Ошибка сервера: {str(e)}") - finally: - conn.close() + logger.error(f"Token validation error: {e}") + raise HTTPException(400, "Invalid request") + + +class ActivatePassRequest(BaseModel): + pass_code: str = Field(..., min_length=3, max_length=64) + + +@router.post("/pass/activate") +async def activate_pass( + body: ActivatePassRequest, + current_user: dict = Depends(get_current_user), + request: Request = None, +): + """Activate a pass code for the current user""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + # Check if pass exists and is active + pass_row = conn.execute( + "SELECT code, owner, is_active, expires_at, max_uses, uses, activated_by FROM passes WHERE code = ?", + (body.pass_code,), + ).fetchone() + + if not pass_row: + raise HTTPException(404, "Проходка не найдена") + + if not pass_row["is_active"]: + raise HTTPException(400, "Проходка уже использована или отозвана") + + if pass_row["uses"] >= pass_row["max_uses"]: + raise HTTPException(400, "Проходка достигла лимита использований") + + if pass_row["expires_at"] and pass_row["expires_at"] < time.time(): + raise HTTPException(400, "Проходка истекла") + + # Check if user already has an active pass + existing = conn.execute( + "SELECT 1 FROM user_passes WHERE user_id = ? AND pass_code = ?", + (current_user["id"], body.pass_code), + ).fetchone() + + if existing: + raise HTTPException(409, "Эта проходка уже активирована вами") + + existing_pass = conn.execute(""" + SELECT 1 FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + """, (current_user["id"], time.time())).fetchone() + + if existing_pass: + raise HTTPException(409, "У вас уже есть активная проходка") + + now = time.time() + + # Link pass to user + conn.execute( + "INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)", + (current_user["id"], body.pass_code, now), + ) + + # Increment usage count + conn.execute( + "UPDATE passes SET uses = uses + 1, activated_by = ?, activated_at = ? WHERE code = ?", + (current_user["id"], now, body.pass_code), + ) + + # Upgrade user role if they don't have a higher role + if current_user["role"] < 1: + conn.execute( + "UPDATE users SET role = 1 WHERE id = ?", + (current_user["id"],), + ) + + uid = current_user["id"] + uname = current_user["username"] + pcode = body.pass_code + + log_audit( + uid, + "pass_activated", + f"Pass activated: {pcode[:8]}...", + ip, + ) + + logger.info( + "Pass activated", + user=uname, + user_id=uid, + pass_code=pcode, + ip=ip, + ) + + return { + "success": True, + "message": f"Проходка активирована для {uname}", + "role": 1, + } + @router.get("/pass/my") -async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)): - if not credentials: - raise HTTPException(401, "Требуется авторизация") - - payload = verify_jwt(credentials.credentials) - if not payload: - raise HTTPException(401, "Недействительный токен") - - user_id = payload["sub"] - - conn = get_db() - try: - rows = conn.execute(""" - SELECT p.code, p.expires_at, p.is_active, up.activated_at - FROM user_passes up +async def my_pass_status(current_user: dict = Depends(get_current_user)): + """Check if current user has an active pass""" + with get_db() as conn: + row = conn.execute(""" + SELECT 1 FROM user_passes up JOIN passes p ON up.pass_code = p.code - WHERE up.user_id = ? - """, (user_id,)).fetchall() - - passes = [] - now = time.time() - for row in rows: - expires = row["expires_at"] - is_active = row["is_active"] and (expires is None or expires > now) - passes.append({ - "code": row["code"], - "activated_at": row["activated_at"], - "expires_at": expires, - "is_active": is_active - }) - - return { - "passes": passes, - "has_active": any(p["is_active"] for p in passes) - } - finally: - conn.close() \ No newline at end of file + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + """, (current_user["id"], time.time())).fetchone() + return {"has_active": row is not None} diff --git a/server/cli.py b/server/cli.py index f76650c..9709f31 100644 --- a/server/cli.py +++ b/server/cli.py @@ -15,14 +15,15 @@ def parse_args(): mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload") mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers") mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests") - + mode_group.add_argument("--sync", action="store_true", help="Sync mode - sync with main server as mirror") + # Additional options parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)") - parser.add_argument("--workers", type=int, default=4, help="Number of workers for production mode") + parser.add_argument("--workers", type=int, default=1, help="Number of workers for production mode (default: 1, more causes file download slowdown)") parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)") - return parser.parse_args() + return parser.parse_known_args()[0] async def run_test_mode(): """Validate all packs and generate/update manifests""" @@ -53,6 +54,43 @@ async def run_test_mode(): logger.info("All packs validated successfully") sys.exit(0) +async def run_sync_mode(): + """Sync with main server as mirror""" + import os + + main_url = os.environ.get("MAIN_SERVER_URL") + if not main_url: + logger.error("MAIN_SERVER_URL not set. Run: MAIN_SERVER_URL=http://main:1582 python cli.py --sync") + sys.exit(1) + + logger.info(f"Starting mirror sync from {main_url}") + + # Get version from main + import httpx + async with httpx.AsyncClient() as client: + # Get version + try: + resp = await client.get(f"{main_url}/launcher/version") + data = resp.json() + version = data.get("version") + logger.info(f"Main server version: {version}") + except Exception as e: + logger.error(f"Failed to get version from main: {e}") + sys.exit(1) + + # Get sync manifest + try: + resp = await client.get(f"{main_url}/launcher/sync/{version}") + sync_data = resp.json() + logger.info(f"Files to sync: {len(sync_data.get('files', []))}") + except Exception as e: + logger.error(f"Failed to get sync manifest: {e}") + sys.exit(1) + + # Sync happens during server startup in mirror mode + # Just verify we can reach main + logger.info("Mirror sync configured. Server will sync on startup.") + def run_production_mode(host: str, port: int, workers: int): """Run with multiple workers""" logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}") diff --git a/server/friends.py b/server/friends.py new file mode 100644 index 0000000..67f7b95 --- /dev/null +++ b/server/friends.py @@ -0,0 +1,176 @@ +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from typing import Optional +import structlog +import time + +from auth import get_db, get_current_user + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix="/api", tags=["friends"]) + +def init_friends_db(): + with get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS friendships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + requester_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(requester_id, target_id), + FOREIGN KEY (requester_id) REFERENCES users(id), + FOREIGN KEY (target_id) REFERENCES users(id) + ); + CREATE TABLE IF NOT EXISTS user_status ( + user_id INTEGER PRIMARY KEY, + is_online INTEGER DEFAULT 0, + current_pack TEXT DEFAULT '', + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id); + CREATE INDEX IF NOT EXISTS idx_friendships_target ON friendships(target_id); + """) + +class AddFriendRequest(BaseModel): + username: str + +class RemoveFriendRequest(BaseModel): + user_id: int + +class AcceptFriendRequest(BaseModel): + user_id: int + +class StatusUpdateRequest(BaseModel): + online: bool = True + current_pack: Optional[str] = None + +@router.post("/friends/add") +async def add_friend( + req: AddFriendRequest, + current_user: dict = Depends(get_current_user) +): + with get_db() as conn: + cursor = conn.execute("SELECT id FROM users WHERE username = ?", (req.username,)) + target = cursor.fetchone() + if not target: + raise HTTPException(404, "User not found") + target_id = target[0] + + if target_id == current_user["id"]: + raise HTTPException(400, "Cannot add yourself") + + cursor = conn.execute( + "SELECT status FROM friendships WHERE requester_id = ? AND target_id = ?", + (current_user["id"], target_id) + ) + existing = cursor.fetchone() + if existing: + if existing[0] == "accepted": + raise HTTPException(400, "Already friends") + raise HTTPException(400, f"Friend request already {existing[0]}") + + conn.execute( + "INSERT INTO friendships (requester_id, target_id, status) VALUES (?, ?, 'pending')", + (current_user["id"], target_id) + ) + logger.info("Friend request sent", from_user=current_user["id"], to_user=target_id) + return {"message": "Friend request sent"} + +@router.post("/friends/accept") +async def accept_friend( + req: AcceptFriendRequest, + current_user: dict = Depends(get_current_user) +): + with get_db() as conn: + cursor = conn.execute( + "SELECT id, requester_id FROM friendships WHERE target_id = ? AND requester_id = ? AND status = 'pending'", + (current_user["id"], req.user_id) + ) + row = cursor.fetchone() + if not row: + raise HTTPException(404, "No pending friend request from this user") + conn.execute("UPDATE friendships SET status = 'accepted' WHERE id = ?", (row[0],)) + logger.info("Friend request accepted", from_user=req.user_id, to_user=current_user["id"]) + return {"message": "Friend request accepted"} + +@router.post("/friends/remove") +async def remove_friend( + req: RemoveFriendRequest, + current_user: dict = Depends(get_current_user) +): + with get_db() as conn: + cursor = conn.execute( + "SELECT id FROM friendships WHERE (requester_id = ? AND target_id = ?) OR (requester_id = ? AND target_id = ?)", + (current_user["id"], req.user_id, req.user_id, current_user["id"]) + ) + row = cursor.fetchone() + if not row: + raise HTTPException(404, "Not friends") + conn.execute("DELETE FROM friendships WHERE id = ?", (row[0],)) + logger.info("Friend removed", user=current_user["id"], target=req.user_id) + return {"message": "Friend removed"} + +@router.get("/friends/list") +async def list_friends(current_user: dict = Depends(get_current_user)): + friends = [] + with get_db() as conn: + rows = conn.execute(""" + SELECT u.id, u.username, u.role, + COALESCE(us.is_online, 0) as online, + COALESCE(us.current_pack, '') as current_pack, + us.last_seen + FROM friendships f + JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id + LEFT JOIN user_status us ON u.id = us.user_id + WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted' + """, (current_user["id"], current_user["id"], current_user["id"])) + + for row in rows: + friends.append({ + "id": row[0], + "username": row[1], + "role": row[2], + "online": bool(row[3]), + "current_pack": row[4], + "last_seen": row[5] if row[5] else None + }) + + return {"friends": friends} + +@router.get("/friends/requests") +async def list_friend_requests(current_user: dict = Depends(get_current_user)): + requests = [] + with get_db() as conn: + rows = conn.execute(""" + SELECT u.id, u.username, u.role, f.created_at + FROM friendships f + JOIN users u ON f.requester_id = u.id + WHERE f.target_id = ? AND f.status = 'pending' + """, (current_user["id"],)) + for row in rows: + requests.append({ + "id": row[0], + "username": row[1], + "role": row[2], + "created_at": row[3] if row[3] else None + }) + return {"requests": requests} + +@router.post("/friends/status") +async def update_status( + req: StatusUpdateRequest, + current_user: dict = Depends(get_current_user) +): + with get_db() as conn: + conn.execute(""" + INSERT INTO user_status (user_id, is_online, current_pack, last_seen) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + is_online = excluded.is_online, + current_pack = COALESCE(excluded.current_pack, user_status.current_pack), + last_seen = CURRENT_TIMESTAMP + """, (current_user["id"], int(req.online), req.current_pack or "")) + return {"status": "ok"} diff --git a/server/http_logger.py b/server/http_logger.py deleted file mode 100644 index 587f502..0000000 --- a/server/http_logger.py +++ /dev/null @@ -1,25 +0,0 @@ -# http_logger.py -import logging -from fastapi import Request, Response -from starlette.middleware.base import BaseHTTPMiddleware -import time -import uuid - -logger = logging.getLogger("uvicorn.error") - -class HTTPLogger: - """Custom HTTP logger to catch invalid requests""" - - @staticmethod - def log_invalid_request(data: bytes, client_addr: tuple): - """Log invalid HTTP requests""" - try: - # Try to decode as much as possible - request_str = data.decode('utf-8', errors='replace')[:500] - logger.warning( - f"Invalid HTTP request received\n" - f"Client: {client_addr[0]}:{client_addr[1]}\n" - f"Data: {request_str}" - ) - except Exception as e: - logger.warning(f"Invalid HTTP request from {client_addr}, could not decode: {e}") \ No newline at end of file diff --git a/server/main.py b/server/main.py index 0e4b013..5d5c895 100644 --- a/server/main.py +++ b/server/main.py @@ -1,25 +1,42 @@ -from fastapi import FastAPI, HTTPException, Request, Response -from fastapi.responses import FileResponse, JSONResponse +import re +import logging +import os from contextlib import asynccontextmanager +from datetime import datetime from pathlib import Path +from urllib.parse import urlparse, quote +from typing import Optional + +import httpx import json import structlog from cachetools import TTLCache -import logging -from datetime import datetime +from fastapi import Depends, FastAPI, HTTPException, Request, Response +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol -from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR +# Disable httpx debug logging +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + +from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR, is_pack_disabled, get_pack_presets, get_merged_manifest from models import PackMeta from middleware import LoggingMiddleware -from cli import parse_args, run_test_mode, run_production_mode, run_development_mode +from cli import parse_args, run_test_mode, run_production_mode, run_development_mode, run_sync_mode from log_manager import init_logging -import httpx -import base64 -from fastapi.responses import StreamingResponse +from auth import get_current_user, router as auth_router, init_db, verify_jwt +from roles import Permissions, has_permission +from admin_router import router as admin_router +from friends import router as friends_router, init_friends_db +from playtime import router as playtime_router, init_playtime_db -from auth import router as auth_router, init_db, verify_jwt +import asyncio +import hashlib +import uuid +import aiofiles +import mimetypes logger = structlog.get_logger(__name__) @@ -27,6 +44,48 @@ logger = structlog.get_logger(__name__) manifest_cache = TTLCache(maxsize=100, ttl=300) BUILDS_DIR = Path("builds") +VERSIONS_DIR = BUILDS_DIR / "versions" +WHITELIST_DIR = Path(__file__).parent / "whitelist" + +# Website (landing) static files +SITE_DIR = Path(__file__).parent / "site" + +# SkinRestorer skins (shared with TG bot) +SKINS_DIR = Path("/root/python/site/skins") + +# Minecraft server status (same host the TG bot pings) +MC_STATUS_HOST = os.environ.get("MC_STATUS_HOST", "mc.zernmc.ru") + +# Mirror configuration +LAUNCHER_MIRRORS = { + "main": "https://api.zern.cc", # primary + "mirror-1": "https://api.zernmc.ru", # legacy + "mirror-2": "https://api.zernmc.online", # legacy + "geo-pl": "https://api.pl.zern.cc", + "geo-swe": "https://api.swe.zern.cc", +} + +# Server role: "main" or "mirror" +SERVER_ROLE = os.environ.get("SERVER_ROLE", "main") +MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "") # For mirrors to sync from +SYNC_API_KEY = os.environ.get("SYNC_API_KEY", "changeme") # API key for mirror sync +MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror + +# IP Filtering Configuration +import middleware as mw + +# Only configure manually blocked IPs at import time +# Public blocklists are loaded in lifespan (once, not per-worker) +MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""} # noqa: F811 + +# Cache file for blocklist (load once) +BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt") + +# Crash reports directory +CRASH_REPORTS_DIR = Path("data/crash_reports") + +# Network diagnostics reports directory +DIAG_REPORTS_DIR = Path("data/diag_reports") @asynccontextmanager @@ -35,7 +94,60 @@ async def lifespan(app: FastAPI): # Initialize logging init_logging() - #logger = logging.getLogger(__name__) + + # Load public blocklists (single worker loads, others wait for cache) + USE_PUBLIC_BLOCKLIST = os.environ.get("PUBLIC_BLOCKLIST", "true").lower() == "true" + all_blocked = set(MANUAL_BLOCKED_IPS) + + if USE_PUBLIC_BLOCKLIST: + cached_ips = set() + + # Try to load from cache first + if BLOCKLIST_CACHE_FILE.exists(): + try: + cached_ips = set(BLOCKLIST_CACHE_FILE.read_text().strip().splitlines()) + if cached_ips: + logger.info(f"Loaded {len(cached_ips)} IPs from blocklist cache") + except Exception as e: + logger.warning(f"Failed to load blocklist cache: {e}") + + # If no cache, download (only one worker will do this) + if not cached_ips: + DATA_DIR.mkdir(exist_ok=True) + lock_file = DATA_DIR / "blocklist.lock" + + try: + # Try to acquire lock (non-blocking) + import fcntl + lock_fd = open(lock_file, 'w') + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + # We got the lock - download + cached_ips = mw.load_public_blocklists() + if cached_ips: + BLOCKLIST_CACHE_FILE.write_text("\n".join(cached_ips)) + logger.info(f"Downloaded and saved {len(cached_ips)} IPs to blocklist cache") + except BlockingIOError: + # Another process is downloading - wait for cache + pass + finally: + lock_fd.close() + except Exception as e: + logger.warning(f"Lock error: {e}") + + # Re-read cache after download + if BLOCKLIST_CACHE_FILE.exists() and not cached_ips: + try: + cached_ips = set(BLOCKLIST_CACHE_FILE.read_text().strip().splitlines()) + if cached_ips: + logger.info(f"Loaded {len(cached_ips)} IPs from blocklist cache (after wait)") + except Exception: + pass + + all_blocked.update(cached_ips) + + mw.set_ip_config(blocked=all_blocked) + logger.info(f"IP blocklist loaded: {len(all_blocked)} IPs") # Determine environment if args.test: @@ -51,10 +163,11 @@ async def lifespan(app: FastAPI): BUILDS_DIR.mkdir(exist_ok=True) PACKS_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True) + CRASH_REPORTS_DIR.mkdir(exist_ok=True) init_db() - - app.include_router(auth_router) + init_friends_db() + init_playtime_db() if args.test: await run_test_mode() @@ -76,17 +189,792 @@ async def lifespan(app: FastAPI): logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True) logger.info("All packs ready. Server is running.") + + # Mirror sync with main server (full diff: hash check + delete stale) + if SERVER_ROLE == "mirror" and MAIN_SERVER_URL: + logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}") + try: + async with httpx.AsyncClient() as client: + headers = {"X-Sync-Key": SYNC_API_KEY} + result = await sync_mirror_full(client, headers, MAIN_SERVER_URL) + if result: + await sync_mirror_self_update(client, headers, MAIN_SERVER_URL) + except Exception as e: + logger.warning(f"Initial mirror sync failed: {e}") + + # Scan launcher versions and generate meta + logger.info("Scanning launcher versions...") + + # Generate meta.json in builds/ directory + logger.info("Generating launcher meta...") + generate_launcher_builds_meta() + + # Extract new format ZIPs to versions directory + logger.info("Extracting new format versions...") + extract_new_format_versions() + + launcher_versions = get_launcher_versions() + if launcher_versions: + latest = launcher_versions[0] + logger.info(f"Launcher meta ready: v{latest['meta']['version']} ({len(latest['meta']['files'])} files)") + else: + logger.warning("No launcher versions found in new format") + + logger.info("Launcher meta system ready.") + + # Initialize proxy client + global proxy_client + proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) + + # Start background sync + self-update task for mirrors + if SERVER_ROLE == "mirror" and MAIN_SERVER_URL: + async def periodic_sync(): + sync_interval = 1800 # 30 minutes + while True: + await asyncio.sleep(sync_interval) + try: + logger.info("Periodic mirror sync started...") + headers = {"X-Sync-Key": SYNC_API_KEY} + result = await sync_mirror_full(proxy_client, headers, MAIN_SERVER_URL) + if result: + await sync_mirror_self_update(proxy_client, headers, MAIN_SERVER_URL) + except Exception as e: + logger.warning(f"Periodic sync error: {e}") + + asyncio.create_task(periodic_sync()) + + # Background task: auto-detect new ZIP archives in builds/ and register them + async def watch_new_zips(): + watch_interval = int(os.environ.get("ZIP_WATCH_INTERVAL", "15")) + while True: + await asyncio.sleep(watch_interval) + try: + before = set(f.name for f in VERSIONS_DIR.iterdir()) if VERSIONS_DIR.exists() else set() + extract_new_format_versions() + after = set(f.name for f in VERSIONS_DIR.iterdir()) if VERSIONS_DIR.exists() else set() + new_versions = after - before + if new_versions: + logger.info(f"New launcher versions detected: {', '.join(sorted(new_versions))}") + generate_launcher_builds_meta() + except Exception as e: + logger.warning(f"ZIP watch error: {e}") + + asyncio.create_task(watch_new_zips()) + yield + + # Cleanup proxy client + if proxy_client: + await proxy_client.aclose() + logger.info("Server shutting down...") +# ====================== MIRROR SYNC HELPERS ====================== + +async def _calculate_file_hash_async(file_path: Path) -> Optional[str]: + """Calculate SHA256 hash of a file asynchronously""" + try: + loop = asyncio.get_running_loop() + def _hash(): + h = hashlib.sha256() + with open(file_path, "rb") as f: + while chunk := f.read(65536): + h.update(chunk) + return h.hexdigest() + return await loop.run_in_executor(None, _hash) + except Exception: + return None + + +async def sync_mirror_full( + client: httpx.AsyncClient, + headers: dict, + main_url: str, +) -> bool: + """Full mirror sync: compare by hash, download new/changed, delete stale. + Returns True if sync completed without critical errors.""" + try: + # Get latest version from main + resp = await client.get(f"{main_url}/launcher/version", timeout=15) + resp.raise_for_status() + main_data = resp.json() + main_version = main_data.get("version") + if not main_version: + logger.warning("sync_mirror_full: no version from main") + return False + logger.info(f"sync_mirror_full: main version = {main_version}") + + # Get sync manifest (full file list with hashes) + resp = await client.get( + f"{main_url}/launcher/sync/{main_version}", headers=headers, timeout=30 + ) + if resp.status_code != 200: + logger.warning(f"sync_mirror_full: manifest returned {resp.status_code}") + return False + + sync_data = resp.json() + server_files = sync_data.get("files", []) + logger.info(f"sync_mirror_full: {len(server_files)} files in manifest") + + # Build server file map + server_map = {} + for f in server_files: + server_map[f["path"]] = f + + # Scan local builds directory + local_files = {} + if BUILDS_DIR.exists(): + for fpath in BUILDS_DIR.rglob("*"): + if fpath.is_file() and fpath.name not in ("meta.json", "blocklist_cache.txt"): + rel = str(fpath.relative_to(BUILDS_DIR)) + local_files[rel] = "" + + # Download new/changed files + downloaded = 0 + for path, info in server_map.items(): + local_path = BUILDS_DIR / path + need_download = False + + if not local_path.exists(): + need_download = True + logger.info(f" New: {path}") + else: + local_hash = await _calculate_file_hash_async(local_path) + server_hash = info.get("hash", "").replace("sha256:", "") + if local_hash and local_hash != server_hash: + need_download = True + logger.info(f" Changed: {path}") + + if need_download: + local_path.parent.mkdir(parents=True, exist_ok=True) + file_url = f"{main_url}/launcher/sync/{main_version}/file/{path}" + try: + resp = await client.get(file_url, headers=headers, timeout=120) + resp.raise_for_status() + local_path.write_bytes(resp.content) + downloaded += 1 + logger.debug(f" Downloaded: {path}") + except Exception as e: + logger.warning(f" Failed to download {path}: {e}") + + # Delete files that exist locally but not on server + deleted = 0 + for local_path in list(local_files.keys()): + if local_path not in server_map: + del_path = BUILDS_DIR / local_path + try: + del_path.unlink() + deleted += 1 + logger.info(f" Deleted stale: {local_path}") + except Exception as e: + logger.warning(f" Failed to delete {local_path}: {e}") + + # Clean empty directories + for fpath in sorted(BUILDS_DIR.rglob("*"), key=lambda p: str(p), reverse=True): + if fpath.is_dir() and fpath != BUILDS_DIR: + try: + if not any(fpath.iterdir()): + fpath.rmdir() + logger.debug(f" Removed empty dir: {fpath.relative_to(BUILDS_DIR)}") + except Exception: + pass + + logger.info(f"sync_mirror_full: downloaded={downloaded}, deleted={deleted}") + return True + + except httpx.HTTPStatusError as e: + logger.warning(f"sync_mirror_full HTTP error: {e.response.status_code} - {e.response.text[:200]}") + return False + except Exception as e: + logger.warning(f"sync_mirror_full error: {e}") + return False + + +async def sync_mirror_self_update( + client: httpx.AsyncClient, + headers: dict, + main_url: str, +) -> bool: + """Check if the mirror's own launcher distribution needs updating. + Downloads new ZIP + extracts it, or replaces the bootstrap JAR.""" + try: + # Get current launcher version info + resp = await client.get(f"{main_url}/launcher/version", timeout=15) + resp.raise_for_status() + version_info = resp.json() + server_version = version_info.get("version", "") + if not server_version: + return False + + # Check current local version + local_version = "0.0.0" + build_version_file = BUILDS_DIR / "build.version" + if build_version_file.exists(): + local_version = build_version_file.read_text().strip() + + if local_version == server_version: + logger.debug("sync_mirror_self_update: already up to date") + return False + + logger.info(f"sync_mirror_self_update: local={local_version}, server={server_version}") + + # Parse versions + def parse_ver(v: str) -> tuple: + try: + return tuple(int(x) for x in v.split(".")) + except Exception: + return (0, 0, 0) + + if not (parse_ver(server_version) > parse_ver(local_version)): + logger.debug("sync_mirror_self_update: server version not newer") + return False + + # Get latest ZIP info from the version endpoint + zip_filename = None + zip_info = version_info.get("all_zips", []) + if zip_info: + zip_filename = zip_info[0].get("filename") + + if not zip_filename: + logger.warning("sync_mirror_self_update: no ZIP found in version info") + return False + + # Download the new launcher ZIP to a temp file + logger.info(f"sync_mirror_self_update: downloading {zip_filename}") + zip_url = f"{main_url}/launcher/download/zip/{zip_filename}" + zip_path = BUILDS_DIR / f".sync_{zip_filename}" + + resp = await client.get(zip_url, timeout=300) + resp.raise_for_status() + zip_path.write_bytes(resp.content) + logger.info(f"sync_mirror_self_update: downloaded {len(resp.content)} bytes") + + # Extract to a temp directory + import zipfile + import tempfile + import shutil + + with tempfile.TemporaryDirectory() as tmpdir: + extract_dir = Path(tmpdir) / "extracted" + extract_dir.mkdir(parents=True) + + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(extract_dir) + + # Move extracted files to builds directory, replacing old ones + moved = 0 + for item in extract_dir.rglob("*"): + if item.is_file() and item.name not in ("meta.json", "build.version"): + rel = item.relative_to(extract_dir) + target = BUILDS_DIR / rel + target.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.copy2(item, target) + moved += 1 + except Exception as e: + logger.warning(f" Failed to copy {rel}: {e}") + + logger.info(f"sync_mirror_self_update: moved {moved} files") + + # Clean up temp ZIP + zip_path.unlink(missing_ok=True) + + # Update version file + build_version_file.write_text(server_version) + logger.info(f"sync_mirror_self_update: updated to v{server_version}") + + # Regenerate meta for the new version + generate_launcher_builds_meta() + extract_new_format_versions() + + # Write a restart flag so the process manager can restart the server + restart_flag = BUILDS_DIR / ".restart-required" + restart_flag.write_text(server_version) + logger.warning(f"sync_mirror_self_update: restart flag written ({restart_flag})") + logger.warning("sync_mirror_self_update: server restart recommended to pick up new launcher files") + + return True + + except Exception as e: + logger.warning(f"sync_mirror_self_update error: {e}") + return False + + +# ====================== ШАБЛОН СТРАНИЦЫ АКТИВАЦИИ ====================== +ACTIVATE_PASS_HTML = """ + + + + + + Активация проходки | ZernMC + + + +
    + + + + +""" + + + # Create app with lifespan app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan) -# Add logging middleware +# Add Logging middleware app.add_middleware(LoggingMiddleware) +# ====================== ОПТИМИЗАЦИЯ ЗАГРУЗКИ ФАЙЛОВ ====================== + +class CacheControlMiddleware: + """Middleware for caching static and large files""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + + # Skip caching for dynamic endpoints + skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install", "/skin/"]) + if skip_cache: + await self.app(scope, receive, send) + return + + async def send_wrapper(message): + if message.get("type") == "http.response.start": + headers = message.get("headers", []) + cache_headers = [(b"cache-control", b"public, max-age=86400")] + message["headers"] = list(headers) + cache_headers + await send(message) + + await self.app(scope, receive, send_wrapper) + + +app.add_middleware(CacheControlMiddleware) + + +# Cache for file hashes (ETag) +file_etag_cache = TTLCache(maxsize=1000, ttl=3600) + + +async def get_etag_for_file(file_path: Path) -> str: + """Get or calculate ETag for file""" + cache_key = str(file_path) + + if cache_key in file_etag_cache: + return file_etag_cache[cache_key] + + # Calculate from file size + mtime + stat = file_path.stat() + etag = f'"{stat.st_size}-{stat.st_mtime}"' + file_etag_cache[cache_key] = etag + + return etag + + +async def send_file_async( + file_path: Path, + request: Request, + content_type: str = None, + cache: bool = True +): + """Optimized async file serving with Range support""" + if not file_path.exists(): + raise HTTPException(404, "File not found") + + file_size = file_path.stat().st_size + + # Determine content type + if content_type is None: + content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + + # Check for Range header (for resumable downloads) + range_header = request.headers.get("range") + + if range_header: + # Parse Range header + match = re.match(r"bytes=(\d+)-(\d+)?", range_header) + if match: + start = int(match.group(1)) + end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1) + else: + start, end = 0, file_size - 1 + + content_length = end - start + 1 + + # Read chunk asynchronously + async with aiofiles.open(file_path, "rb") as f: + await f.seek(start) + chunk = await f.read(content_length) + + # Return 206 Partial Content + return StreamingResponse( + iter([chunk]), + status_code=206, + media_type=content_type, + headers={ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(content_length), + "Cache-Control": "public, max-age=86400" if cache else "no-cache", + } + ) + else: + # Return full file with streaming + async def file_iterator(): + async with aiofiles.open(file_path, "rb") as f: + while True: + chunk = await f.read(65536) # 64KB chunks + if not chunk: + break + yield chunk + + # Calculate ETag + etag = await get_etag_for_file(file_path) + + return StreamingResponse( + file_iterator(), + media_type=content_type, + headers={ + "Accept-Ranges": "bytes", + "Content-Length": str(file_size), + "Cache-Control": "public, max-age=86400" if cache else "no-cache", + "ETag": etag, + "X-Content-Type-Options": "nosniff", + } + ) + +# Register routers +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(friends_router) +app.include_router(playtime_router) + +# Static assets for the landing site +for sub in ("css", "js", "img"): + mount_dir = SITE_DIR / sub + mount_dir.mkdir(parents=True, exist_ok=True) + app.mount(f"/{sub}", StaticFiles(directory=mount_dir), name=f"site-{sub}") + +# Server highlights media (screenshots / clips for the landing carousel) +HIGHLIGHTS_DIR = SITE_DIR / "highlights" +HIGHLIGHTS_DIR.mkdir(parents=True, exist_ok=True) +app.mount("/highlights", StaticFiles(directory=HIGHLIGHTS_DIR), name="site-highlights") + + # Monkey patch to catch invalid HTTP requests original_data_received = HttpToolsProtocol.data_received @@ -95,9 +983,7 @@ def patched_data_received(self, data): return original_data_received(self, data) except Exception as e: client = self.transport.get_extra_info('peername') - logger = logging.getLogger(__name__) - # Показываем первые 200 байт запроса в HEX для диагностики hex_preview = data[:100].hex() if len(data) > 0 else "empty" logger.error(f"Invalid HTTP request from {client}") @@ -107,16 +993,14 @@ def patched_data_received(self, data): try: raw_data = data[:500].decode('utf-8', errors='replace') logger.error(f"Raw request data: {repr(raw_data)}") - except: + except Exception: pass - # Не перевыбрасываем исключение, а возвращаем 400 ответ - # Это важно! Иначе клиент не получит ответ try: response = b"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: 21\r\n\r\nInvalid HTTP request" self.transport.write(response) self.transport.close() - except: + except Exception: pass return @@ -127,9 +1011,10 @@ HttpToolsProtocol.data_received = patched_data_received @app.get("/") async def root(): - """Root endpoint""" - logger = logging.getLogger(__name__) - logger.info("Root endpoint accessed") + """Root endpoint - serves the launcher landing site""" + index_path = SITE_DIR / "index.html" + if index_path.exists(): + return HTMLResponse(index_path.read_text(encoding="utf-8")) return { "status": "ok", "message": "ZernMC Launcher Server is running", @@ -138,20 +1023,113 @@ async def root(): } +@app.get("/skin/{filename}") +@app.head("/skin/{filename}") +async def get_skin(filename: str, request: Request): + """Serve SkinRestorer skins (compatibility with TG bot direct links)""" + if not filename or ".." in filename or "/" in filename or "\\" in filename: + raise HTTPException(status_code=404, detail="Скин не найден") + + filepath = SKINS_DIR / filename + + if not filepath.is_file(): + raise HTTPException(status_code=404, detail="Скин не найден") + + try: + content = filepath.read_bytes() + size = len(content) + + headers = { + "Content-Length": str(size), + "Content-Type": "image/png", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + "Access-Control-Allow-Origin": "*", + "Content-Encoding": "identity", + } + + if request.method == "HEAD": + return Response(status_code=200, headers=headers) + + return Response( + content=content, + media_type="image/png", + headers=headers + ) + except Exception as e: + logger.error("Skin read error", error=str(e)) + raise HTTPException(status_code=500, detail="Ошибка чтения скина") + + +@app.get("/api/status") +async def api_server_status(): + """Live Minecraft server status via mcstatus ping (same host as TG bot)""" + try: + from mcstatus import JavaServer + server = JavaServer.lookup(f"{MC_STATUS_HOST}") + status = await server.async_status() + + players_list = [] + try: + query = await server.async_query() + if query.players.names: + players_list = query.players.names + elif query.players.online > 0 and query.players.sample: + players_list = [p.name for p in query.players.sample] + except Exception: + if status.players.sample: + players_list = [p.name for p in status.players.sample] + + version = getattr(status.version, "name", None) or "unknown" + motd = status.description + + return { + "online": True, + "players_online": status.players.online, + "players_max": status.players.max, + "players_list": ", ".join(players_list) if players_list else "никого нет", + "players": players_list, + "motd": motd, + "version": version if isinstance(version, str) else str(version), + "updated": datetime.now().strftime("%H:%M:%S") + } + except asyncio.TimeoutError: + return {"online": False, "error": "Таймаут подключения к серверу", "updated": datetime.now().strftime("%H:%M:%S")} + except Exception as e: + return {"online": False, "error": f"{type(e).__name__}: {e}", "updated": datetime.now().strftime("%H:%M:%S")} + + @app.get("/health") async def health(): """Health check endpoint""" return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()} +# ====================== WEB ИНТЕРФЕЙС ДЛЯ АКТИВАЦИИ ПРОХОДКИ ====================== + +@app.get("/activate-pass") +async def activate_pass_page(): + """Веб-интерфейс для активации проходки""" + return Response( + content=ACTIVATE_PASS_HTML, + media_type="text/html" + ) + + # ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ====================== @app.get("/packs") -async def list_packs(): - """List all available packs""" - logger = logging.getLogger(__name__) +async def list_packs( + request: Request, + current_user: dict = Depends(get_current_user) +): + """List all available packs - requires auth""" + if not has_permission(current_user["role"], Permissions.VIEW_PACKS): + raise HTTPException(403, "Requires active pass") + packs = [] - + for pack_dir in PACKS_DIR.iterdir(): if pack_dir.is_dir(): meta_path = DATA_DIR / f"{pack_dir.name}.meta" @@ -159,20 +1137,28 @@ async def list_packs(): try: with open(meta_path, 'r', encoding='utf-8') as f: meta = json.load(f) - # Исправлено: конвертируем updated_at в строку если это datetime updated_at = meta.get("updated_at") - if updated_at and isinstance(updated_at, datetime): + if isinstance(updated_at, datetime): updated_at = updated_at.isoformat() - - packs.append({ - "name": pack_dir.name, - "version": meta.get("version", 1), - "files_count": len(meta.get("files", {})), - "updated_at": updated_at, - "minecraft_version": meta.get("minecraft_version", "unknown"), - "loader_type": meta.get("loader_type", "vanilla"), - "loader_version": meta.get("loader_version") - }) + + desc_path = pack_dir / "description.txt" + description = "" + if desc_path.exists(): + description = desc_path.read_text(encoding="utf-8") + + packs.append({ + "name": pack_dir.name, + "version": meta.get("version", 1), + "files_count": len(meta.get("files", {})), + "updated_at": updated_at, + "minecraft_version": meta.get("minecraft_version", "unknown"), + "loader_type": meta.get("loader_type", "vanilla"), + "loader_version": meta.get("loader_version"), + "asset_index": meta.get("asset_index"), + "description": description, + "disabled": is_pack_disabled(pack_dir.name), + "presets": get_pack_presets(pack_dir.name) + }) except Exception as e: logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}") packs.append({ @@ -189,11 +1175,27 @@ async def list_packs(): @app.post("/pack/{pack_name}/diff") -async def get_pack_diff(pack_name: str, request: Request): - """ - Client sends: { "mods/jei.jar": "sha256_hash", ... } +async def get_pack_diff( + pack_name: str, + request: Request, + current_user: dict = Depends(get_current_user), + preset: Optional[str] = None +): + """Client sends: { "mods/jei.jar": "sha256_hash", ... } Server returns diff information - """ + ТРЕБУЕТ ПРОХОДКУ ДЛЯ СКАЧИВАНИЯ""" + + # Проверяем наличие проходки + if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK): + raise HTTPException( + status_code=403, + detail="Для скачивания сборок требуется активная проходка. Обратитесь к администратору." + ) + + # Check if pack is disabled + if is_pack_disabled(pack_name): + raise HTTPException(403, "Pack is disabled by administrator") + client_ip = request.client.host if request.client else "unknown" # Читаем тело запроса @@ -205,13 +1207,17 @@ async def get_pack_diff(pack_name: str, request: Request): logger.info("Received diff request", pack=pack_name, + preset=preset, client_files_count=len(body), client_ip=client_ip) try: - meta = get_cached_manifest(pack_name) - if not meta: - meta = await scan_pack(pack_name) + if preset: + meta = get_merged_manifest(pack_name, preset) + else: + meta = get_cached_manifest(pack_name) + if not meta: + meta = await scan_pack(pack_name) except FileNotFoundError: logger.warning("Pack not found", pack=pack_name, client_ip=client_ip) raise HTTPException(404, "Pack not found") @@ -228,7 +1234,9 @@ async def get_pack_diff(pack_name: str, request: Request): for path, entry in server_files.items(): client_hash = body.get(path) if client_hash is None or client_hash != entry.hash: - url = f"/pack/{pack_name}/file/{path}" + url = f"/pack/{pack_name}/file/{quote(path, safe='/')}" + if preset: + url += f"?preset={quote(preset, safe='')}" to_download.append({ "path": path, "url": url, @@ -259,8 +1267,11 @@ async def get_pack_diff(pack_name: str, request: Request): @app.get("/pack/{pack_name}") -async def get_pack_manifest(pack_name: str, request: Request): +async def get_pack_manifest(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)): """Get pack manifest with caching""" + if is_pack_disabled(pack_name): + raise HTTPException(403, "Pack is disabled by administrator") + client_ip = request.client.host if request.client else "unknown" cached_meta = get_cached_manifest(pack_name) @@ -300,13 +1311,41 @@ async def get_pack_manifest(pack_name: str, request: Request): @app.get("/pack/{pack_name}/file/{file_path:path}") -async def get_pack_file(pack_name: str, file_path: str, request: Request): - """Get a file from a pack""" - full_path = PACKS_DIR / pack_name / file_path +async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user), preset: Optional[str] = None): + """Get a file from a pack. If preset is specified, try preset dir first, fall back to base.""" + if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK): + raise HTTPException(403, "Requires active pass") + if is_pack_disabled(pack_name): + raise HTTPException(403, "Pack is disabled by administrator") + client_ip = request.client.host if request.client else None + # If preset specified, try preset path first + if preset: + preset_path = PACKS_DIR / pack_name / "presets" / preset / file_path + preset_resolved = preset_path.resolve() + preset_root = (PACKS_DIR / pack_name).resolve() + try: + if str(preset_resolved).startswith(str(preset_root)) and preset_resolved.is_file(): + full_path = preset_resolved + else: + full_path = None + except (ValueError, OSError): + full_path = None + else: + full_path = None + + # Fall back to base path + if full_path is None: + full_path = PACKS_DIR / pack_name / file_path + # Security: prevent path traversal - if ".." in file_path: + try: + full_path = full_path.resolve() + pack_root = (PACKS_DIR / pack_name).resolve() + if not str(full_path).startswith(str(pack_root)): + raise HTTPException(403, "Invalid file path") + except (ValueError, OSError): raise HTTPException(403, "Invalid file path") if not full_path.exists() or not full_path.is_file(): @@ -316,33 +1355,328 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request): client_ip=client_ip) raise HTTPException(404, "File not found") - logger.info("Serving file", - pack=pack_name, - file=file_path, + logger.info("Serving file", + pack=pack_name, + file=file_path, size=full_path.stat().st_size, client_ip=client_ip) - - return FileResponse(full_path) + + return await send_file_async(full_path, request, cache=True) # ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ====================== def get_current_launcher_version() -> str: - """Get current launcher version from build.version file""" + """Get current launcher version — prefers build.version (includes hotfix), falls back to extracted versions""" version_file = BUILDS_DIR / "build.version" if version_file.exists(): - return version_file.read_text().strip() + v = version_file.read_text().strip() + if v: + return v + + versions = get_launcher_versions() + if versions: + return versions[0]["meta"]["version"] + return "1.0.0" +def parse_version(version_str: str) -> dict: + """Parse version string to determine if it's new or legacy format""" + match = re.match(r'^(\d+)\.(\d+)\.(\d+)(.*)$', version_str) + if not match: + return {"major": 0, "minor": 0, "patch": 0, "suffix": version_str, "is_legacy": True} + + major, minor, patch, suffix = match.groups() + suffix = suffix.strip("-") + + is_legacy = bool(suffix) + + return { + "major": int(major), + "minor": int(minor), + "patch": int(patch), + "suffix": suffix, + "is_legacy": is_legacy + } + + +def is_new_format(filename: str) -> bool: + """Check if filename represents new format build""" + return filename.startswith("ZernMC-win-") + + +# ====================== ЛАУНЧЕР МЕТА СИСТЕМА ====================== + +def calculate_file_hash(file_path: Path) -> str: + """Calculate SHA256 hash of a file""" + hash_sha = hashlib.sha256() + with open(file_path, 'rb') as f: + while chunk := f.read(8192): + hash_sha.update(chunk) + return hash_sha.hexdigest() + + +def generate_launcher_builds_meta(): + """Generate meta.json in builds/ directory for incremental updates""" + version = get_current_launcher_version() + if not version: + return + + meta_path = BUILDS_DIR / "meta.json" + + # Always regenerate to pick up file changes (new JS, CSS, etc.) + # The version check alone is insufficient — same version can have different files + + + # Generate new meta + files = [] + try: + for file_path in BUILDS_DIR.rglob("*"): + if not file_path.is_file(): + continue + rel_path = str(file_path.relative_to(BUILDS_DIR)) + + # Only include runtime files needed for launcher updates + # Skip: JRE, old ZIPs, extracted versions, docs, root JARs. + # lib/javafx/*-win.jar IS included so existing clients get the + # JavaFX update (e.g. 23 -> 23.0.1 WebKit WebSocket fix) via the + # incremental updater, not only through a full ZIP reinstall. + if rel_path.startswith(("jre21/", "lib-javafx/", "versions/", "libs/", "logs/")): + continue + if rel_path.startswith("lib/") and not rel_path.startswith("lib/javafx/"): + continue + if rel_path.endswith(".zip") or rel_path in ("README.txt", "build.version"): + continue + if file_path.name in ("meta.json",): + continue + # Skip root-level JARs — only bin/*.jar is needed at runtime + if rel_path in ("zernmclauncher.jar", "zernmc-bootstrap.jar"): + continue + # CLI exe is not shipped to users + if rel_path == "zernmc-cli.exe": + continue + # zernmc.exe embeds the launcher version, so its hash changes on + # every build even when the bootstrap code is unchanged. Exclude it + # from the incremental update: a new exe is only delivered via a + # full ZIP reinstall. Without this the bootstrap would re-download + # and re-stage its own exe on every launcher version bump, and the + # detached .cmd helper fails with "file not found". + if rel_path == "zernmc.exe": + continue + stat = file_path.stat() + + # Calculate hash + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + + files.append({ + "path": rel_path, + "size": stat.st_size, + "hash": f"sha256:{sha256_hash.hexdigest()}" + }) + except Exception as e: + logger.warning(f"Failed to generate launcher meta: {e}") + return + + mirrors = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()] + + meta = { + "version": version, + "type": "builds", + "release_date": datetime.utcnow().isoformat(), + "files": files, + "mirrors": mirrors + } + + try: + with open(meta_path, 'w', encoding='utf-8') as f: + json.dump(meta, f, indent=2) + logger.info(f"Generated launcher meta.json with {len(files)} files") + except Exception as e: + logger.warning(f"Failed to save meta.json: {e}") + + +def generate_version_meta(version_path: Path, version: str) -> dict: + """Generate and cache meta.json for an extracted version directory.""" + files = [] + for file_path in version_path.rglob("*"): + if file_path.is_file() and file_path.name != "meta.json": + rel_path = str(file_path.relative_to(version_path)) + if rel_path in ("zernmc.exe", "zernmc-cli.exe"): + continue + stat = file_path.stat() + file_hash = calculate_file_hash(file_path) + files.append({ + "path": rel_path, + "size": stat.st_size, + "hash": f"sha256:{file_hash}" + }) + + meta = { + "version": version, + "type": "new", + "release_date": datetime.utcnow().isoformat(), + "files": files + } + + try: + meta_path = version_path / "meta.json" + with open(meta_path, 'w', encoding='utf-8') as f: + json.dump(meta, f, indent=2) + except Exception as e: + logger.warning(f"Failed to save launcher meta for {version}: {e}") + + return meta + + +def scan_launcher_version(version: str) -> Optional[dict]: + """Scan a launcher version directory and return meta""" + # First check if meta exists in builds/ directly (for new format) + meta_path = BUILDS_DIR / "meta.json" + if meta_path.exists(): + try: + with open(meta_path, 'r', encoding='utf-8') as f: + meta = json.load(f) + if meta.get("version") == version: + return meta + except Exception: + pass + + # Fallback: check versions directory + version_path = VERSIONS_DIR / version + + if not version_path.exists() or not version_path.is_dir(): + return None + + meta_path = version_path / "meta.json" + + # Check cache first + if meta_path.exists(): + try: + with open(meta_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + pass + + # Generate meta + return generate_version_meta(version_path, version) + + +def parse_version_key(v: str) -> tuple: + """Parse version string for proper numeric sorting""" + try: + parts = v.split(".") + return tuple(int(p) for p in parts) + except: + return (0, 0, 0) + +def get_launcher_versions() -> list: + """Get list of available launcher versions with meta""" + if not VERSIONS_DIR.exists(): + return [] + + versions = [] + for version_dir in VERSIONS_DIR.iterdir(): + if version_dir.is_dir(): + meta = scan_launcher_version(version_dir.name) + if meta: + versions.append({ + "version": version_dir.name, + "meta": meta + }) + + versions.sort(key=lambda x: parse_version_key(x["version"]), reverse=True) + return versions + + +def get_launcher_version_meta(version: str) -> Optional[dict]: + """Get meta for specific launcher version""" + return scan_launcher_version(version) + + +def extract_new_format_versions(): + """Extract new format ZIPs to versions directory""" + VERSIONS_DIR.mkdir(exist_ok=True) + + # Find all ZernMC-win-*.zip files + new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip")) + + total = len(new_format_zips) + extracted = 0 + skipped = 0 + failed = 0 + + for zip_file in new_format_zips: + version = zip_file.stem.replace("ZernMC-win-", "") + extract_dir = VERSIONS_DIR / version + + # Skip if already extracted and meta exists + if extract_dir.exists() and (extract_dir / "meta.json").exists(): + skipped += 1 + continue + + logger.info(f"Extracting {zip_file.name} to versions/{version}/...") + + try: + import zipfile + with zipfile.ZipFile(zip_file, 'r') as zf: + # Extract all files + zf.extractall(extract_dir) + + # Generate meta.json cache for the extracted version. Without this the + # "already extracted" check above always fails: scan_launcher_version() + # short-circuits on BUILDS_DIR/meta.json for the latest version and never + # writes versions//meta.json, so every scan would re-extract the zip. + generate_version_meta(extract_dir, version) + + extracted += 1 + logger.info(f"Extracted {zip_file.name} successfully") + except Exception as e: + failed += 1 + logger.error(f"Failed to extract {zip_file.name}: {e}") + + logger.info(f"Version scan complete: {total} total, {extracted} extracted, {skipped} skipped (already present), {failed} failed") + + +# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ====================== + + def get_available_zips() -> list: - """Get list of available zip archives""" + """Get list of available zip archives (new format only)""" if not BUILDS_DIR.exists(): return [] zips = [] for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"): + if is_new_format(zip_file.name): + continue + version = zip_file.stem.replace("ZernMCLauncher-", "") + parsed = parse_version(version) + stat = zip_file.stat() + zips.append({ + "version": version, + "filename": zip_file.name, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "is_legacy": parsed["is_legacy"] + }) + + zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True) + return zips + + +def get_new_format_zips() -> list: + """Get list of available zip archives (new format: ZernMC-win-*.zip)""" + if not BUILDS_DIR.exists(): + return [] + + zips = [] + for zip_file in BUILDS_DIR.glob("ZernMC-win-*.zip"): + version = zip_file.stem.replace("ZernMC-win-", "") stat = zip_file.stat() zips.append({ "version": version, @@ -351,63 +1685,112 @@ def get_available_zips() -> list: "modified": datetime.fromtimestamp(stat.st_mtime).isoformat() }) - zips.sort(key=lambda x: x["version"], reverse=True) + zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True) return zips +def get_legacy_zips() -> list: + """Get list of available legacy zip archives (< 1.0.8 or with suffix)""" + if not BUILDS_DIR.exists(): + return [] + + zips = [] + for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"): + version = zip_file.stem.replace("ZernMCLauncher-", "") + parsed = parse_version(version) + + is_legacy = ( + parsed["is_legacy"] or + (parsed["major"] < 1) or + (parsed["major"] == 1 and parsed["minor"] == 0 and parsed["patch"] < 8) + ) + + if is_legacy: + stat = zip_file.stat() + zips.append({ + "version": version, + "filename": zip_file.name, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "is_legacy": True + }) + + zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True) + return zips + + +@app.get("/launcher/ip") +async def get_launcher_client_ip(request: Request): + """Return the public IP that the server sees for this client.""" + from middleware import get_client_ip + client_ip = get_client_ip(request) + return {"ip": client_ip} + + @app.get("/launcher/version") async def get_launcher_version(): """Return launcher version information""" version = get_current_launcher_version() - zips = get_available_zips() + new_zips = get_new_format_zips() + legacy_zips = get_legacy_zips() response = { "version": version, "updated_at": datetime.utcnow().isoformat() } - jar_path = BUILDS_DIR / "ZernMCLauncher.jar" + jar_path = BUILDS_DIR / "zernmclauncher.jar" if jar_path.exists(): response["download_jar"] = "/launcher/download/jar" response["jar_size"] = jar_path.stat().st_size - exe_path = BUILDS_DIR / "ZernMCLauncher.exe" + exe_path = BUILDS_DIR / "zernmc.exe" if exe_path.exists(): response["download_exe"] = "/launcher/download/exe" response["exe_size"] = exe_path.stat().st_size - if zips: - response["download_zip"] = f"/launcher/download/zip/{zips[0]['filename']}" - response["zip_version"] = zips[0]["version"] - response["zip_size"] = zips[0]["size"] - response["all_zips"] = zips + if new_zips: + response["download_zip"] = f"/launcher/download/zip/{new_zips[0]['filename']}" + response["zip_version"] = new_zips[0]["version"] + response["zip_size"] = new_zips[0]["size"] + response["all_zips"] = new_zips + + if legacy_zips: + response["legacy_zips"] = legacy_zips + response["legacy_download_url"] = "/launcher/download/legacy" return response @app.get("/launcher/download/jar") -async def download_launcher_jar(): +async def download_launcher_jar(request: Request = None): """Download launcher JAR file""" - file_path = BUILDS_DIR / "ZernMCLauncher.jar" - + file_path = BUILDS_DIR / "zernmclauncher.jar" + if not file_path.exists(): raise HTTPException(404, "JAR file not found") - + + if request: + return await send_file_async(file_path, request, content_type="application/java-archive", cache=True) + return FileResponse( path=file_path, - filename="ZernMCLauncher.jar", + filename="zernmclauncher.jar", media_type="application/java-archive" ) @app.get("/launcher/download/exe") -async def download_launcher_exe(): +async def download_launcher_exe(request: Request = None): """Download launcher EXE file (Windows)""" - file_path = BUILDS_DIR / "ZernMCLauncher.exe" - + file_path = BUILDS_DIR / "zernmc.exe" + if not file_path.exists(): raise HTTPException(404, "EXE file not found") - + + if request: + return await send_file_async(file_path, request, content_type="application/vnd.microsoft.portable-executable", cache=True) + return FileResponse( path=file_path, filename="ZernMCLauncher.exe", @@ -416,16 +1799,20 @@ async def download_launcher_exe(): @app.get("/launcher/download/zip/{filename}") -async def download_launcher_zip(filename: str): +async def download_launcher_zip(filename: str, request: Request = None): """Download specific launcher ZIP archive""" - if ".." in filename or not filename.startswith("ZernMCLauncher-") or not filename.endswith(".zip"): + valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"] + if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"): raise HTTPException(400, "Invalid filename") - + file_path = BUILDS_DIR / filename - + if not file_path.exists(): raise HTTPException(404, "ZIP file not found") - + + if request: + return await send_file_async(file_path, request, content_type="application/zip", cache=True) + return FileResponse( path=file_path, filename=filename, @@ -435,25 +1822,210 @@ async def download_launcher_zip(filename: str): @app.get("/launcher/download/latest") async def download_latest_launcher(): - """Download the latest launcher (prefer ZIP if available, fallback to JAR)""" - zips = get_available_zips() + """Download the latest launcher (new format: ZernMC-win-*.zip)""" + zips = get_new_format_zips() if zips: latest_zip = zips[0]["filename"] return await download_launcher_zip(latest_zip) - jar_path = BUILDS_DIR / "ZernMCLauncher.jar" - if jar_path.exists(): - return await download_launcher_jar() + raise HTTPException(404, "No new format launcher files available") + + +@app.get("/launcher/download/legacy") +async def download_legacy_launcher(): + """Download the latest legacy launcher (< 1.0.8 or with suffix)""" + zips = get_legacy_zips() - raise HTTPException(404, "No launcher files available") + if zips: + latest_zip = zips[0]["filename"] + return await download_launcher_zip(latest_zip) + + raise HTTPException(404, "No legacy launcher files available") + + +# ====================== ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ====================== + +@app.get("/launcher/meta") +async def get_launcher_meta_list(): + """Get list of all launcher versions with meta (new format)""" + versions = get_launcher_versions() + return { + "versions": [ + {"version": v["version"], "meta": v["meta"]} + for v in versions + ] + } + + +@app.get("/launcher/mirrors") +async def get_launcher_mirrors(): + """Get list of available mirrors""" + mirrors_list = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()] + return {"mirrors": mirrors_list} + + +# ====================== SYNC FOR MIRRORS ====================== + +def verify_sync_api_key(request: Request): + """Verify API key for sync endpoints""" + api_key = request.headers.get("X-Sync-Key") + if not api_key or api_key != SYNC_API_KEY: + raise HTTPException(401, "Invalid or missing sync API key") + + +@app.get("/launcher/sync/{version}") +async def get_sync_manifest(version: str, request: Request): + """Get sync manifest for mirror servers - returns files to download/delete""" + verify_sync_api_key(request) + + if SERVER_ROLE != "main": + raise HTTPException(403, "Sync only available on main server") + + # Check for incremental sync (if-modified-since) + last_sync = request.headers.get("If-Modified-Since") + + # Get server meta + meta = get_launcher_version_meta(version) + if not meta: + raise HTTPException(404, f"Version {version} not found") + + # Build sync response + sync_data = { + "version": version, + "files": [], + "delete": [], # Files that were removed from main + "timestamp": datetime.utcnow().isoformat() + } + + for f in meta.get("files", []): + sync_data["files"].append({ + "path": f["path"], + "size": f["size"], + "hash": f["hash"], + "url": f"/launcher/file/{version}/{f['path']}" + }) + + return sync_data + + +@app.get("/launcher/sync/{version}/file/{file_path:path}") +async def sync_download_file(version: str, file_path: str, request: Request): + """Download file for mirror sync""" + verify_sync_api_key(request) + + if SERVER_ROLE != "main": + raise HTTPException(403, "Sync only available on main server") + + full_path = BUILDS_DIR / file_path + + if ".." in file_path: + raise HTTPException(403, "Invalid path") + + if not full_path.exists(): + raise HTTPException(404, "File not found") + + return await send_file_async(full_path, request, cache=False) + + +@app.get("/launcher/meta/{version}") +async def get_launcher_version_meta_handler(version: str): + """Get meta for specific launcher version""" + meta = get_launcher_version_meta(version) + if not meta: + raise HTTPException(404, f"Version {version} not found or not in new format") + return meta + + +@app.post("/launcher/diff") +async def get_launcher_diff(request: Request): + """Get diff for launcher update - compare local files with server version""" + body = await request.json() + + # Get latest version + versions = get_launcher_versions() + if not versions: + raise HTTPException(404, "No versions available") + + latest = versions[0] + meta = latest["meta"] + + # Build hash map from client + client_hashes = body # {filename: hash, ...} + + to_download = [] + to_delete = [] + + # Find new/updated files + server_files = {f["path"]: f for f in meta["files"]} + + for path, file_info in server_files.items(): + if path not in client_hashes: + to_download.append(file_info) + elif client_hashes[path] != file_info["hash"]: + to_download.append(file_info) + + # Find deleted files (files on server but not in client) + for path in client_hashes: + if path not in server_files: + to_delete.append(path) + + return { + "version": meta["version"], + "to_download": to_download, + "to_delete": to_delete + } + + +@app.get("/launcher/file/{version}/{file_path:path}") +async def get_launcher_file(version: str, file_path: str, request: Request): + """Download a specific file from a launcher version""" + # Ищем в builds/ директории (где лежит zernmc.exe, lib, assets и т.д.) + full_path = BUILDS_DIR / file_path + + # Security: prevent path traversal + if ".." in file_path: + raise HTTPException(403, "Invalid file path") + + if not full_path.exists() or not full_path.is_file(): + # Fallback: ищем в versions директории + alt_path = VERSIONS_DIR / version / file_path + if alt_path.exists() and alt_path.is_file(): + full_path = alt_path + else: + raise HTTPException(404, "File not found: " + file_path) + + return await send_file_async(full_path, request, cache=True) + + +@app.get("/launcher/download/zip/version/{version}") +async def download_launcher_zip_version(version: str, request: Request = None): + """Download full ZIP for specific version (for new installs)""" + zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip" + + if not zip_path.exists(): + raise HTTPException(404, f"ZIP for version {version} not found") + + if request: + return await send_file_async(zip_path, request, content_type="application/zip", cache=True) + + # Fallback without request + return FileResponse( + path=zip_path, + filename=f"ZernMC-win-{version}.zip", + media_type="application/zip" + ) + + +# ====================== END ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ====================== @app.get("/launcher/info") async def get_launcher_full_info(): """Full launcher information with all available files""" version = get_current_launcher_version() - zips = get_available_zips() + new_zips = get_new_format_zips() + legacy_zips = get_legacy_zips() info = { "current_version": version, @@ -461,9 +2033,21 @@ async def get_launcher_full_info(): "files": { "jar": None, "exe": None, - "zips": zips + "zips": new_zips + legacy_zips }, - "recommended": "zip" if zips else ("exe" if (BUILDS_DIR / "ZernMCLauncher.exe").exists() else "jar") + "recommended": "zip" if new_zips else ("exe" if (BUILDS_DIR / "ZernMCLauncher.exe").exists() else "jar"), + "new_format": { + "available": len(new_zips) > 0, + "latest": new_zips[0] if new_zips else None, + "download_url": "/launcher/download/latest" + }, + "legacy": { + "available": len(legacy_zips) > 0, + "count": len(legacy_zips), + "latest": legacy_zips[0] if legacy_zips else None, + "download_url": "/launcher/download/legacy", + "warning": "Legacy builds are technically compatible but not recommended. Consider upgrading to new format." + } } jar_path = BUILDS_DIR / "ZernMCLauncher.jar" @@ -480,28 +2064,166 @@ async def get_launcher_full_info(): "download_url": "/launcher/download/exe" } - if zips: - info["files"]["latest_zip"] = zips[0] + if new_zips: + info["files"]["latest_zip"] = new_zips[0] info["files"]["download_latest"] = "/launcher/download/latest" return info +@app.post("/launcher/crash-report") +async def receive_crash_report(request: Request): + """Receive and store launcher crash reports""" + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + ip = request.client.host if request.client else "unknown" + body["source_ip"] = ip + body["received_at"] = datetime.utcnow().isoformat() + + report_id = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8] + report_path = CRASH_REPORTS_DIR / f"{report_id}.json" + + try: + CRASH_REPORTS_DIR.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(report_path, "w", encoding="utf-8") as f: + await f.write(json.dumps(body, indent=2, ensure_ascii=False)) + logger.info(f"Crash report saved: {report_path.name} from {ip}") + except Exception as e: + logger.error(f"Failed to save crash report: {e}") + raise HTTPException(status_code=500, detail="Failed to save report") + + return {"status": "ok", "id": report_id} + + +@app.post("/diag/upload") +async def receive_diag_report(request: Request): + """Receive and store network diagnostics reports from the diag utility""" + try: + body = await request.body() + except Exception: + raise HTTPException(status_code=400, detail="Failed to read body") + + if not body: + raise HTTPException(status_code=400, detail="Empty body") + + ip = request.client.host if request.client else "unknown" + name = request.query_params.get("name", "").strip() + if not name or "/" in name or "\\" in name or ".." in name: + name = f"diag_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{ip}.log" + + report_path = DIAG_REPORTS_DIR / name + + try: + DIAG_REPORTS_DIR.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(report_path, "w", encoding="utf-8") as f: + await f.write(body.decode("utf-8", errors="replace")) + logger.info(f"Diag report saved: {report_path.name} from {ip}") + except Exception as e: + logger.error(f"Failed to save diag report: {e}") + raise HTTPException(status_code=500, detail="Failed to save report") + + return {"status": "ok", "name": report_path.name} + + +# ====================== НОВОСТИ ====================== + +NEWS_DIR = Path(__file__).parent / "news" + + +@app.get("/news") +async def list_news(): + """List all news files with their content""" + if not NEWS_DIR.exists(): + return {"news": []} + + news_list = [] + files = [f for f in NEWS_DIR.iterdir() if f.is_file() and f.suffix == ".txt"] + files.sort(key=lambda f: f.stat().st_mtime, reverse=True) + for f in files: + try: + content = f.read_text(encoding="utf-8").strip().split("\n") + if len(content) >= 4: + title = content[0].strip() + news_type = content[1].strip() + version = content[2].strip() + body = "\n".join(content[3:]).strip() + news_list.append({ + "id": f.stem, + "title": title, + "type": news_type, + "version": version, + "body": body + }) + except Exception as e: + logger.warning(f"Failed to read news file {f.name}: {e}") + + return {"news": news_list} + + +@app.get("/news/{news_id}") +async def get_news(news_id: str): + """Get a single news item by ID""" + file_path = NEWS_DIR / f"{news_id}.txt" + if not file_path.exists(): + raise HTTPException(404, "News not found") + + content = file_path.read_text(encoding="utf-8").strip().split("\n") + if len(content) < 4: + raise HTTPException(400, "Invalid news file format") + + return { + "id": file_path.stem, + "title": content[0].strip(), + "type": content[1].strip(), + "version": content[2].strip(), + "body": "\n".join(content[3:]).strip() + } + + +# ====================== WHITELIST MODS ====================== + +WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt" + + +@app.get("/whitelist/mods") +async def list_whitelist_mods(current_user: dict = Depends(get_current_user)): + """Return whitelist mods list as name :: size :: hash lines""" + if not WHITELIST_MODS_FILE.exists(): + return Response(content="", media_type="text/plain") + + content = WHITELIST_MODS_FILE.read_text(encoding="utf-8").strip() + if not content: + return Response(content="", media_type="text/plain") + + return Response(content=content, media_type="text/plain") + + +@app.get("/whitelist/mods/{mod_hash}/{filename:path}") +async def download_whitelist_mod(mod_hash: str, filename: str, request: Request, current_user: dict = Depends(get_current_user)): + """Download whitelist mod file by hash""" + if ".." in mod_hash or ".." in filename: + raise HTTPException(400, "Invalid path") + + mod_path = WHITELIST_DIR / mod_hash + if not mod_path.exists() or not mod_path.is_file(): + raise HTTPException(404, "Mod not found") + + return await send_file_async(mod_path, request, cache=True) + + # ====================== ПРОКСИ ЭНДПОИНТЫ ====================== # Эти эндпоинты позволяют клиентам с сетевыми проблемами # скачивать файлы через сервер Zern -# Создаем HTTP клиент для прокси -proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) +# HTTP клиент для прокси — создаётся в lifespan, закрывается при shutdown +proxy_client: httpx.AsyncClient | None = None # Кэш для часто запрашиваемых данных (5 минут) -from cachetools import TTLCache proxy_cache = TTLCache(maxsize=50, ttl=300) -# Список заблокированных/проблемных хостов (можно обновлять) -BLOCKED_HOSTS = [] - - @app.get("/proxy/fabric/versions/loader") async def proxy_fabric_versions(request: Request): """Прокси для Fabric Meta API - список версий загрузчика""" @@ -548,7 +2270,6 @@ async def proxy_fabric_installer_latest(request: Request): xml = response.text # Парсим последнюю версию из XML - import re match = re.search(r'([^<]+)', xml) if match: version = match.group(1) @@ -637,19 +2358,34 @@ async def proxy_mojang_version(version_id: str, request: Request): # Сначала получаем манифест, чтобы найти URL версии manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" - cache_key = f"version_url_{version_id}" - version_url = proxy_cache.get(cache_key) - + # Cache the final version JSON, not just the version URL. piston-meta is + # slow/unreliable from some regions, so full serialization of the version + # JSON (plus a warm-up) keeps installs from stalling on install time. + cache_key = f"version_json_{version_id}" + cached = proxy_cache.get(cache_key) + if cached is not None: + logger.info(f"Proxy served from cache: Mojang version {version_id}") + return JSONResponse(content=cached) + + version_url = proxy_cache.get(f"version_url_{version_id}") + if not version_url: try: - response = await proxy_client.get(manifest_url) - response.raise_for_status() - manifest = response.json() + # Reuse the manifest already cached by /proxy/mojang/version_manifest + manifest = proxy_cache.get(manifest_url) + if manifest is None: + response = await proxy_client.get(manifest_url) + response.raise_for_status() + manifest = response.json() + proxy_cache[manifest_url] = manifest + logger.info("Proxy success: Mojang manifest") + else: + logger.info("Proxy served manifest from cache") for version in manifest.get("versions", []): if version.get("id") == version_id: version_url = version.get("url") - proxy_cache[cache_key] = version_url + proxy_cache[f"version_url_{version_id}"] = version_url break if not version_url: @@ -664,6 +2400,7 @@ async def proxy_mojang_version(version_id: str, request: Request): response.raise_for_status() data = response.json() + proxy_cache[cache_key] = data logger.info(f"Proxy success: Mojang version {version_id}") return JSONResponse(content=data) @@ -725,6 +2462,58 @@ async def proxy_forge_maven(path: str, request: Request): raise HTTPException(502, f"Bad Gateway: {str(e)}") +@app.get("/proxy/neoforge/versions") +async def proxy_neoforge_versions(request: Request): + """Прокси для списка версий NeoForge""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: NeoForge versions from {client_ip}") + + url = "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml" + + try: + response = await proxy_client.get(url) + response.raise_for_status() + + return Response( + content=response.content, + media_type="application/xml", + headers={"X-Proxied-By": "ZernMC"} + ) + + except httpx.HTTPError as e: + logger.error(f"Proxy error for NeoForge versions: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + +@app.get("/proxy/neoforge/maven/{path:path}") +async def proxy_neoforge_maven(path: str, request: Request): + """Прокси для NeoForge Maven файлов""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: NeoForge Maven {path} from {client_ip}") + + full_url = f"https://maven.neoforged.net/{path}" + + try: + response = await proxy_client.get(full_url) + response.raise_for_status() + + content_type = "application/octet-stream" + if path.endswith(".jar"): + content_type = "application/java-archive" + elif path.endswith(".pom"): + content_type = "application/xml" + + return Response( + content=response.content, + media_type=content_type, + headers={"X-Proxied-By": "ZernMC"} + ) + + except httpx.HTTPError as e: + logger.error(f"Proxy error for NeoForge Maven {path}: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + @app.get("/proxy/download") async def proxy_download(request: Request): """Универсальный прокси для скачивания файлов""" @@ -739,14 +2528,20 @@ async def proxy_download(request: Request): "maven.fabricmc.net", "meta.fabricmc.net", "piston-meta.mojang.com", + "piston-data.mojang.com", "launchermeta.mojang.com", "resources.download.minecraft.net", + "libraries.minecraft.net", "maven.minecraftforge.net", - "files.minecraftforge.net" + "files.minecraftforge.net", + "maven.neoforged.net", + "repo1.maven.org", + "repo.maven.apache.org", + "api.zernmc.ru", + "api.zernmc.online" ] # Проверяем, что URL ведет на разрешенный домен - from urllib.parse import urlparse parsed = urlparse(url) domain = parsed.netloc.lower() @@ -761,7 +2556,7 @@ async def proxy_download(request: Request): try: # Используем streaming response для больших файлов - response = await proxy_client.get(url) + response = await proxy_client.get(url, timeout=300.0) response.raise_for_status() # Определяем content-type из ответа или по расширению @@ -819,7 +2614,8 @@ async def proxy_status(): "piston-meta.mojang.com", "launchermeta.mojang.com", "resources.download.minecraft.net", - "maven.minecraftforge.net" + "maven.minecraftforge.net", + "maven.neoforged.net" ], "note": "Use this proxy if you have network issues connecting to Fabric/Mojang/Forge" } @@ -834,20 +2630,15 @@ async def global_exception_handler(request: Request, exc: Exception): ) -# Cleanup on shutdown -@app.on_event("shutdown") -async def shutdown_proxy(): - await proxy_client.close() - - # ====================== ЗАПУСК ====================== if __name__ == "__main__": args = parse_args() - + if args.test: - import asyncio asyncio.run(run_test_mode()) + elif args.sync: + asyncio.run(run_sync_mode()) elif args.dev: run_development_mode(args.host, args.port, args.reload) else: diff --git a/server/middleware.py b/server/middleware.py index f42d886..5c71298 100644 --- a/server/middleware.py +++ b/server/middleware.py @@ -5,43 +5,208 @@ import logging import time import uuid import traceback +import httpx +import re +from collections import defaultdict +from typing import Optional logger = logging.getLogger(__name__) +# Public blocklist URLs +BLOCKLIST_URLS = [ + "https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset", + "https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/iblocklist_isp.netset", +] + + +def load_blocklist_from_url(url: str, timeout: int = 10) -> set[str]: + """Download and parse IP blocklist from URL""" + ips = set() + try: + response = httpx.get(url, timeout=timeout, follow_redirects=True) + if response.status_code == 200: + for line in response.text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if re.match(r"^\d+\.\d+\.\d+\.\d+(/\d+)?$", line): + ip = line.split("/")[0] + ips.add(ip) + logger.info(f"Loaded {len(ips)} IPs from blocklist: {url}") + except Exception as e: + logger.warning(f"Failed to load blocklist from {url}: {e}") + return ips + + +def load_public_blocklists() -> set[str]: + """Load all public blocklists""" + all_ips = set() + for url in BLOCKLIST_URLS: + all_ips.update(load_blocklist_from_url(url)) + logger.info(f"Total blocked IPs from public lists: {len(all_ips)}") + return all_ips + + +# Rate limiting config +RATE_LIMIT_REQUESTS = 60 # Max requests per window +RATE_LIMIT_WINDOW = 60 # Window in seconds +_ip_request_counts: dict[str, list[float]] = defaultdict(list) + +# IP blocking config (set from main.py) +BLOCKED_IPS: set[str] = set() + +# Request stats (for summary logging) +_stats = {"blocked": 0, "rate_limited": 0, "total": 0} +_stats_last_log = time.time() +STATS_LOG_INTERVAL = 60 # Log stats every 60 seconds + +# Suspicious paths that indicate bot scanning +SUSPICIOUS_PATHS = { + ".env", ".env.local", ".env.production", ".env.development", ".env.bak", + ".env.old", ".env.backup", ".env.orig", ".env.save", ".env~", ".env.swp", + ".env.copy", ".env.1", ".ENV", + "appsettings.json", "appsettings.Development.json", "appsettings.Production.json", + "appsettings.Staging.json", "web.config", + "phpinfo.php", "info.php", "test.php", "i.php", "phpi.php", "php.php", + "phptest.php", "server-info.php", "phpinformation.php", "infophp.php", + "php_info.php", "config.php", + "actuator/env", "actuator/configprops", "actuator", + "manage/env", "admin/env", "env", + "actuator/env/aws", "actuator/env/cloud", + "_layouts/15/", "_layouts/15/ToolPane.aspx", + "wp-admin", "wp-login.php", "wordpress", + "administrator", "phpmyadmin", + ".git", ".svn", ".hg", +} + +def get_client_ip(request: Request) -> str: + """Extract client IP from request""" + client_ip = request.client.host if request.client else "unknown" + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + client_ip = forwarded.split(",")[0].strip() + return client_ip + + +def is_ip_blocked(client_ip: str) -> bool: + """Check if IP is blocked""" + return client_ip in BLOCKED_IPS + + +def check_rate_limit(client_ip: str) -> bool: + """Check if IP has exceeded rate limit""" + now = time.time() + + # Clean old requests + _ip_request_counts[client_ip] = [ + t for t in _ip_request_counts[client_ip] + if now - t < RATE_LIMIT_WINDOW + ] + + if len(_ip_request_counts[client_ip]) >= RATE_LIMIT_REQUESTS: + return False + + _ip_request_counts[client_ip].append(now) + return True + + +def is_suspicious_path(path: str) -> bool: + """Check if path is suspicious (bot scanning)""" + path_lower = path.lower() + + # Direct match + if path_lower in SUSPICIOUS_PATHS: + return True + + # Contains suspicious patterns + suspicious_patterns = [ + ".env", "phpinfo", "actuator", "wp-", "phpmyadmin", + ".git", ".svn", + ] + + for pattern in suspicious_patterns: + if pattern in path_lower: + return True + + # Path traversal attempts + if ".." in path or ".." in path.replace("%2e%2e", "").replace("%252e", ""): + return True + + return False + + +def set_ip_config(blocked: Optional[set[str]] = None): + """Configure IP blocking (call from main.py)""" + global BLOCKED_IPS + if blocked is not None: + BLOCKED_IPS = blocked + + class LoggingMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): - # Generate request ID request_id = str(uuid.uuid4())[:8] + global _stats, _stats_last_log - # Get client IP - client_ip = request.client.host if request.client else "unknown" - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - client_ip = forwarded.split(",")[0].strip() + client_ip = get_client_ip(request) - # Log incoming request - logger.info(f"→ {request.method} {request.url.path} (IP: {client_ip}, ID: {request_id})") + # Check if IP is blocked (silent) + if is_ip_blocked(client_ip): + _stats["blocked"] += 1 + return Response(status_code=404, content="") - # Start timer + path = request.url.path + + # Skip rate limiting for pack file downloads (direct high-speed serving) + if path.startswith("/pack/") and "/file/" in path: + is_file_download = True + elif path.startswith(("/skin/", "/css/", "/js/", "/img/", "/highlights/")): + # SkinRestorer skins + landing site assets must not be rate-limited + is_file_download = True + else: + is_file_download = False + + # Check rate limit + if not check_rate_limit(client_ip): + _stats["rate_limited"] += 1 + if time.time() - _stats_last_log > STATS_LOG_INTERVAL: + logger.warning(f"Stats: {_stats}") + _stats_last_log = time.time() + return Response(status_code=429, content="Too many requests") + + # Check suspicious path (silent 404 for bots) + if is_suspicious_path(path): + # Return 404 without logging - confuse the bots + return Response(status_code=404, content="") + + # Track total requests for stats + _stats["total"] += 1 + + # Log legitimate requests (except file downloads) start_time = time.time() + if not is_file_download: + logger.info(f"→ {request.method} {path} (IP: {client_ip}, ID: {request_id})") + try: response = await call_next(request) - - # Calculate duration duration = (time.time() - start_time) * 1000 - # Log response - logger.info(f"← {request.method} {request.url.path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]") + if not is_file_download: + logger.info(f"← {request.method} {path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]") + + # Periodic stats logging (only log if there were blocked/rate-limited) + now = time.time() + if now - _stats_last_log > STATS_LOG_INTERVAL: + if _stats["blocked"] > 0 or _stats["rate_limited"] > 0: + logger.warning(f"Blocked requests: IP_blocked={_stats['blocked']}, rate_limited={_stats['rate_limited']}") + _stats = {"blocked": 0, "rate_limited": 0, "total": 0} + _stats_last_log = now - # Add request ID to response headers response.headers["X-Request-ID"] = request_id - return response except Exception as e: duration = (time.time() - start_time) * 1000 - # Log full traceback error_traceback = traceback.format_exc() - logger.error(f"✗ {request.method} {request.url.path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}") + logger.error(f"✗ {request.method} {path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}") raise \ No newline at end of file diff --git a/server/mirror.py b/server/mirror.py new file mode 100644 index 0000000..97d87bb --- /dev/null +++ b/server/mirror.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +Lightweight Mirror Server - only serves static files +""" + +import os +import asyncio +from pathlib import Path +import structlog +import httpx + +MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582") +MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") +PORT = int(os.environ.get("PORT", "1582")) + +BUILDS_DIR = Path("builds") +VERSIONS_DIR = BUILDS_DIR / "versions" +PACKS_DIR = Path("packs") + +BUILDS_DIR.mkdir(exist_ok=True) +PACKS_DIR.mkdir(exist_ok=True) + +logging = structlog.get_logger() + + +async def sync_with_main(): + """Sync files from main server""" + logging.info(f"Syncing from {MAIN_SERVER_URL}") + + client = httpx.AsyncClient(timeout=120.0) + headers = {"X-Master-Key": MASTER_KEY} + + try: + # Get launcher info + resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers) + if resp.status_code != 200: + logging.error(f"Failed to get launcher info: {resp.status_code}") + return + + data = resp.json() + current_version = data.get("current_version", "1.0.9") + files = data.get("files", {}) + zips = files.get("zips", []) + logging.info(f"Current version: {current_version}, zips: {len(zips)}") + + # Download latest ZIP + for z in zips: + if not z.get("is_legacy"): + zip_filename = z.get("filename") + zip_path = BUILDS_DIR / zip_filename + if not zip_path.exists(): + logging.info(f"Downloading {zip_filename}...") + # Try direct download + download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}" + resp = await client.get(download_url, headers=headers) + if resp.status_code == 200: + zip_path.write_bytes(resp.content) + logging.info(f"Downloaded {zip_filename}") + + # Extract + version = z.get("version") + extract_to = VERSIONS_DIR / version + extract_to.mkdir(parents=True, exist_ok=True) + import zipfile + with zipfile.ZipFile(zip_path, 'r') as zf: + zf.extractall(extract_to) + logging.info(f"Extracted {version}") + + # Get launcher meta + resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers) + if resp.status_code == 200: + (BUILDS_DIR / "meta.json").write_text(resp.text) + logging.info("Meta synced") + + # Sync packs list + resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers) + if resp.status_code == 200: + packs_data = resp.json() + packs = packs_data.get("packs", []) + logging.info(f"Found {len(packs)} packs") + + for pack in packs: + pack_name = pack.get("name") + pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}" + resp = await client.get(pack_meta_url, headers=headers) + if resp.status_code == 200: + pack_dir = PACKS_DIR / pack_name + pack_dir.mkdir(parents=True, exist_ok=True) + (pack_dir / "meta.json").write_text(resp.text) + logging.info(f"Synced pack: {pack_name}") + + finally: + await client.aclose() + + logging.info("Sync complete") + + +async def run_server(): + """Run static server""" + from fastapi import FastAPI, HTTPException, Request + from fastapi.responses import StreamingResponse + import aiofiles + import mimetypes + import re + import uvicorn + + app = FastAPI(title="ZernMC Mirror") + + async def send_file(file_path: Path, request: Request): + if not file_path.exists(): + raise HTTPException(404, "Not found") + + content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + file_size = file_path.stat().st_size + range_header = request.headers.get("range") + + if range_header: + match = re.match(r"bytes=(\d+)-(\d+)?", range_header) + if match: + start = int(match.group(1)) + end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1) + content_length = end - start + 1 + async with aiofiles.open(file_path, "rb") as f: + await f.seek(start) + chunk = await f.read(content_length) + return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type, + headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)}) + + async def file_iter(): + async with aiofiles.open(file_path, "rb") as f: + while True: + chunk = await f.read(65536) + if not chunk: + break + yield chunk + + return StreamingResponse(file_iter(), media_type=content_type, + headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)}) + + @app.get("/launcher/info") + async def get_launcher_info(): + meta_path = BUILDS_DIR / "meta.json" + if meta_path.exists(): + import json + return json.loads(meta_path.read_text()) + return {"current_version": "unknown", "files": {}} + + @app.get("/launcher/version") + async def get_version(): + return await get_launcher_info() + + @app.get("/launcher/file/{version}/{file_path:path}") + async def get_launcher_file(version: str, file_path: str, request: Request): + full_path = BUILDS_DIR / "versions" / version / file_path + if ".." in file_path: + raise HTTPException(403, "Invalid path") + if not full_path.exists(): + raise HTTPException(404, f"File not found: {file_path}") + return await send_file(full_path, request) + + @app.get("/launcher/download/zip/{filename}") + async def download_zip(filename: str, request: Request): + return await send_file(BUILDS_DIR / filename, request) + + @app.get("/launcher/meta/{version}") + async def get_meta(version: str): + meta_path = BUILDS_DIR / "meta.json" + if meta_path.exists(): + import json + return json.loads(meta_path.read_text()) + raise HTTPException(404, "Meta not found") + + @app.get("/launcher/mirrors") + async def get_mirrors(): + return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]} + + @app.get("/packs") + async def list_packs(): + import json + packs = [] + for pack_dir in PACKS_DIR.iterdir(): + if pack_dir.is_dir(): + meta_path = pack_dir / "meta.json" + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text()) + packs.append({ + "name": pack_dir.name, + "version": meta.get("version", 1), + "files_count": len(meta.get("files", {})) + }) + except: + packs.append({"name": pack_dir.name, "error": "invalid"}) + return {"packs": packs} + + @app.get("/pack/{pack_name}") + async def get_pack(pack_name: str): + meta_path = PACKS_DIR / pack_name / "meta.json" + if meta_path.exists(): + import json + return json.loads(meta_path.read_text()) + raise HTTPException(404, "Pack not found") + + @app.get("/pack/meta/{pack_name}") + async def get_pack_meta(pack_name: str): + return await get_pack(pack_name) + + @app.get("/pack/{pack_name}/diff") + async def get_pack_diff(pack_name: str): + # For mirror, just return empty diff (no local changes) + return {"added": [], "removed": [], "changed": []} + + @app.get("/pack/{pack_name}/file/{file_path:path}") + async def get_pack_file(pack_name: str, file_path: str, request: Request): + return await send_file(PACKS_DIR / pack_name / file_path, request) + + config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info") + server = uvicorn.Server(config) + await server.serve() + + +async def main(): + logging.info("Starting ZernMC Mirror Server") + await sync_with_main() + await run_server() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/server/models.py b/server/models.py index 99f4a58..e983664 100644 --- a/server/models.py +++ b/server/models.py @@ -28,16 +28,4 @@ class PackMeta(BaseModel): minecraft_version: str loader_type: str loader_version: Optional[str] = None - -class MinecraftVersion(BaseModel): - version: str - type: str # release, snapshot, old_alpha, old_beta - release_time: datetime - url: Optional[str] = None - -class ModLoader(BaseModel): - type: str - version: str - minecraft_version: str - installer_url: Optional[str] = None - libraries: List[str] = Field(default_factory=list) \ No newline at end of file + asset_index: Optional[str] = None \ No newline at end of file diff --git a/server/news/news6.txt b/server/news/news6.txt new file mode 100644 index 0000000..5d3564c --- /dev/null +++ b/server/news/news6.txt @@ -0,0 +1,10 @@ +Pack Download Stability Fix +Update +v1.0.13.1 +[{"bold":1,"text":"Fixed pack downloads getting stuck on Minecraft/Forge installation","click_action":"none"}] +- All Minecraft downloads now use smart proxy system (like the rest of the launcher) +- Added read timeouts to prevent hanging on slow/unstable connections +- Asset download no longer blocks forever if Mojang CDN stalls +- Forge installer process now has a 10-minute timeout +- Progress bar now updates during Minecraft installation phase +[{"clickable":1,"text":"Download v1.0.13.1","click_action":"open_url","url":"https://zernmc.ru/download"}] diff --git a/server/pack_manager.py b/server/pack_manager.py index 4eae54c..66a65ba 100644 --- a/server/pack_manager.py +++ b/server/pack_manager.py @@ -1,10 +1,13 @@ import hashlib import os +import zipfile from datetime import datetime from pathlib import Path import json -from typing import Optional, Dict +from typing import Optional, Dict, List import structlog +import asyncio +import aiofiles from models import PackMeta, FileEntry @@ -17,6 +20,46 @@ DATA_DIR.mkdir(exist_ok=True) # Cache for loaded manifests _manifest_cache: Dict[str, PackMeta] = {} +# Pack config cache +_packs_config: Optional[Dict] = None + +def load_packs_config() -> dict: + global _packs_config + if _packs_config is not None: + return _packs_config + config_path = DATA_DIR / "packs_config.json" + if config_path.exists(): + try: + _packs_config = json.loads(config_path.read_text()) + except Exception: + _packs_config = {} + else: + _packs_config = {} + return _packs_config + +def save_packs_config(config: dict): + global _packs_config + _packs_config = config + config_path = DATA_DIR / "packs_config.json" + config_path.write_text(json.dumps(config, indent=2)) + +def is_pack_disabled(pack_name: str) -> bool: + config = load_packs_config() + return config.get(pack_name, {}).get("disabled", False) + +def toggle_pack_disabled(pack_name: str) -> bool: + config = load_packs_config() + entry = config.setdefault(pack_name, {}) + entry["disabled"] = not entry.get("disabled", False) + save_packs_config(config) + return entry["disabled"] + +def get_pack_presets(pack_name: str) -> List[str]: + presets_dir = PACKS_DIR / pack_name / "presets" + if not presets_dir.exists() or not presets_dir.is_dir(): + return [] + return sorted([d.name for d in presets_dir.iterdir() if d.is_dir()]) + def get_cached_manifest(pack_name: str) -> Optional[PackMeta]: """Get manifest from memory cache if available""" return _manifest_cache.get(pack_name) @@ -33,9 +76,9 @@ def calculate_sha256_sync(file_path: Path) -> str: return hash_sha.hexdigest() async def calculate_sha256(file_path: Path) -> str: - """Calculate SHA256 hash of a file (async wrapper)""" - # Используем синхронную версию для простоты - return calculate_sha256_sync(file_path) + """Calculate SHA256 hash of a file (async)""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, calculate_sha256_sync, file_path) async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: """Scan pack directory and update manifest if needed""" @@ -51,11 +94,11 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: if not force_rescan and pack_name in _manifest_cache: return _manifest_cache[pack_name] - # Load existing meta if available (синхронно) + # Load existing meta if available if meta_path.exists(): try: - with open(meta_path, 'r', encoding='utf-8') as f: - data = json.load(f) + async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f: + data = json.loads(await f.read()) current_meta = PackMeta.model_validate(data) except Exception as e: logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}") @@ -64,10 +107,11 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: new_files: Dict[str, FileEntry] = {} changed = False - # Get ignored directories - ignored_dirs = current_meta.ignored_dirs if current_meta else [ + # Get ignored directories (always use current list, not cached from meta) + ignored_dirs = [ "resourcepacks", "shaderpacks", "saves", "logs", - "crash-reports", "screenshots", "journeymap", "config" + "crash-reports", "screenshots", "journeymap", + "presets" ] # Walk through pack directory @@ -79,10 +123,19 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: file_path = Path(root) / file rel_path = file_path.relative_to(pack_path).as_posix() + # Skip metadata files that should not be part of the pack + if file in ("description.txt", "instance.json"): + continue + # Skip if in ignored directory if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs): continue + # Validate .jar files — skip corrupted ones + if file.endswith(".jar") and not _is_valid_zip(file_path): + logger.warning(f"Skipping corrupt jar in pack {pack_name}: {rel_path}") + continue + stat = file_path.stat() file_hash = await calculate_sha256(file_path) @@ -109,16 +162,17 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: minecraft_version = "1.20.4" loader_type = "vanilla" loader_version = None + asset_index = None pack_config_path = pack_path / "instance.json" if pack_config_path.exists(): try: - # Синхронное чтение конфига - with open(pack_config_path, 'r', encoding='utf-8') as f: - config = json.load(f) + async with aiofiles.open(pack_config_path, 'r', encoding='utf-8') as f: + config = json.loads(await f.read()) minecraft_version = config.get("minecraftVersion", minecraft_version) loader_type = config.get("loaderType", loader_type) loader_version = config.get("loaderVersion") + asset_index = config.get("assetIndex") except Exception as e: logger.warning(f"Failed to load instance.json for {pack_name}: {e}") @@ -131,12 +185,12 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: ignored_dirs=ignored_dirs, minecraft_version=minecraft_version, loader_type=loader_type, - loader_version=loader_version + loader_version=loader_version, + asset_index=asset_index ) - # Save to disk (синхронно) - with open(meta_path, 'w', encoding='utf-8') as f: - f.write(new_meta.model_dump_json(indent=2)) + async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f: + await f.write(new_meta.model_dump_json(indent=2)) # Update cache _manifest_cache[pack_name] = new_meta @@ -150,4 +204,53 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: return current_meta # Should not happen - raise Exception(f"Failed to scan pack {pack_name}") \ No newline at end of file + raise Exception(f"Failed to scan pack {pack_name}") + +def _is_valid_zip(path: Path) -> bool: + """Check if a file is a valid ZIP archive""" + try: + with zipfile.ZipFile(path, 'r') as zf: + return zf.testzip() is None + except zipfile.BadZipFile: + return False + except Exception: + return False + + +def get_merged_manifest(pack_name: str, preset: str) -> PackMeta: + """Get manifest merged with preset files. Preset files override base files.""" + base_meta = get_cached_manifest(pack_name) + if not base_meta: + raise FileNotFoundError(f"Pack {pack_name} not found") + + preset_dir = PACKS_DIR / pack_name / "presets" / preset + if not preset_dir.exists() or not preset_dir.is_dir(): + raise FileNotFoundError(f"Preset {preset} not found for pack {pack_name}") + + merged_files = dict(base_meta.files) + + for root, dirs, files in os.walk(preset_dir): + for file in files: + file_path = Path(root) / file + rel_path = file_path.relative_to(preset_dir).as_posix() + stat = file_path.stat() + file_hash = calculate_sha256_sync(file_path) + merged_files[rel_path] = FileEntry( + path=rel_path, + hash=file_hash, + size=stat.st_size, + added_at=datetime.utcfromtimestamp(stat.st_ctime), + modified_at=datetime.utcfromtimestamp(stat.st_mtime) + ) + + return PackMeta( + pack_name=base_meta.pack_name, + version=base_meta.version, + files=merged_files, + updated_at=base_meta.updated_at, + ignored_dirs=base_meta.ignored_dirs, + minecraft_version=base_meta.minecraft_version, + loader_type=base_meta.loader_type, + loader_version=base_meta.loader_version, + asset_index=base_meta.asset_index + ) \ No newline at end of file diff --git a/server/pass_manager.py b/server/pass_manager.py deleted file mode 100644 index 574fcb6..0000000 --- a/server/pass_manager.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -from pathlib import Path -from datetime import datetime -import structlog - -logger = structlog.get_logger(__name__) - -PASSES_FILE = Path("data/passes.json") - -def load_passes(): - if not PASSES_FILE.exists(): - PASSES_FILE.parent.mkdir(exist_ok=True) - default = {"passes": {}} - PASSES_FILE.write_text(json.dumps(default, indent=2, ensure_ascii=False)) - return default - try: - return json.loads(PASSES_FILE.read_text(encoding="utf-8")) - except: - return {"passes": {}} - -def save_passes(data): - PASSES_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False)) - -def activate_pass(pass_code: str, username: str, user_id: int) -> dict: - data = load_passes() - pass_code = pass_code.upper().strip() - - if pass_code not in data["passes"]: - return {"success": False, "error": "Проходка не найдена"} - - p = data["passes"][pass_code] - - if not p.get("is_active", True): - return {"success": False, "error": "Проходка деактивирована"} - - if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp(): - return {"success": False, "error": "Проходка истекла"} - - if p.get("owner") is not None: - if p.get("owner") != username: - return {"success": False, "error": "Проходка уже активирована другим пользователем"} - return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"} - - # Активация - now = datetime.now().timestamp() - p["owner"] = username - p["activated_at"] = now - p["uses"] = p.get("uses", 0) + 1 - - save_passes(data) - - logger.info("Pass activated", pass_code=pass_code, username=username) - return {"success": True, "message": "Проходка успешно активирована!"} - -def has_active_pass(username: str) -> bool: - data = load_passes() - for p in data["passes"].values(): - if p.get("owner") == username: - if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp(): - continue - if p.get("is_active", True): - return True - return False - -def get_user_passes(username: str) -> list: - data = load_passes() - result = [] - now = datetime.now().timestamp() - for p in data["passes"].values(): - if p.get("owner") == username: - result.append({ - "code": p["code"], - "activated_at": p.get("activated_at"), - "expires_at": p.get("expires_at"), - "is_active": p.get("is_active", True) and (not p.get("expires_at") or p.get("expires_at") > now) - }) - return result \ No newline at end of file diff --git a/server/playtime.py b/server/playtime.py new file mode 100644 index 0000000..bcfbdee --- /dev/null +++ b/server/playtime.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from typing import Optional +import structlog + +from auth import get_db, get_current_user + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix="/api", tags=["playtime"]) + +def init_playtime_db(): + with get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS playtime ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + pack_name TEXT DEFAULT '', + minutes INTEGER DEFAULT 0, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + CREATE INDEX IF NOT EXISTS idx_playtime_user ON playtime(user_id); + """) + +class SyncPlaytimeRequest(BaseModel): + minutes: int + pack_name: Optional[str] = "" + +@router.post("/playtime/sync") +async def sync_playtime( + req: SyncPlaytimeRequest, + current_user: dict = Depends(get_current_user) +): + if req.minutes < 0 or req.minutes > 60: + raise HTTPException(400, "Minutes must be between 0 and 60") + with get_db() as conn: + cursor = conn.execute( + "SELECT id, minutes FROM playtime WHERE user_id = ? AND pack_name = ?", + (current_user["id"], req.pack_name) + ) + existing = cursor.fetchone() + if existing: + conn.execute( + "UPDATE playtime SET minutes = minutes + ?, last_updated = CURRENT_TIMESTAMP WHERE id = ?", + (req.minutes, existing[0]) + ) + else: + conn.execute( + "INSERT INTO playtime (user_id, pack_name, minutes) VALUES (?, ?, ?)", + (current_user["id"], req.pack_name, req.minutes) + ) + logger.info("Playtime synced", user=current_user["id"], minutes=req.minutes) + return {"status": "ok"} + +@router.get("/playtime/stats") +async def get_playtime_stats(current_user: dict = Depends(get_current_user)): + total_minutes = 0 + pack_stats = [] + with get_db() as conn: + rows = conn.execute( + "SELECT COALESCE(SUM(minutes), 0) FROM playtime WHERE user_id = ?", + (current_user["id"],) + ) + total_minutes = rows.fetchone()[0] + + rows = conn.execute( + "SELECT pack_name, minutes FROM playtime WHERE user_id = ? AND pack_name != '' ORDER BY minutes DESC", + (current_user["id"],) + ) + for row in rows: + pack_stats.append({ + "pack_name": row[0], + "minutes": row[1] + }) + return { + "total_minutes": total_minutes, + "total_hours": round(total_minutes / 60, 1), + "packs": pack_stats + } diff --git a/server/roles.py b/server/roles.py new file mode 100644 index 0000000..d045275 --- /dev/null +++ b/server/roles.py @@ -0,0 +1,114 @@ +# roles.py +from enum import IntEnum +from typing import Dict, Set + +class UserRole(IntEnum): + USER = 0 # Обычный пользователь + PASS_HOLDER = 1 # Пользователь с проходкой + MODERATOR = 2 # Модератор + ELDER = 3 # Elder Moderator + CREATOR = 4 # Создатель + +# Aliases for backwards compatibility with admin_router.py +ROLE_USER = UserRole.USER +ROLE_PASS_HOLDER = UserRole.PASS_HOLDER +ROLE_MODERATOR = UserRole.MODERATOR +ROLE_ELDER = UserRole.ELDER +ROLE_CREATOR = UserRole.CREATOR + +__all__ = [ + "UserRole", "ROLE_USER", "ROLE_PASS_HOLDER", "ROLE_MODERATOR", + "ROLE_ELDER", "ROLE_CREATOR", "ROLE_NAMES", "Permissions", + "ROLE_PERMISSIONS", "has_permission", "require_permission", +] + +ROLE_NAMES: Dict[int, str] = { + UserRole.USER: "Игрок", + UserRole.PASS_HOLDER: "Игрок [Проходка]", + UserRole.MODERATOR: "Модератор", + UserRole.ELDER: "Elder Moderator", + UserRole.CREATOR: "Создатель" +} + +# Права доступа +class Permissions: + # Базовые права + DOWNLOAD_PACK = "download_pack" # Скачивание сборок + VIEW_PACKS = "view_packs" # Просмотр списка сборок + + # Права модератора + REQUEST_PASS = "request_pass" # Запрос проходки для игрока + VIEW_USER_LIST = "view_user_list" # Просмотр списка пользователей + + # Права Elder Moderator + APPROVE_PASS = "approve_pass" # Одобрение проходок + REJECT_PASS = "reject_pass" # Отклонение проходок + VIEW_PASS_REQUESTS = "view_pass_requests" # Просмотр запросов проходок + MANAGE_MODERATORS = "manage_moderators" # Управление модераторами + + # Права создателя + DIRECT_PASS = "direct_pass" # Прямая выдача проходки + MANAGE_ELDER = "manage_elder" # Управление Elder + MANAGE_SERVER = "manage_server" # Управление сервером + VIEW_AUDIT_LOG = "view_audit_log" # Просмотр логов + +# Маппинг ролей на права +ROLE_PERMISSIONS: Dict[int, Set[str]] = { + UserRole.USER: { + # Обычный игрок НЕ может даже смотреть сборки! + # Только авторизоваться и смотреть свой профиль + }, + UserRole.PASS_HOLDER: { + Permissions.VIEW_PACKS, # Может видеть список сборок + Permissions.DOWNLOAD_PACK, # Может скачивать сборки + }, + UserRole.MODERATOR: { + Permissions.VIEW_PACKS, + Permissions.DOWNLOAD_PACK, + Permissions.REQUEST_PASS, # Может запрашивать проходки для игроков + Permissions.VIEW_USER_LIST, # Может видеть список пользователей + }, + UserRole.ELDER: { + Permissions.VIEW_PACKS, + Permissions.DOWNLOAD_PACK, + Permissions.REQUEST_PASS, + Permissions.VIEW_USER_LIST, + Permissions.APPROVE_PASS, # Может одобрять проходки + Permissions.REJECT_PASS, # Может отклонять проходки + Permissions.VIEW_PASS_REQUESTS, + Permissions.MANAGE_MODERATORS, # Может управлять модераторами + }, + UserRole.CREATOR: { + Permissions.VIEW_PACKS, + Permissions.DOWNLOAD_PACK, + Permissions.REQUEST_PASS, + Permissions.VIEW_USER_LIST, + Permissions.APPROVE_PASS, + Permissions.REJECT_PASS, + Permissions.VIEW_PASS_REQUESTS, + Permissions.MANAGE_MODERATORS, + Permissions.DIRECT_PASS, # Прямая выдача проходки + Permissions.MANAGE_ELDER, # Управление Elder + Permissions.MANAGE_SERVER, # Управление сервером + Permissions.VIEW_AUDIT_LOG, # Просмотр логов + } +} + +def has_permission(role: int, permission: str) -> bool: + """Проверка наличия права у роли""" + return permission in ROLE_PERMISSIONS.get(role, set()) + +def require_permission(permission: str): + """Декоратор для проверки права""" + from functools import wraps + from fastapi import HTTPException, Depends + from auth import get_current_user + + def decorator(func): + @wraps(func) + async def wrapper(*args, current_user: dict = Depends(get_current_user), **kwargs): + if not has_permission(current_user["role"], permission): + raise HTTPException(403, f"Недостаточно прав. Требуется право: {permission}") + return await func(*args, current_user=current_user, **kwargs) + return wrapper + return decorator \ No newline at end of file diff --git a/server/site/css/style.css b/server/site/css/style.css new file mode 100644 index 0000000..ff1b44b --- /dev/null +++ b/server/site/css/style.css @@ -0,0 +1,1310 @@ +:root { + --accent: #e94560; + --accent-soft: #ff6b6b; + --bg-deep: #07070a; + --bg-surface: #0c0c12; + --bg-card: #16161f; + --text: #eeeef0; + --text-secondary: #88889a; + --border: rgba(255, 255, 255, 0.06); + --font: 'Onest', system-ui, -apple-system, 'Segoe UI', sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + font-family: var(--font); + background: var(--bg-deep); + color: var(--text); + line-height: 1.55; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; text-decoration: none; } +img { display: block; } +ul { list-style: none; } + +::selection { background: var(--accent); color: #fff; } + +/* ---------- Nav ---------- */ + +.nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + transition: background .3s ease, border-color .3s ease, backdrop-filter .3s ease; + border-bottom: 1px solid transparent; +} + +.nav.scrolled { + background: rgba(7, 7, 10, 0.72); + backdrop-filter: blur(14px); + border-bottom-color: var(--border); +} + +.nav-inner { + max-width: 1200px; + margin: 0 auto; + padding: 18px 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.nav-brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + font-size: 18px; + letter-spacing: 0.3px; +} + +.nav-brand img { border-radius: 6px; } + +.nav-links { + display: flex; + gap: 36px; + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); +} + +.nav-links a { position: relative; transition: color .2s ease; } + +.nav-links a::after { + content: ''; + position: absolute; + left: 0; + bottom: -6px; + width: 0; + height: 2px; + background: var(--accent); + transition: width .25s ease; +} + +.nav-links a:hover { color: var(--text); } +.nav-links a:hover::after { width: 100%; } + +.nav-cta { + font-size: 14px; + font-weight: 600; + padding: 10px 22px; + border-radius: 8px; + background: var(--accent); + color: #fff; + transition: background .2s ease, transform .15s ease; +} + +.nav-cta:hover { background: var(--accent-soft); transform: translateY(-1px); } + +.nav-right { + display: flex; + align-items: center; + gap: 16px; +} + +.lang-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.4px; + cursor: pointer; + transition: color .2s ease, border-color .2s ease, background .2s ease; +} + +.lang-toggle:hover { color: var(--text); border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.04); } + +/* ---------- Hero ---------- */ + +.hero { + position: relative; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + overflow: hidden; + padding: 120px 32px 80px; + background: + radial-gradient(1000px 500px at 50% -10%, rgba(233, 69, 96, 0.12), transparent 60%), + var(--bg-deep); +} + +.hero-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px); + background-size: 64px 64px; + mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 75%); + -webkit-mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 75%); +} + +.hero-glow { + position: absolute; + width: 560px; + height: 560px; + left: 50%; + top: 42%; + transform: translate(-50%, -50%); + background: radial-gradient(circle, rgba(233, 69, 96, 0.16), transparent 65%); + filter: blur(20px); + animation: pulse 6s ease-in-out infinite; + pointer-events: none; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.6; transform: translate(-50%, -50%) scale(1); } + 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.12); } +} + +.hero-diamond { + position: absolute; + border: 1px solid rgba(233, 69, 96, 0.35); + transform: rotate(45deg); + opacity: 0; + animation: float-in 2s ease forwards; +} + +.hero-diamond--1 { + width: 260px; height: 260px; + right: 8%; top: 16%; + animation-delay: .4s; +} + +.hero-diamond--2 { + width: 150px; height: 150px; + left: 10%; top: 24%; + border-color: rgba(255, 255, 255, 0.1); + animation-delay: .7s; +} + +.hero-diamond--3 { + width: 90px; height: 90px; + left: 24%; bottom: 14%; + animation-delay: 1s; +} + +@keyframes float-in { + 0% { opacity: 0; transform: rotate(45deg) scale(0.6); } + 100% { opacity: 1; transform: rotate(45deg) scale(1); } +} + +.hero-inner { position: relative; z-index: 2; max-width: 900px; will-change: transform; } + +.hero-kicker { + font-size: 13px; + font-weight: 600; + letter-spacing: 3px; + color: var(--accent); + margin-bottom: 24px; + text-transform: uppercase; +} + +.hero-title { + font-size: clamp(52px, 9vw, 108px); + font-weight: 900; + line-height: 0.98; + letter-spacing: -2px; + margin-bottom: 28px; +} + +.hero-title-accent { + background: linear-gradient(120deg, var(--accent-soft), var(--accent)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.hero-sub { + font-size: clamp(16px, 2.2vw, 19px); + color: var(--text-secondary); + max-width: 560px; + margin: 0 auto 40px; + font-weight: 300; +} + +.hero-actions { + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 15px 30px; + border-radius: 10px; + font-family: inherit; + font-size: 16px; + font-weight: 600; + cursor: pointer; + border: none; + transition: transform .15s ease, box-shadow .2s ease, background .2s ease, border-color .2s ease; +} + +.btn:hover { transform: translateY(-2px); } + +.btn-primary { + background: var(--accent); + color: #fff; + box-shadow: 0 8px 30px rgba(233, 69, 96, 0.35); +} + +.btn-primary:hover { + background: var(--accent-soft); + box-shadow: 0 10px 36px rgba(233, 69, 96, 0.45); +} + +.btn-ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-ghost:hover { border-color: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.03); } + +.btn-block { width: 100%; } + +.hero-meta { + margin-top: 26px; + font-size: 13px; + color: var(--text-secondary); + letter-spacing: 0.5px; +} + +.scroll-hint { + position: absolute; + bottom: 36px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + z-index: 2; + color: var(--text-secondary); + font-size: 12px; + letter-spacing: 2px; + text-transform: uppercase; + will-change: transform; +} + +.scroll-hint-line { + width: 1px; + height: 44px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.scroll-hint-line i { + display: block; + width: 1px; + height: 100%; + background: linear-gradient(180deg, var(--accent), transparent); + animation: scroll-line 1.8s ease-in-out infinite; +} + +@keyframes scroll-line { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100%); } +} + +/* ---------- Sections ---------- */ + +.section { + max-width: 1200px; + margin: 0 auto; + padding: 120px 32px; + position: relative; +} + +.section-head { margin-bottom: 64px; } + +.section-head--center { text-align: center; } +.section-head--center .section-sub { margin-left: auto; margin-right: auto; } + +.section-index { + display: inline-block; + font-size: 13px; + font-weight: 600; + letter-spacing: 2px; + color: var(--accent); + text-transform: uppercase; + margin-bottom: 16px; +} + +.section-title { + font-size: clamp(34px, 5vw, 56px); + font-weight: 800; + line-height: 1.05; + letter-spacing: -1px; + margin-bottom: 16px; +} + +.accent { color: var(--accent); } + +.section-sub { + font-size: 17px; + color: var(--text-secondary); + max-width: 520px; + font-weight: 300; +} + +/* ---------- Server status ---------- */ + +.server { + background: + radial-gradient(800px 400px at 15% 10%, rgba(233, 69, 96, 0.08), transparent 60%), + radial-gradient(800px 400px at 85% 90%, rgba(233, 69, 96, 0.05), transparent 60%); +} + +.server-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +.server-card { + background: rgba(22, 22, 31, 0.85); + backdrop-filter: blur(16px); + border: 1px solid var(--border); + border-radius: 16px; + padding: 30px 28px; + transition: transform .25s ease, border-color .25s ease; +} + +.server-card:hover { transform: translateY(-4px); border-color: rgba(233, 69, 96, 0.35); } + +.server-card-label { + font-size: 12px; + font-weight: 600; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 14px; +} + +.server-card-value { + font-size: 26px; + font-weight: 800; + line-height: 1.1; + display: flex; + align-items: center; + gap: 10px; +} + +.server-card-sub { font-size: 13px; color: var(--text-secondary); margin-top: 10px; } + +.server-season { color: var(--text-secondary); font-size: 18px; font-weight: 600; } + +.status-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex: none; + display: inline-block; +} + +.status-dot--online { background: #22c55e; box-shadow: 0 0 16px rgba(34, 197, 94, 0.8); animation: pulse 2s infinite; } +.status-dot--offline { background: #ef4444; box-shadow: 0 0 16px rgba(239, 68, 68, 0.8); } +.status-dot--loading { background: var(--text-secondary); animation: pulse 1.2s infinite; } + +.status-text--online { color: #22c55e; } +.status-text--offline { color: #ef4444; } + +/* ---------- Highlights carousel ---------- */ + +.carousel { + position: relative; + border-radius: 20px; + overflow: hidden; + background: var(--bg-card); + border: 1px solid var(--border); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35); +} + +.carousel-track { + display: flex; + transition: transform .6s cubic-bezier(0.4, 0, 0.2, 1); +} + +.carousel-slide { + flex: 0 0 100%; + min-width: 100%; + position: relative; + background: #000; +} + +.carousel-slide img, +.carousel-slide video { + width: 100%; + height: auto; + max-height: 640px; + object-fit: contain; + display: block; +} + +.carousel-caption { + position: absolute; + left: 20px; + bottom: 20px; + background: rgba(7, 7, 10, 0.7); + backdrop-filter: blur(10px); + border: 1px solid var(--border); + color: var(--text); + font-size: 13px; + padding: 8px 14px; + border-radius: 8px; +} + +.carousel-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 5; + width: 52px; + height: 52px; + border-radius: 50%; + border: 1px solid var(--border); + background: rgba(7, 7, 10, 0.6); + backdrop-filter: blur(8px); + color: var(--text); + font-size: 28px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background .2s ease, transform .15s ease, color .2s ease; +} + +.carousel-btn:hover { background: var(--accent); color: #fff; } + +.carousel-btn--prev { left: 16px; } +.carousel-btn--next { right: 16px; } + +.carousel-btn:active { transform: translateY(-50%) scale(0.92); } + +.carousel-dots { + display: flex; + justify-content: center; + gap: 8px; + padding: 18px 0; + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 5; +} + +.carousel-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.25); + cursor: pointer; + transition: background .2s ease, transform .2s ease; +} + +.carousel-dot.active { background: var(--accent); transform: scale(1.3); } + +/* ---------- Season theme grid ---------- */ + +.theme { + background: var(--bg-surface); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.theme-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +.theme-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 30px; + text-align: center; + transition: transform .25s ease, border-color .25s ease, background .25s ease; +} + +.theme-card:hover { + transform: translateY(-4px); + border-color: rgba(233, 69, 96, 0.35); + background: #1a1a25; +} + +.theme-tag { + display: inline-block; + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--accent); + background: rgba(233, 69, 96, 0.12); + padding: 5px 10px; + border-radius: 6px; + margin-bottom: 16px; +} + +.theme-card h3 { font-size: 20px; font-weight: 700; margin-bottom: 10px; } +.theme-card p { font-size: 14px; color: var(--text-secondary); font-weight: 300; } + +/* ---------- Features ---------- */ + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +.feature-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 34px 30px; + position: relative; + overflow: hidden; + transition: transform .25s ease, border-color .25s ease, background .25s ease; +} + +.feature-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--accent), transparent); + opacity: 0; + transition: opacity .3s ease; +} + +.feature-card:hover { + transform: translateY(-4px); + border-color: rgba(233, 69, 96, 0.35); + background: #1a1a25; +} + +.feature-card:hover::before { opacity: 1; } + +.feature-num { + position: absolute; + top: 26px; + right: 28px; + font-size: 13px; + font-weight: 700; + color: rgba(255, 255, 255, 0.12); +} + +.feature-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(233, 69, 96, 0.12); + color: var(--accent); + margin-bottom: 22px; +} + +.feature-card h3 { + font-size: 20px; + font-weight: 700; + margin-bottom: 10px; +} + +.feature-card p { + font-size: 15px; + color: var(--text-secondary); + font-weight: 300; +} + +/* ---------- Download ---------- */ + +.download { + background: + radial-gradient(800px 400px at 50% 0%, rgba(233, 69, 96, 0.07), transparent 65%), + var(--bg-surface); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.download-inner { max-width: 720px; margin: 0 auto; } + +.download-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 20px; + padding: 36px; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4); +} + +.download-card-top { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 28px; +} + +.download-card-top img { border-radius: 12px; } + +.download-name { + font-size: 22px; + font-weight: 700; +} + +.download-desc { font-size: 14px; color: var(--text-secondary); } + +.download-mirrors { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 22px; +} + +.mirrors-label { + font-size: 12px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-secondary); + margin-right: 6px; +} + +.mirror-tag { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + border: 1px solid var(--border); + padding: 6px 12px; + border-radius: 999px; + transition: border-color .2s ease, color .2s ease; +} + +.mirror-tag:hover { border-color: rgba(233, 69, 96, 0.4); color: var(--text); } + +.download-note { + text-align: center; + margin-top: 28px; + font-size: 14px; + color: var(--text-secondary); +} + +/* ---------- News ---------- */ + +.news-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +.news-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 28px; + display: flex; + flex-direction: column; + transition: transform .25s ease, border-color .25s ease; +} + +.news-card:hover { transform: translateY(-4px); border-color: rgba(233, 69, 96, 0.35); } + +.news-tags { display: flex; gap: 8px; margin-bottom: 16px; } + +.news-tag { + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--accent); + background: rgba(233, 69, 96, 0.12); + padding: 5px 10px; + border-radius: 6px; +} + +.news-tag--version { color: var(--text-secondary); background: rgba(255, 255, 255, 0.06); } + +.news-card h3 { + font-size: 18px; + font-weight: 700; + line-height: 1.3; + margin-bottom: 10px; +} + +.news-card p { + font-size: 14px; + color: var(--text-secondary); + font-weight: 300; + flex: 1; +} + +.news-skeleton { + background: var(--bg-card); + border-radius: 16px; + height: 180px; + border: 1px solid var(--border); + animation: shimmer 1.5s ease-in-out infinite; +} + +@keyframes shimmer { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} + +/* ---------- Footer ---------- */ + +.footer { + border-top: 1px solid var(--border); + background: var(--bg-deep); + padding: 48px 32px; +} + +.footer-inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; +} + +.footer-brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; +} + +.footer-brand img { border-radius: 6px; } + +.footer-note { font-size: 13px; color: var(--text-secondary); } + +/* ---------- Reveal on scroll ---------- */ + +.reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity .7s ease, transform .7s ease; +} + +.reveal.visible { + opacity: 1; + transform: none; +} + +/* ---------- Launcher showcase ---------- */ + +.launcher-preview { + max-width: 920px; + margin: 0 auto; + user-select: none; + -webkit-user-select: none; + cursor: default; +} + +.launcher-window { + position: relative; + border-radius: 14px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--bg-deep); + box-shadow: 0 40px 120px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(233, 69, 96, 0.06); +} + +.launcher-titlebar { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + background: var(--bg-elevated); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.win-dots { display: flex; gap: 7px; } +.win-dots span { width: 11px; height: 11px; border-radius: 50%; background: rgba(255, 255, 255, 0.14); } +.win-dots span:first-child { background: #ff5f57; } +.win-dots span:nth-child(2) { background: #febc2e; } +.win-dots span:nth-child(3) { background: #28c840; } + +.launcher-titlebar-name { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.win-icon { width: 16px; height: 16px; border-radius: 4px; } + +.launcher-preview-body { + position: relative; + height: 430px; + background: + radial-gradient(700px 300px at 70% -20%, rgba(233, 69, 96, 0.08), transparent 60%), + var(--bg-deep); +} + +.preview-scene { + position: absolute; + inset: 0; + opacity: 0; + transform: translateY(10px) scale(0.995); + transition: opacity .55s ease, transform .55s ease; + pointer-events: none; +} + +.preview-scene.is-active { + opacity: 1; + transform: none; +} + +/* --- login scene --- */ + +.scene-login { display: flex; align-items: center; justify-content: center; } + +.scene-login-inner { + width: 320px; + display: flex; + flex-direction: column; + gap: 14px; + padding: 28px; + background: var(--bg-surface); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 12px; +} + +.pv-brand { display: flex; justify-content: center; } + +.pv-title { + text-align: center; + font-size: 20px; + font-weight: 700; + margin: 4px 0 6px; +} + +.pv-field { display: flex; flex-direction: column; gap: 6px; } +.pv-field label { font-size: 12px; color: var(--text-secondary); font-weight: 600; } + +.pv-input { + height: 38px; + background: var(--bg-inset); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 6px; +} + +.pv-btn { + margin-top: 6px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + background: var(--accent); + color: #fff; + font-size: 14px; + font-weight: 600; +} + +/* --- packs scene --- */ + +.scene-packs { display: flex; } + +.pv-sidebar { + width: 200px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px; + background: var(--bg-surface); + border-right: 1px solid rgba(255, 255, 255, 0.05); +} + +.pv-sb-brand { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; } +.pv-sb-brand svg { border-radius: 5px; } +.pv-sb-brand em { display: block; font-style: normal; font-weight: 500; font-size: 10px; color: var(--text-muted); } + +.pv-sb-nav { display: flex; flex-direction: column; gap: 2px; } + +.pv-nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 9px; + border-radius: 6px; + font-size: 12px; + color: var(--text-secondary); + border: 1px solid transparent; +} + +.pv-nav-item.is-active { color: var(--accent); background: var(--accent-soft); border-color: rgba(233, 69, 96, 0.2); } + +.pv-sb-section { display: flex; flex-direction: column; gap: 5px; } + +.pv-sb-section-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-muted); +} + +.pv-pack-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 7px 9px; + border-radius: 6px; + background: var(--bg-inset); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.pv-pack-item.is-active { border-color: rgba(233, 69, 96, 0.45); box-shadow: 0 0 0 1px rgba(233, 69, 96, 0.25) inset; } + +.pv-pack-name { font-size: 12px; font-weight: 600; } +.pv-pack-meta { font-size: 10px; color: var(--text-muted); } + +.pv-sb-user { + margin-top: auto; + display: flex; + align-items: center; + gap: 8px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.pv-avatar { + width: 28px; + height: 28px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + background: linear-gradient(135deg, var(--accent), var(--accent-soft)); + color: #fff; +} + +.pv-user-meta { display: flex; flex-direction: column; gap: 2px; margin-right: auto; } +.pv-user-meta em { font-style: normal; font-size: 11px; font-weight: 600; } + +.pv-badge { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.6px; + padding: 1px 5px; + border-radius: 4px; + background: var(--accent); + color: #fff; + width: fit-content; +} + +.pv-sb-action { color: var(--text-muted); flex-shrink: 0; } + +.pv-main { + flex: 1; + display: flex; + flex-direction: column; + padding: 18px 20px; + gap: 16px; + min-width: 0; +} + +.pv-main-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.pv-main-head h3 { font-size: 18px; font-weight: 700; margin-bottom: 6px; } + +.pv-tags { display: flex; gap: 6px; } + +.pv-tag { + font-size: 10px; + font-weight: 600; + padding: 3px 8px; + border-radius: 999px; + background: var(--bg-card); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-secondary); +} + +.pv-tag-mc { background: rgba(233, 69, 96, 0.12); border-color: rgba(233, 69, 96, 0.3); color: var(--accent); } + +.pv-stats { display: flex; gap: 18px; padding-top: 4px; } +.pv-stats span { display: flex; flex-direction: column; gap: 2px; text-align: right; } +.pv-stats b { font-size: 15px; font-weight: 700; } +.pv-stats i { font-style: normal; font-size: 9px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); } + +.pv-gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; flex: 1; } + +.pv-gallery-item { + border-radius: 8px; + background: + linear-gradient(160deg, rgba(233, 69, 96, 0.15), rgba(255, 255, 255, 0.03)), + repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.03) 0 10px, transparent 10px 20px); + border: 1px solid rgba(255, 255, 255, 0.06); + min-height: 120px; +} + +.pv-gallery-item--2 { background: linear-gradient(160deg, rgba(255, 255, 255, 0.07), rgba(0, 0, 0, 0.15)); } +.pv-gallery-item--3 { background: linear-gradient(200deg, rgba(233, 69, 96, 0.22), rgba(233, 69, 96, 0.04)); } + +.pv-playbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 10px; + background: var(--bg-surface); + border: 1px solid rgba(255, 255, 255, 0.06); + font-size: 12px; + color: var(--text-secondary); +} +.pv-playbar em { font-style: normal; color: var(--text); } + +.pv-play { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 18px; + border-radius: 7px; + background: var(--accent); + color: #fff; + font-weight: 700; + font-size: 12px; +} +.pv-play i { font-style: normal; } + +/* --- news scene --- */ + +.scene-news { display: flex; align-items: center; justify-content: center; } + +.pv-panel { + width: 520px; + max-width: calc(100% - 40px); + display: flex; + flex-direction: column; + gap: 12px; + padding: 24px; + background: var(--bg-surface); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 12px; +} + +.pv-view-title { font-size: 18px; font-weight: 700; margin-bottom: 4px; } + +.pv-news-item { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px; + border-radius: 8px; + background: var(--bg-inset); + border: 1px solid rgba(255, 255, 255, 0.06); +} +.pv-news-item b { font-size: 13px; } +.pv-news-item p { font-size: 11px; color: var(--text-secondary); line-height: 1.5; } + +.pv-news-tag { + width: fit-content; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + padding: 2px 8px; + border-radius: 4px; +} + +.pv-tag-update { background: rgba(233, 69, 96, 0.14); color: var(--accent); } +.pv-tag-event { background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); } + +/* --- settings scene --- */ + +.scene-settings { display: flex; align-items: center; justify-content: center; } + +.pv-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} +.pv-setting-row:last-of-type { border-bottom: none; } + +.pv-setting-label { font-size: 13px; font-weight: 600; } + +.pv-setting-control { display: flex; align-items: center; gap: 10px; } + +.pv-range { + position: relative; + width: 150px; + height: 4px; + border-radius: 999px; + background: var(--bg-card); +} + +.pv-range-fill { + position: absolute; + left: 0; top: 0; bottom: 0; + width: 30%; + border-radius: 999px; + background: var(--accent); +} + +.pv-range-knob { + position: absolute; + top: 50%; + left: 30%; + width: 13px; + height: 13px; + border-radius: 50%; + background: #fff; + transform: translate(-50%, -50%); + box-shadow: 0 0 8px rgba(233, 69, 96, 0.6); +} + +.pv-range-value { font-size: 12px; font-weight: 600; min-width: 38px; } + +.pv-chip { + min-width: 54px; + padding: 6px 10px; + text-align: center; + border-radius: 6px; + background: var(--bg-inset); + border: 1px solid rgba(255, 255, 255, 0.09); + font-size: 11px; + font-weight: 600; +} + +.pv-chip--wide { min-width: 96px; } +.pv-times { color: var(--text-muted); font-size: 11px; } + +.pv-badge-online { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 700; + font-size: 11px; + padding: 5px 10px; + border-radius: 999px; + background: rgba(74, 222, 128, 0.12); + color: #4ade80; + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.pv-badge-online i { width: 6px; height: 6px; border-radius: 50%; background: #4ade80; } + +/* --- install scene --- */ + +.scene-install { display: flex; align-items: center; justify-content: center; } + +.scene-install-inner { + width: 400px; + max-width: calc(100% - 40px); + display: flex; + flex-direction: column; + gap: 14px; + padding: 28px; + background: var(--bg-surface); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 12px; +} + +.pv-progress { height: 8px; border-radius: 999px; background: var(--bg-card); overflow: hidden; } + +.pv-progress-fill { + display: block; + width: 68%; + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--accent), var(--accent-soft)); + box-shadow: 0 0 14px rgba(233, 69, 96, 0.5); +} + +.pv-stage { text-align: center; font-size: 12px; color: var(--text-secondary); } + +/* preview dots + caption */ + +.preview-dots { display: flex; justify-content: center; gap: 8px; margin-top: 22px; } + +.preview-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); + transition: background .3s ease, width .3s ease; +} + +.preview-dot.active { background: var(--accent); width: 22px; } + +.preview-caption { + margin-top: 14px; + text-align: center; + font-size: 12px; + font-weight: 600; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-secondary); +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 1024px) { + .features-grid, .news-grid, .theme-grid { grid-template-columns: repeat(2, 1fr); } + .server-stats { grid-template-columns: repeat(3, 1fr); } +} + +@media (max-width: 720px) { + .nav-links { display: none; } + .nav-cta { display: none; } + .section { padding: 90px 20px; } + .features-grid, .news-grid, .theme-grid { grid-template-columns: 1fr; } + .server-stats { grid-template-columns: 1fr; } + .hero { padding-top: 140px; } + .hero-diamond--1 { right: -10%; } + .download-card { padding: 26px; } + .carousel-btn { width: 42px; height: 42px; font-size: 22px; } + .launcher-preview-body { height: 360px; } + .pv-sidebar { width: 58px; } + .pv-sidebar > .pv-sb-section, + .pv-sb-brand span, + .pv-nav-item span, + .pv-user-meta, + .pv-sb-action { display: none; } + .pv-sidebar { align-items: center; } + .pv-nav-item { justify-content: center; padding: 8px; } + .pv-sb-section { margin-top: 6px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } + html { scroll-behavior: auto; } +} diff --git a/server/site/highlights/1.jpg b/server/site/highlights/1.jpg new file mode 100644 index 0000000..b0b292d Binary files /dev/null and b/server/site/highlights/1.jpg differ diff --git a/server/site/highlights/10.jpg b/server/site/highlights/10.jpg new file mode 100644 index 0000000..73595c7 Binary files /dev/null and b/server/site/highlights/10.jpg differ diff --git a/server/site/highlights/11.jpg b/server/site/highlights/11.jpg new file mode 100644 index 0000000..1ca9672 Binary files /dev/null and b/server/site/highlights/11.jpg differ diff --git a/server/site/highlights/12.jpg b/server/site/highlights/12.jpg new file mode 100644 index 0000000..3aad86b Binary files /dev/null and b/server/site/highlights/12.jpg differ diff --git a/server/site/highlights/13.jpg b/server/site/highlights/13.jpg new file mode 100644 index 0000000..a3c62d6 Binary files /dev/null and b/server/site/highlights/13.jpg differ diff --git a/server/site/highlights/14.jpg b/server/site/highlights/14.jpg new file mode 100644 index 0000000..c764d98 Binary files /dev/null and b/server/site/highlights/14.jpg differ diff --git a/server/site/highlights/15.jpg b/server/site/highlights/15.jpg new file mode 100644 index 0000000..2e06f09 Binary files /dev/null and b/server/site/highlights/15.jpg differ diff --git a/server/site/highlights/2.mp4 b/server/site/highlights/2.mp4 new file mode 100644 index 0000000..b66fad2 Binary files /dev/null and b/server/site/highlights/2.mp4 differ diff --git a/server/site/highlights/3.jpg b/server/site/highlights/3.jpg new file mode 100644 index 0000000..23b1c54 Binary files /dev/null and b/server/site/highlights/3.jpg differ diff --git a/server/site/highlights/4.mp4 b/server/site/highlights/4.mp4 new file mode 100644 index 0000000..16d250f Binary files /dev/null and b/server/site/highlights/4.mp4 differ diff --git a/server/site/highlights/5.jpg b/server/site/highlights/5.jpg new file mode 100644 index 0000000..fb53fbb Binary files /dev/null and b/server/site/highlights/5.jpg differ diff --git a/server/site/highlights/6.jpg b/server/site/highlights/6.jpg new file mode 100644 index 0000000..2c78d34 Binary files /dev/null and b/server/site/highlights/6.jpg differ diff --git a/server/site/highlights/7.jpg b/server/site/highlights/7.jpg new file mode 100644 index 0000000..c565ae7 Binary files /dev/null and b/server/site/highlights/7.jpg differ diff --git a/server/site/highlights/8.jpg b/server/site/highlights/8.jpg new file mode 100644 index 0000000..38cc2a7 Binary files /dev/null and b/server/site/highlights/8.jpg differ diff --git a/server/site/highlights/9.jpg b/server/site/highlights/9.jpg new file mode 100644 index 0000000..d533f40 Binary files /dev/null and b/server/site/highlights/9.jpg differ diff --git a/server/site/img/zernmc.svg b/server/site/img/zernmc.svg new file mode 100644 index 0000000..11a1e02 --- /dev/null +++ b/server/site/img/zernmc.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/server/site/index.html b/server/site/index.html new file mode 100644 index 0000000..8867630 --- /dev/null +++ b/server/site/index.html @@ -0,0 +1,426 @@ + + + + + +Zern — Awaken from the cold + + + + + + + + + + + +
    + + + + + + + +
    +

    КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР

    +

    Очнись от
    холода.

    +

    Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.

    + +

    v · · Windows

    +
    + +
    + Листай вниз +
    +
    +
    + +
    + +
    +
    + 01 / 06 +

    Сервер жив.

    +

    ZernMC — это кастомный Minecraft-сервер со своим лором, самописными плагинами и живым комьюнити. Вот что происходит прямо сейчас.

    +
    + +
    +
    +
    Статус
    +
    Проверяем…
    +
    1.20.1 / ОБТ
    +
    +
    +
    Игроков онлайн
    +
    — / —
    +
    mc.zernmc.ru
    +
    +
    +
    Режим
    +
    Survival · Сезон Prologue
    +
    1.21.1 NeoForge · Create: Aeronautics
    +
    +
    +
    + +
    +
    + 02 / 06 +

    Хайлайты сервера.

    +

    Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.

    +
    + + +
    + +
    +
    + 03 / 06 +

    Всё, что тебе нужно.

    +

    Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.

    +
    + +
    +
    + 01 + +

    Forge, NeoForge, Fabric

    +

    Все основные модлоадеры подхватываются автоматически. Библиотеки предзагружаются и проверяются, так что установка работает — даже на медленном интернете.

    +
    + +
    + 02 + +

    Тёмный, быстрый нативный UI

    +

    Лёгкий интерфейс на JavaFX с чистым тёмным оформлением. Быстро запускается, мгновенно реагирует и не мешает.

    +
    + +
    + 03 + +

    Умные зеркала и прокси

    +

    Загрузки идут через быстрые зеркала с автоматическим фолбэком. Заблокировали или зарезали скорость? Прокси подхватит — ты больше не зависнешь на 80%.

    +
    + +
    + 04 + +

    Новости и статус сервера

    +

    Чейнджлоги, ивенты и обновления приходят прямо в лаунчер. Ты всегда знаешь, что нового, ещё до нажатия «Играть».

    +
    + +
    + 05 + +

    Установка сборки в один клик

    +

    Выбери сборку и жми «Установить». Манифесты версий, ванильные ассеты и библиотеки подтягиваются и проверяются автоматически. Просто.

    +
    +
    +
    + +
    +
    + 04 / 07 +

    Показываем лаунчер

    +

    Никаких статичных скриншотов — живой тур по интерфейсу. Нужно просто наблюдать: лаунчер сам открывает свои экраны.

    +
    + + +
    + +
    +
    + 05 / 07 +

    Что внутри сезона.

    +

    Prologue — модовый сезон на базе Create: Aeronautics, война и выживание. ОБТ — полигон, где всё это обкатывается.

    +
    + +
    +
    + ОБТ · 1.20.1 Forge +

    Война в Заре

    +

    Двухдневный ивент в городе Заря: группировка «Феникс» атакует, «Ласточка» обороняется, а жители выбирают сторону — или поднимают бунт. Военные правила.

    +
    +
    + ОБТ · тестовая стадия +

    Открытый бета-сервер

    +

    Бета — это ивентовый сервер: городские бои, сценарий атомного реактора и правила, которые лягут в основу настоящего сезона.

    +
    +
    + Сезон · 1.21.1 NeoForge +

    Корабли в небе

    +

    Create: Aeronautics даёт дирижабли, субмарины и летающие механизмы. Поезда, дизели, большие пушки и атомный реактор — осваивай.

    +
    +
    + Сезон · Prologue +

    Марафон на год

    +

    Сезон — это марафон, а не спринт. Начни эту главу с первого дня и строй её вместе с комьюнити.

    +
    +
    + Сервер +

    Свой лор и плагины

    +

    Самописные плагины, ивенты под историю сервера и системы, которые из коробки работают с лаунчером.

    +
    +
    + Комьюнити +

    Голос и друзья

    +

    Simple Voice Chat в игре, система друзей в лаунчере и Telegram-комьюнити, которое реально рулит — решения принимаются голосованием игроков.

    +
    +
    +
    + +
    +
    +
    + 06 / 07 +

    Скачать Zern.

    +

    Скачай лаунчер для Windows и заходи на сервер ZernMC.

    +
    + +
    +
    + +
    +

    Zern Launcher

    +

    Windows ·

    +
    +
    + + + Скачать актуальную версию + +
    + Зеркала +
    +
    + +

    Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.

    +
    +
    + +
    +
    + 07 / 07 +

    Что нового.

    +

    Последние чейнджлоги и обновления команды Zern.

    +
    + +
    +
    +
    +
    +
    +
    + +
    + +
    + +
    + + + + diff --git a/server/site/js/main.js b/server/site/js/main.js new file mode 100644 index 0000000..50a4cf5 --- /dev/null +++ b/server/site/js/main.js @@ -0,0 +1,675 @@ +(() => { + 'use strict'; + + const API = ''; // same origin (root -> launcher server) + + const I18N = { + ru: { + 'nav.server': 'Сервер', + 'nav.highlights': 'Хайлайты', + 'nav.season': 'Сезон', + 'nav.features': 'Возможности', + 'nav.showcase': 'Лаунчер', + 'nav.download': 'Скачать', + 'nav.news': 'Новости', + 'nav.play': 'Играть сейчас', + 'hero.kicker': 'КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР', + 'hero.title': 'Очнись от
    холода.', + 'hero.sub': 'Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.', + 'hero.download': 'Скачать лаунчер', + 'hero.explore': 'Обзор', + 'hero.scroll': 'Листай вниз', + 'server.title': 'Сервер жив.', + 'server.sub': 'ZernMC — это кастомный Minecraft-сервер со своим лором, самописными плагинами и живым комьюнити. Вот что происходит прямо сейчас.', + 'server.status': 'Статус', + 'server.checking': 'Проверяем…', + 'server.players': 'Игроков онлайн', + 'server.mode': 'Режим', + 'server.season': 'Сезон Prologue', + 'server.online': 'Онлайн', + 'server.offline': 'Оффлайн', + 'server.unavailable': 'Недоступно', + 'highlights.title': 'Хайлайты сервера.', + 'highlights.sub': 'Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.', + 'carousel.prev': 'Назад', + 'carousel.next': 'Вперёд', + 'carousel.cap1': 'Zern', + 'carousel.cap3': 'Это честно не я', + 'carousel.cap5': 'Спавн ТЦ (старое)', + 'carousel.cap6': 'Спавн склад (старое)', + 'carousel.alt': 'Хайлайт Zern', + 'features.title': 'Всё, что тебе нужно.', + 'features.sub': 'Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.', + 'features.forge.title': 'Forge, NeoForge, Fabric', + 'features.forge.sub': 'Все основные модлоадеры подхватываются автоматически. Библиотеки предзагружаются и проверяются, так что установка работает — даже на медленном интернете.', + 'features.ui.title': 'Тёмный, быстрый нативный UI', + 'features.ui.sub': 'Лёгкий интерфейс на JavaFX с чистым тёмным оформлением. Быстро запускается, мгновенно реагирует и не мешает.', + 'features.proxy.title': 'Умные зеркала и прокси', + 'features.proxy.sub': 'Загрузки идут через быстрые зеркала с автоматическим фолбэком. Заблокировали или зарезали скорость? Прокси подхватит — ты больше не зависнешь на 80%.', + 'features.news.title': 'Новости и статус сервера', + 'features.news.sub': 'Чейнджлоги, ивенты и обновления приходят прямо в лаунчер. Ты всегда знаешь, что нового, ещё до нажатия «Играть».', + 'features.pack.title': 'Установка сборки в один клик', + 'features.pack.sub': 'Выбери сборку и жми «Установить». Манифесты версий, ванильные ассеты и библиотеки подтягиваются и проверяются автоматически. Просто.', + 'theme.title': 'Что внутри сезона.', + 'theme.sub': 'Prologue — модовый сезон на базе Create: Aeronautics, война и выживание. ОБТ — полигон, где всё это обкатывается.', + 'theme.zarya.tag': 'ОБТ · 1.20.1 Forge', + 'theme.zarya.title': 'Война в Заре', + 'theme.zarya.sub': 'Двухдневный ивент в городе Заря: группировка «Феникс» атакует, «Ласточка» обороняется, а жители выбирают сторону — или поднимают бунт. Военные правила.', + 'theme.obt.tag': 'ОБТ · тестовая стадия', + 'theme.obt.title': 'Открытый бета-сервер', + 'theme.obt.sub': 'Бета — это ивентовый сервер: городские бои, сценарий атомного реактора и правила, которые лягут в основу настоящего сезона.', + 'theme.air.tag': 'Сезон · 1.21.1 NeoForge', + 'theme.air.title': 'Корабли в небе', + 'theme.air.sub': 'Create: Aeronautics даёт дирижабли, субмарины и летающие механизмы. Поезда, дизели, большие пушки и атомный реактор — осваивай.', + 'theme.year.tag': 'Сезон · Prologue', + 'theme.year.title': 'Марафон на год', + 'theme.year.sub': 'Сезон — это марафон, а не спринт. Начни эту главу с первого дня и строй её вместе с комьюнити.', + 'theme.lore.tag': 'Сервер', + 'theme.lore.title': 'Свой лор и плагины', + 'theme.lore.sub': 'Самописные плагины, ивенты под историю сервера и системы, которые из коробки работают с лаунчером.', + 'theme.community.tag': 'Комьюнити', + 'theme.community.title': 'Голос и друзья', + 'theme.community.sub': 'Simple Voice Chat в игре, система друзей в лаунчере и Telegram-комьюнити, которое реально рулит — решения принимаются голосованием игроков.', + 'download.title': 'Скачать Zern.', + 'download.sub': 'Скачай лаунчер для Windows и заходи на сервер ZernMC.', + 'download.latest': 'Скачать актуальную версию', + 'download.mirrors': 'Зеркала', + 'download.note': 'Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.', + 'showcase.title': 'Показываем лаунчер', + 'showcase.sub': 'Никаких статичных скриншотов — живой тур по интерфейсу. Нужно просто наблюдать: лаунчер сам открывает свои экраны.', + 'preview.aria': 'ZernMC Launcher preview, auto-rotating screens', + 'preview.login': 'Вход', + 'preview.packs': 'Сборки', + 'preview.news': 'Новости', + 'preview.settings': 'Настройки', + 'preview.install': 'Установка', + 'preview.signIn': 'Вход', + 'preview.username': 'Логин', + 'preview.password': 'Пароль', + 'preview.nav.packs': 'Сборки', + 'preview.nav.news': 'Новости', + 'preview.nav.friends': 'Друзья', + 'preview.nav.settings': 'Настройки', + 'preview.serverPacks': 'Серверные сборки', + 'preview.localPacks': 'Локальные сборки', + 'preview.stat.loader': 'Загрузчик', + 'preview.stat.files': 'Файлы', + 'preview.stat.size': 'Размер', + 'preview.ready': 'Готов к запуску', + 'preview.play': 'Играть', + 'preview.news.update': 'Обновление', + 'preview.news.event': 'Событие', + 'preview.news1.title': 'Сезон Prologue запущен', + 'preview.news1.body': 'Небо принадлежит дирижаблям. Добро пожаловать в Prologue — 1.21.1 NeoForge, Create: Aeronautics.', + 'preview.news2.title': 'Война в Заре', + 'preview.news2.body': '«Феникс» атакует, «Ласточка» защищает. Жители выбирают сторону — или бунт.', + 'preview.settings.ram': 'Выделено ОЗУ', + 'preview.settings.resolution': 'Разрешение', + 'preview.settings.language': 'Язык', + 'preview.settings.server': 'Сервер', + 'preview.online': 'Онлайн', + 'preview.install.title': 'Установка сборки…', + 'preview.install.stage': 'Загрузка модов 130/130 · 1.8 ГБ', + 'news.title': 'Что нового.', + 'news.sub': 'Последние чейнджлоги и обновления команды Zern.', + 'news.empty': 'Новостей пока нет.', + 'news.error': 'Новости временно недоступны.', + 'news.update': 'Обновление', + 'footer.note': 'Не аффилирован с Mojang или Microsoft. Minecraft — торговая марка Mojang AB.' + }, + en: { + 'nav.server': 'Server', + 'nav.highlights': 'Highlights', + 'nav.season': 'Season', + 'nav.features': 'Features', + 'nav.showcase': 'Launcher', + 'nav.download': 'Download', + 'nav.news': 'News', + 'nav.play': 'Play now', + 'hero.kicker': 'CUSTOM MINECRAFT LAUNCHER', + 'hero.title': 'Awaken from
    the cold.', + 'hero.sub': 'Zern brings your favourite modpacks to life. One launcher, every loader, no hassle — built for the ZernMC server and beyond.', + 'hero.download': 'Download launcher', + 'hero.explore': 'Explore', + 'hero.scroll': 'Scroll to explore', + 'server.title': 'The server is alive.', + 'server.sub': 'ZernMC is a custom Minecraft server with its own lore, hand-written plugins and a living community. This is what\'s happening right now.', + 'server.status': 'Status', + 'server.checking': 'Checking…', + 'server.players': 'Players online', + 'server.mode': 'Mode', + 'server.season': 'Season Prologue', + 'server.online': 'Online', + 'server.offline': 'Offline', + 'server.unavailable': 'Unavailable', + 'highlights.title': 'Server highlights.', + 'highlights.sub': 'Moments captured on the ZernMC server — wars, builds and chaos.', + 'carousel.prev': 'Previous', + 'carousel.next': 'Next', + 'carousel.cap1': 'Zern', + 'carousel.cap3': 'This is honestly not me', + 'carousel.cap5': 'Spawn mall (old)', + 'carousel.cap6': 'Spawn storage (old)', + 'carousel.alt': 'Zern highlight', + 'features.title': 'Everything you need.', + 'features.sub': 'A launcher that does the heavy lifting, so you can just play.', + 'features.forge.title': 'Forge, NeoForge, Fabric', + 'features.forge.sub': 'Every major mod loader handled automatically. Libraries are prefetched and verified, so installs just work — even on slow networks.', + 'features.ui.title': 'Dark, fast, native UI', + 'features.ui.sub': 'A lightweight JavaFX interface with a clean dark theme. Starts quick, responds instantly, and stays out of your way.', + 'features.proxy.title': 'Smart mirrors & proxy', + 'features.proxy.sub': 'Downloads route through fast mirrors with automatic failover. Blocked or throttled? The proxy takes over so you never stall at 80%.', + 'features.news.title': 'News & server status', + 'features.news.sub': 'Changelogs, events and updates stream straight into the launcher. You always know what\'s new before you hit play.', + 'features.pack.title': 'One-click pack install', + 'features.pack.sub': 'Pick a pack, hit install. Version manifests, vanilla assets and libraries are pulled and verified automatically. Simple.', + 'theme.title': 'What\'s inside the season.', + 'theme.sub': 'Prologue — a modded season built around Create: Aeronautics, war and survival. OBT is the testing ground for the whole idea.', + 'theme.zarya.tag': 'OBT · 1.20.1 Forge', + 'theme.zarya.title': 'War in Zarya', + 'theme.zarya.sub': 'Two-day event set in the city of Zarya: the Fenix group attacks, Lastochka defends, and civilians pick a side — or rise up. Military rules apply.', + 'theme.obt.tag': 'OBT · test stage', + 'theme.obt.title': 'Open beta server', + 'theme.obt.sub': 'The beta is an event server: city battles, an atomic-reactor city-life scenario and rule sets that will shape the real season.', + 'theme.air.tag': 'Season · 1.21.1 NeoForge', + 'theme.air.title': 'Ships in the sky', + 'theme.air.sub': 'Create: Aeronautics gives you airships, submarines and flying contraptions. Trains, diesel engines, big cannons and a nuclear reactor to master.', + 'theme.year.tag': 'Season · Prologue', + 'theme.year.title': 'A year-long run', + 'theme.year.sub': 'Each season is a marathon, not a sprint. Start this chapter from day one and shape it with the community.', + 'theme.lore.tag': 'Server', + 'theme.lore.title': 'Own lore & plugins', + 'theme.lore.sub': 'Hand-written plugins, events written for the server story, and systems the launcher integrates with out of the box.', + 'theme.community.tag': 'Community', + 'theme.community.title': 'Voice & friends', + 'theme.community.sub': 'Simple Voice Chat in-game, a friends system in the launcher and a Telegram community that runs the show — decisions are voted on by players.', + 'download.title': 'Get Zern.', + 'download.sub': 'Download the launcher for Windows and join the ZernMC server.', + 'download.latest': 'Download latest', + 'download.mirrors': 'Mirrors', + 'download.note': 'No paywalls, no ads. Just a launcher. System requirements: Windows 10+, 4 GB RAM.', + 'showcase.title': 'Meet the launcher', + 'showcase.sub': 'No static screenshots — a live tour of the UI. Just watch: the launcher shows its screens on its own.', + 'preview.aria': 'ZernMC Launcher preview, auto-rotating screens', + 'preview.login': 'Sign in', + 'preview.packs': 'Packs', + 'preview.news': 'News', + 'preview.settings': 'Settings', + 'preview.install': 'Installing', + 'preview.signIn': 'Sign In', + 'preview.username': 'Username', + 'preview.password': 'Password', + 'preview.nav.packs': 'Packs', + 'preview.nav.news': 'News', + 'preview.nav.friends': 'Friends', + 'preview.nav.settings': 'Settings', + 'preview.serverPacks': 'Server Packs', + 'preview.localPacks': 'Local Packs', + 'preview.stat.loader': 'Loader', + 'preview.stat.files': 'Files', + 'preview.stat.size': 'Size', + 'preview.ready': 'Ready to launch', + 'preview.play': 'Play', + 'preview.news.update': 'Update', + 'preview.news.event': 'Event', + 'preview.news1.title': 'Season Prologue has launched', + 'preview.news1.body': 'The sky belongs to airships. Welcome to Prologue — 1.21.1 NeoForge, Create: Aeronautics.', + 'preview.news2.title': 'War in Zarya', + 'preview.news2.body': 'Fenix attacks, Lastochka defends. Citizens pick a side — or rise up.', + 'preview.settings.ram': 'Allocated RAM', + 'preview.settings.resolution': 'Resolution', + 'preview.settings.language': 'Language', + 'preview.settings.server': 'Server', + 'preview.online': 'Online', + 'preview.install.title': 'Installing pack…', + 'preview.install.stage': 'Downloading mods 130/130 · 1.8 GB', + 'news.title': 'What\'s new.', + 'news.sub': 'Latest changelogs and updates from the Zern team.', + 'news.empty': 'No news yet.', + 'news.error': 'News is temporarily unavailable.', + 'news.update': 'Update', + 'footer.note': 'Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.' + } + }; + + const STORAGE_KEY = 'zern_lang'; + + function getLang() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved === 'ru' || saved === 'en') return saved; + } catch (e) { /* ignore */ } + return 'ru'; // default: Russian + } + + let currentLang = getLang(); + + function tr(key) { + const dict = I18N[currentLang] || I18N.ru; + return dict[key] != null ? dict[key] : key; + } + + function applyI18n() { + document.documentElement.lang = currentLang; + + const toggle = document.getElementById('langToggleLabel'); + if (toggle) toggle.textContent = currentLang === 'ru' ? 'EN' : 'RU'; + + document.querySelectorAll('[data-i18n]').forEach((el) => { + el.textContent = tr(el.dataset.i18n); + }); + document.querySelectorAll('[data-i18n-html]').forEach((el) => { + el.innerHTML = tr(el.dataset.i18nHtml); + }); + document.querySelectorAll('[data-i18n-aria]').forEach((el) => { + el.setAttribute('aria-label', tr(el.dataset.i18nAria)); + }); + + // Carousel captions & alts are created dynamically with tr() at init + document.querySelectorAll('.carousel-caption[data-caption-key]').forEach((el) => { + el.textContent = tr(el.dataset.captionKey); + }); + document.querySelectorAll('.carousel-slide img').forEach((img) => { + const slide = img.closest('.carousel-slide'); + const cap = slide && slide.querySelector('.carousel-caption[data-caption-key]'); + if (cap) img.alt = tr(cap.dataset.captionKey); + else img.alt = tr('carousel.alt'); + }); + + // Live-update dynamic strings + const statusValue = document.getElementById('srv-status'); + if (statusValue && statusValue.dataset.state) { + updateStatusUI(statusValue.dataset.state); + } + const caption = document.getElementById('previewCaption'); + if (caption && caption.dataset.screen) { + const scene = SCENES.find((s) => s.key === caption.dataset.screen); + caption.textContent = scene ? tr(scene.label) : ''; + } + } + + function toggleLang() { + currentLang = currentLang === 'ru' ? 'en' : 'ru'; + try { localStorage.setItem(STORAGE_KEY, currentLang); } catch (e) { /* ignore */ } + applyI18n(); + } + + function fmtSize(bytes) { + if (!bytes) return '—'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + let n = bytes; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return n.toFixed(1) + ' ' + units[i]; + } + + async function loadLauncherInfo() { + try { + const res = await fetch(API + '/launcher/info'); + if (!res.ok) return; + const data = await res.json(); + const version = data.current_version || '—'; + + const heroVersion = document.getElementById('hero-version'); + const heroSize = document.getElementById('hero-size'); + const dlVersion = document.getElementById('download-version'); + const dlSize = document.getElementById('download-size'); + const dlBtn = document.getElementById('download-btn'); + const pvVersion = document.getElementById('preview-version'); + + if (heroVersion) heroVersion.textContent = version; + if (dlVersion) dlVersion.textContent = version; + if (pvVersion) pvVersion.textContent = 'v' + version; + + if (data.files && data.files.zips && data.files.zips.length) { + const latest = data.files.zips[0]; + if (heroSize) heroSize.textContent = fmtSize(latest.size); + if (dlSize) dlSize.textContent = fmtSize(latest.size); + } + + if (dlBtn && data.new_format && data.new_format.download_url) { + dlBtn.href = API + data.new_format.download_url; + } + } catch (e) { + console.warn('[site] failed to load launcher info', e); + } + } + + async function loadMirrors() { + try { + const res = await fetch(API + '/launcher/mirrors'); + if (!res.ok) return; + const data = await res.json(); + const container = document.getElementById('mirrors'); + if (!container || !data.mirrors || !data.mirrors.length) return; + + const tags = data.mirrors.slice(0, 5).map((m) => { + const a = document.createElement('a'); + a.className = 'mirror-tag'; + a.href = (m.url || '') + '/launcher/download/latest'; + a.textContent = m.name || m; + return a; + }); + tags.forEach((t) => container.appendChild(t)); + } catch (e) { + console.warn('[site] failed to load mirrors', e); + } + } + + async function loadServerStatus() { + const statusEl = document.getElementById('srv-status'); + const playersEl = document.getElementById('srv-players'); + const versionEl = document.getElementById('srv-version'); + if (!statusEl) return; + + try { + const res = await fetch(API + '/api/status'); + const data = await res.json(); + + if (data.online) { + statusEl.dataset.state = 'online'; + if (playersEl) playersEl.textContent = `${data.players_online} / ${data.players_max}`; + if (versionEl && data.version) versionEl.textContent = data.version; + } else { + statusEl.dataset.state = 'offline'; + if (playersEl) playersEl.textContent = '— / —'; + } + } catch (e) { + statusEl.dataset.state = 'unavailable'; + if (playersEl) playersEl.textContent = '— / —'; + } + updateStatusUI(statusEl.dataset.state); + } + + function updateStatusUI(state) { + const statusEl = document.getElementById('srv-status'); + const dot = document.querySelector('#srv-status .status-dot'); + if (!statusEl) return; + + if (state === 'online') { + if (dot) dot.className = 'status-dot status-dot--online'; + statusEl.innerHTML = '' + tr('server.online') + ''; + } else if (state === 'offline') { + if (dot) dot.className = 'status-dot status-dot--offline'; + statusEl.innerHTML = '' + tr('server.offline') + ''; + } else { + if (dot) dot.className = 'status-dot status-dot--offline'; + statusEl.innerHTML = '' + tr('server.unavailable') + ''; + } + } + + const HIGHLIGHTS = [ + { file: '1.jpg', type: 'image', captionKey: 'carousel.cap1' }, + { file: '2.mp4', type: 'video', captionKey: null }, + { file: '3.jpg', type: 'image', captionKey: 'carousel.cap3' }, + { file: '4.mp4', type: 'video', captionKey: null }, + { file: '5.jpg', type: 'image', captionKey: 'carousel.cap5' }, + { file: '6.jpg', type: 'image', captionKey: 'carousel.cap6' }, + { file: '7.jpg', type: 'image', captionKey: null }, + { file: '8.jpg', type: 'image', captionKey: null }, + { file: '9.jpg', type: 'image', captionKey: null }, + { file: '10.jpg', type: 'image', captionKey: null }, + { file: '11.jpg', type: 'image', captionKey: null }, + { file: '12.jpg', type: 'image', captionKey: null }, + { file: '13.jpg', type: 'image', captionKey: null }, + { file: '14.jpg', type: 'image', captionKey: null }, + { file: '15.jpg', type: 'image', captionKey: null }, + ]; + + function initCarousel() { + const track = document.getElementById('carousel'); + const dotsWrap = document.getElementById('carouselDots'); + const prev = document.getElementById('carouselPrev'); + const next = document.getElementById('carouselNext'); + if (!track) return; + + HIGHLIGHTS.forEach((h, i) => { + const slide = document.createElement('div'); + slide.className = 'carousel-slide'; + + if (h.type === 'video') { + const vid = document.createElement('video'); + vid.src = '/highlights/' + h.file; + vid.controls = true; + vid.autoplay = true; + vid.loop = true; + vid.muted = true; + vid.playsInline = true; + slide.appendChild(vid); + } else { + const img = document.createElement('img'); + img.src = '/highlights/' + h.file; + img.alt = h.captionKey ? tr(h.captionKey) : tr('carousel.alt'); + img.loading = 'lazy'; + slide.appendChild(img); + } + + if (h.captionKey) { + const cap = document.createElement('span'); + cap.className = 'carousel-caption'; + cap.dataset.captionKey = h.captionKey; + cap.textContent = tr(h.captionKey); + slide.appendChild(cap); + } + + track.appendChild(slide); + + const dot = document.createElement('span'); + dot.className = 'carousel-dot' + (i === 0 ? ' active' : ''); + dot.addEventListener('click', () => goto(i)); + dotsWrap.appendChild(dot); + }); + + let index = 0; + let timer = null; + + function goto(i) { + index = (i + HIGHLIGHTS.length) % HIGHLIGHTS.length; + track.style.transform = 'translateX(-' + index * 100 + '%)'; + dotsWrap.querySelectorAll('.carousel-dot').forEach((d, k) => { + d.classList.toggle('active', k === index); + }); + } + + function nextSlide() { goto(index + 1); } + function prevSlide() { goto(index - 1); } + + function start() { + stop(); + timer = setInterval(nextSlide, 6500); + } + function stop() { + if (timer) { clearInterval(timer); timer = null; } + } + + if (prev) prev.addEventListener('click', () => { prevSlide(); start(); }); + if (next) next.addEventListener('click', () => { nextSlide(); start(); }); + track.addEventListener('mouseenter', stop); + track.addEventListener('mouseleave', start); + + start(); + } + + function stripMarkup(text) { + if (!text) return ''; + return text + .replace(/\[[^\]]*photo=[^\]]*\]/g, '') + .replace(/\[[^\]]*\]/g, '') + .replace(/\*\*\*/g, '') + .replace(/\*\*/g, '') + .replace(/[*_~`#]/g, '') + .replace(/\n+/g, ' ') + .trim(); + } + + async function loadNews() { + const grid = document.getElementById('news-grid'); + if (!grid) return; + + try { + const res = await fetch(API + '/news'); + if (!res.ok) throw new Error('news fetch failed'); + const data = await res.json(); + const news = (data.news || []).slice(0, 6); + if (!news.length) { grid.innerHTML = '

    ' + tr('news.empty') + '

    '; return; } + + grid.innerHTML = ''; + news.forEach((n) => { + const card = document.createElement('article'); + card.className = 'news-card'; + + const tags = document.createElement('div'); + tags.className = 'news-tags'; + if (n.type) { + const t = document.createElement('span'); + t.className = 'news-tag'; + t.textContent = n.type; + tags.appendChild(t); + } + if (n.version) { + const v = document.createElement('span'); + v.className = 'news-tag news-tag--version'; + v.textContent = n.version; + tags.appendChild(v); + } + + const title = document.createElement('h3'); + title.textContent = n.title || tr('news.update'); + + const body = document.createElement('p'); + const text = stripMarkup(n.body); + body.textContent = text.length > 220 ? text.slice(0, 220) + '…' : text; + + card.appendChild(tags); + card.appendChild(title); + card.appendChild(body); + grid.appendChild(card); + }); + } catch (e) { + console.warn('[site] failed to load news', e); + grid.innerHTML = '

    ' + tr('news.error') + '

    '; + } + } + + function initNav() { + const nav = document.getElementById('nav'); + const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 24); + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + } + + function initParallax() { + const els = document.querySelectorAll('[data-parallax]'); + if (!els.length) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const onScroll = () => { + const y = window.scrollY; + if (y > window.innerHeight) return; + els.forEach((el) => { + const speed = parseFloat(el.dataset.parallax); + el.style.transform = 'translateY(' + (y * speed) + 'px)'; + }); + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + } + + const SCENES = [ + { key: 'login', label: 'preview.login' }, + { key: 'packs', label: 'preview.packs' }, + { key: 'news', label: 'preview.news' }, + { key: 'settings', label: 'preview.settings' }, + { key: 'install', label: 'preview.install' }, + ]; + + let sceneIndex = 0; + let sceneTimer = null; + + function showScene(i) { + const body = document.getElementById('launcherPreviewBody'); + const dots = document.getElementById('previewDots'); + const caption = document.getElementById('previewCaption'); + if (!body) return; + + sceneIndex = (i + SCENES.length) % SCENES.length; + body.querySelectorAll('.preview-scene').forEach((el, k) => { + el.classList.toggle('is-active', k === sceneIndex); + }); + if (dots) { + dots.querySelectorAll('.preview-dot').forEach((d, k) => { + d.classList.toggle('active', k === sceneIndex); + }); + } + if (caption) { + caption.textContent = tr(SCENES[sceneIndex].label); + caption.dataset.screen = SCENES[sceneIndex].key; + } + } + + function nextScene() { showScene(sceneIndex + 1); } + + function startPreview() { + if (!document.getElementById('launcherPreviewBody')) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + if (sceneTimer) clearInterval(sceneTimer); + sceneTimer = setInterval(nextScene, 4200); + showScene(0); + } + + function initPreview() { + const body = document.getElementById('launcherPreviewBody'); + const dots = document.getElementById('previewDots'); + if (!body || !dots) return; + + SCENES.forEach((_, k) => { + const d = document.createElement('span'); + d.className = 'preview-dot' + (k === 0 ? ' active' : ''); + dots.appendChild(d); + }); + + startPreview(); + } + + function initReveal() { + const els = document.querySelectorAll('.section-head, .feature-card, .download-card, .news-card, .server-card, .theme-card, .carousel, .launcher-window'); + if (!('IntersectionObserver' in window)) { + els.forEach((el) => el.classList.add('visible')); + return; + } + const io = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + io.unobserve(entry.target); + } + }); + }, { threshold: 0.12 }); + els.forEach((el) => { el.classList.add('reveal'); io.observe(el); }); + } + + function initYear() { + const el = document.getElementById('year'); + if (el) el.textContent = new Date().getFullYear(); + } + + document.addEventListener('DOMContentLoaded', () => { + applyI18n(); + + const toggle = document.getElementById('langToggle'); + if (toggle) toggle.addEventListener('click', toggleLang); + + loadLauncherInfo(); + loadMirrors(); + loadNews(); + loadServerStatus(); + setInterval(loadServerStatus, 15000); + initCarousel(); + initPreview(); + initNav(); + initParallax(); + initReveal(); + initYear(); + }); +})(); diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..5c6af63 --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,125 @@ +import os +import sys +import pytest +import tempfile +import shutil +from pathlib import Path + + +def auth_headers(token): + """Create Authorization headers.""" + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture(scope="session") +def test_db_dir(): + """Temporary directory for test databases.""" + d = tempfile.mkdtemp(prefix="zern_test_") + yield Path(d) + shutil.rmtree(d, ignore_errors=True) + + +@pytest.fixture(scope="session") +def test_app(test_db_dir): + """Create FastAPI app with test database.""" + # Patch auth module paths BEFORE importing anything + import auth + auth.AUTH_DB = test_db_dir / "auth.db" + auth.SECRET_KEY = test_db_dir / ".secret_key" + auth._rate_limit_cache.clear() + + # Initialize test database + auth.init_db() + + from main import app + return app + + +@pytest.fixture +def client(test_app): + """TestClient instance.""" + from fastapi.testclient import TestClient + return TestClient(test_app) + + +@pytest.fixture +def registered_user(client): + """Register a unique test user.""" + import secrets + username = f"testuser_{secrets.token_hex(4)}" + password = "TestPassword123" + + resp = client.post("/auth/register", json={"username": username, "password": password}) + assert resp.status_code == 200, f"Registration failed: {resp.text}" + return {"username": username, "password": password} + + +@pytest.fixture +def logged_in_user(client, registered_user): + """Login and return tokens.""" + resp = client.post("/auth/login", json=registered_user) + assert resp.status_code == 200, f"Login failed: {resp.text}" + data = resp.json() + return { + "username": registered_user["username"], + "password": registered_user["password"], + "access_token": data["access_token"], + "refresh_token": data["refresh_token"], + "expires_in": data["expires_in"], + "uuid": data["uuid"], + "role": data["role"], + } + + +@pytest.fixture +def logged_in_user_with_pass(client, registered_user): + """Login user and give them role 1 (pass holder).""" + # Promote to pass holder + import sqlite3 + import auth + conn = sqlite3.connect(str(auth.AUTH_DB)) + conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],)) + conn.commit() + conn.close() + + resp = client.post("/auth/login", json=registered_user) + assert resp.status_code == 200, f"Login failed: {resp.text}" + data = resp.json() + return { + "username": registered_user["username"], + "password": registered_user["password"], + "access_token": data["access_token"], + "refresh_token": data["refresh_token"], + "expires_in": data["expires_in"], + "uuid": data["uuid"], + "role": data["role"], + } + + +@pytest.fixture +def admin_user(client): + """Create and login a creator/admin user.""" + import secrets + import sqlite3 + import auth + + username = f"admin_{secrets.token_hex(4)}" + password = "AdminPassword123" + + resp = client.post("/auth/register", json={"username": username, "password": password}) + assert resp.status_code == 200 + + # Promote to creator + conn = sqlite3.connect(str(auth.AUTH_DB)) + conn.execute("UPDATE users SET role = 4 WHERE username = ?", (username,)) + conn.commit() + conn.close() + + resp = client.post("/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200 + data = resp.json() + return { + "username": username, + "access_token": data["access_token"], + "refresh_token": data["refresh_token"], + } diff --git a/server/tests/test_admin.py b/server/tests/test_admin.py new file mode 100644 index 0000000..708655f --- /dev/null +++ b/server/tests/test_admin.py @@ -0,0 +1,128 @@ +"""Tests for admin endpoints.""" +import pytest +import sqlite3 +import time +from tests.conftest import auth_headers +from auth import AUTH_DB + + +class TestAdminMe: + """Test /admin/me endpoint.""" + + def test_admin_me_success(self, client, logged_in_user): + resp = client.get("/admin/me", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert "id" in data + assert "username" in data + assert "uuid" in data + assert "role" in data + assert "role_name" in data + assert "has_pass" in data + assert "permissions" in data + + def test_admin_me_no_auth(self, client): + resp = client.get("/admin/me") + assert resp.status_code in (401, 403) # Either is acceptable + + +class TestAdminUsersList: + """Test /admin/users endpoint.""" + + def test_admin_users_list(self, client, admin_user): + resp = client.get("/admin/users", headers=auth_headers(admin_user["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert "users" in data + assert isinstance(data["users"], list) + assert len(data["users"]) >= 1 # At least the admin user + + def test_admin_users_list_no_admin(self, client, logged_in_user): + """Regular user should not access admin endpoints.""" + resp = client.get("/admin/users", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code in (401, 403) + + def test_admin_users_list_no_auth(self, client): + resp = client.get("/admin/users") + assert resp.status_code in (401, 403) + + +class TestAdminBan: + """Test ban functionality via admin endpoints.""" + + def test_ban_user(self, client, logged_in_user, admin_user): + """Admin bans a user.""" + # Get user ID first + import sqlite3 + from auth import AUTH_DB + conn = sqlite3.connect(str(AUTH_DB)) + row = conn.execute("SELECT id FROM users WHERE username = ?", + (logged_in_user["username"],)).fetchone() + conn.close() + assert row is not None + + resp = client.post("/admin/user/ban", json={ + "user_id": row[0], + "days": 1, + "reason": "Test ban" + }, headers=auth_headers(admin_user["access_token"])) + assert resp.status_code == 200 + + # Verify ban in DB + conn = sqlite3.connect(str(AUTH_DB)) + row = conn.execute("SELECT banned_until FROM users WHERE username = ?", + (logged_in_user["username"],)).fetchone() + conn.close() + assert row is not None + assert row[0] is not None + assert row[0] > time.time() + + def test_ban_nonexistent_user(self, client, admin_user): + resp = client.post("/admin/user/ban", json={ + "user_id": 99999, + "days": 1, + "reason": "Test ban" + }, headers=auth_headers(admin_user["access_token"])) + assert resp.status_code == 404 + + +class TestAdminRole: + """Test role change functionality.""" + + def test_change_role(self, client, logged_in_user, admin_user): + # Get user ID + import sqlite3 + from auth import AUTH_DB + conn = sqlite3.connect(str(AUTH_DB)) + row = conn.execute("SELECT id FROM users WHERE username = ?", + (logged_in_user["username"],)).fetchone() + conn.close() + assert row is not None + + resp = client.put(f"/admin/users/{row[0]}/role", json={ + "user_id": row[0], + "role": 2 # MODERATOR + }, headers=auth_headers(admin_user["access_token"])) + assert resp.status_code == 200 + + # Verify in DB + conn = sqlite3.connect(str(AUTH_DB)) + row = conn.execute("SELECT role FROM users WHERE username = ?", + (logged_in_user["username"],)).fetchone() + conn.close() + assert row[0] == 2 + + def test_change_role_invalid(self, client, logged_in_user, admin_user): + import sqlite3 + from auth import AUTH_DB + conn = sqlite3.connect(str(AUTH_DB)) + row = conn.execute("SELECT id FROM users WHERE username = ?", + (logged_in_user["username"],)).fetchone() + conn.close() + assert row is not None + + resp = client.put(f"/admin/users/{row[0]}/role", json={ + "user_id": row[0], + "role": 99 + }, headers=auth_headers(admin_user["access_token"])) + assert resp.status_code in (400, 422) diff --git a/server/tests/test_auth.py b/server/tests/test_auth.py new file mode 100644 index 0000000..291ed2c --- /dev/null +++ b/server/tests/test_auth.py @@ -0,0 +1,187 @@ +"""Tests for auth flow: register, login, refresh, validate, logout.""" +import pytest +from tests.conftest import auth_headers + + +class TestRegister: + """Test /auth/register endpoint.""" + + def test_register_success(self, client): + resp = client.post("/auth/register", json={ + "username": "newuser", + "password": "SecurePass123" + }) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert "refresh_token" in data + assert "uuid" in data + assert "expires_in" in data + assert "role" in data + assert data["username"] == "newuser" + + def test_register_duplicate(self, client, registered_user): + resp = client.post("/auth/register", json={ + "username": registered_user["username"], + "password": "AnotherPass123" + }) + assert resp.status_code == 409 + + def test_register_short_username(self, client): + resp = client.post("/auth/register", json={ + "username": "ab", + "password": "SecurePass123" + }) + assert resp.status_code == 422 + + def test_register_short_password(self, client): + resp = client.post("/auth/register", json={ + "username": "validuser", + "password": "short" + }) + assert resp.status_code == 422 + + def test_register_invalid_username(self, client): + resp = client.post("/auth/register", json={ + "username": "user name!", + "password": "SecurePass123" + }) + assert resp.status_code == 422 + + +class TestLogin: + """Test /auth/login endpoint.""" + + def test_login_success(self, client, registered_user): + resp = client.post("/auth/login", json=registered_user) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert "refresh_token" in data + assert "uuid" in data + assert data["username"] == registered_user["username"] + + def test_login_wrong_password(self, client, registered_user): + resp = client.post("/auth/login", json={ + "username": registered_user["username"], + "password": "WrongPassword" + }) + assert resp.status_code == 401 + + def test_login_nonexistent_user(self, client): + resp = client.post("/auth/login", json={ + "username": "ghost", + "password": "SomePass123" + }) + assert resp.status_code == 401 + + def test_login_returns_role(self, client, registered_user): + resp = client.post("/auth/login", json=registered_user) + assert resp.status_code == 200 + data = resp.json() + assert "role" in data + assert data["role"] == 0 # ROLE_USER + + +class TestRefresh: + """Test /auth/refresh endpoint.""" + + def test_refresh_success(self, client, logged_in_user): + resp = client.post("/auth/refresh", json={ + "refresh_token": logged_in_user["refresh_token"] + }) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["username"] == logged_in_user["username"] + + def test_refresh_invalid_token(self, client): + resp = client.post("/auth/refresh", json={ + "refresh_token": "invalid.token.here" + }) + assert resp.status_code == 401 + + def test_refresh_reuses_token_fails(self, client, logged_in_user): + """Refresh token should be invalidated after use.""" + # First refresh + resp = client.post("/auth/refresh", json={ + "refresh_token": logged_in_user["refresh_token"] + }) + assert resp.status_code == 200 + new_token = resp.json()["refresh_token"] + + # Try with old token + resp = client.post("/auth/refresh", json={ + "refresh_token": logged_in_user["refresh_token"] + }) + assert resp.status_code == 401 + + +class TestValidate: + """Test /auth/validate endpoint.""" + + def test_validate_valid_token(self, client, logged_in_user): + resp = client.post("/auth/validate", json={ + "username": logged_in_user["username"], + "uuid": logged_in_user["uuid"] + }, headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert data["valid"] is True + assert data["username"] == logged_in_user["username"] + assert "uuid" in data + + def test_validate_invalid_token(self, client): + resp = client.post("/auth/validate", json={ + "username": "test", + "uuid": "test" + }, headers=auth_headers("invalid.token.here")) + assert resp.status_code == 401 # Invalid token returns 401 + + def test_validate_no_token(self, client): + resp = client.post("/auth/validate", json={ + "username": "test", + "uuid": "test" + }) + assert resp.status_code in (401, 403) + + def test_validate_banned_user(self, client, logged_in_user, admin_user): + """Banned user should get valid=false.""" + # Ban the user + import sqlite3 + from auth import AUTH_DB + conn = sqlite3.connect(str(AUTH_DB)) + import time + conn.execute("UPDATE users SET banned_until = ? WHERE username = ?", + (time.time() + 3600, logged_in_user["username"])) + conn.commit() + conn.close() + + resp = client.post("/auth/validate", json={ + "username": logged_in_user["username"], + "uuid": logged_in_user["uuid"] + }, headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert data["valid"] is False + assert "banned" in data["reason"].lower() + + +class TestLogout: + """Test /auth/logout endpoint.""" + + def test_logout_success(self, client, logged_in_user): + resp = client.post("/auth/logout", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 200 + + # Refresh should fail after logout + resp = client.post("/auth/refresh", json={ + "refresh_token": logged_in_user["refresh_token"] + }) + assert resp.status_code == 401 + + def test_logout_invalid_token(self, client): + resp = client.post("/auth/logout", headers=auth_headers("invalid.token.here")) + assert resp.status_code == 401 + assert resp.status_code == 401 diff --git a/server/tests/test_client.py b/server/tests/test_client.py new file mode 100644 index 0000000..d0f6866 --- /dev/null +++ b/server/tests/test_client.py @@ -0,0 +1,391 @@ +"""Tests for client-facing endpoints — verifying server responses match what the Java launcher expects. + +This tests the full client-server contract: +- AuthManager.java: login, register, refresh, logout, /admin/me for UserInfo +- PackDownloader.java: /packs, /pack/{name}, /pack/{name}/diff, /pack/{name}/file/{path} +- ZHttpClient.java: /launcher/version, /proxy/* +- ServerPack.java: pack list fields +- PackManifest inner class: manifest fields +- DiffResponse inner class: diff fields +- FileInfo inner class: file info fields +""" +import asyncio +import hashlib +import json +import secrets +import sqlite3 +import time +from pathlib import Path + +import pytest + +from tests.conftest import auth_headers +import auth +from pack_manager import scan_pack, PACKS_DIR + + +def scan_pack_sync(pack_name): + """Run scan_pack synchronously.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(scan_pack(pack_name)) + finally: + loop.close() + + +@pytest.fixture +def pack_fixture(tmp_path, logged_in_user): + """Create a test pack with a mod file and scan it.""" + pack_name = f"testpack_{secrets.token_hex(4)}" + pack_dir = PACKS_DIR / pack_name + pack_dir.mkdir(parents=True, exist_ok=True) + + mod_dir = pack_dir / "mods" + mod_dir.mkdir() + mod_content = b"fake mod content for testing" + mod_file = mod_dir / "test-mod.jar" + mod_file.write_bytes(mod_content) + + # Scan to generate .meta + meta = scan_pack_sync(pack_name) + + yield { + "name": pack_name, + "dir": pack_dir, + "mod_content": mod_content, + "mod_path": "mods/test-mod.jar", + "mod_hash": hashlib.sha256(mod_content).hexdigest(), + "meta": meta, + } + + # Cleanup + import shutil + shutil.rmtree(pack_dir, ignore_errors=True) + meta_path = Path("data") / f"{pack_name}.meta" + if meta_path.exists(): + meta_path.unlink() + + +class TestAuthFlowClient: + """Test auth flow exactly as Java AuthManager.java does it.""" + + def test_full_auth_lifecycle(self, client): + """Register → Login → Refresh → Logout, matching Java client behavior.""" + username = f"lifecycle_{secrets.token_hex(4)}" + password = "LifeCyclePass123" + + # 1. Register (AuthManager.authRequest) + resp = client.post("/auth/register", json={"username": username, "password": password}) + assert resp.status_code == 200 + reg = resp.json() + assert reg["access_token"] + assert reg["refresh_token"] + assert isinstance(reg["expires_in"], int) + assert reg["uuid"] + assert reg["username"] == username + assert isinstance(reg["role"], int) + + # 2. Login (AuthManager.authRequest) + resp = client.post("/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200 + login = resp.json() + assert login["access_token"] + assert login["refresh_token"] + assert isinstance(login["expires_in"], int) + assert login["uuid"] + assert login["username"] == username + assert isinstance(login["role"], int) + + access_token = login["access_token"] + refresh_token = login["refresh_token"] + + # 3. Refresh (AuthManager.tryRefresh) + resp = client.post("/auth/refresh", json={"refresh_token": refresh_token}) + assert resp.status_code == 200 + refresh = resp.json() + assert refresh["access_token"] + assert refresh["refresh_token"] + assert isinstance(refresh["expires_in"], int) + assert refresh["username"] == username + assert refresh["uuid"] + assert isinstance(refresh["role"], int) + + # 4. Validate token (used by Minecraft server auth) + resp = client.post("/auth/validate", json={ + "username": username, + "uuid": refresh["uuid"] + }, headers=auth_headers(refresh["access_token"])) + assert resp.status_code == 200 + validate = resp.json() + assert validate["valid"] is True + assert validate["username"] == username + + # 5. /admin/me (AuthManager.fetchUserInfo) + resp = client.get("/admin/me", headers=auth_headers(refresh["access_token"])) + assert resp.status_code == 200 + me = resp.json() + assert isinstance(me["id"], int) + assert me["username"] == username + assert me["uuid"] + assert isinstance(me["role"], int) + assert isinstance(me["role_name"], str) + assert isinstance(me["has_pass"], bool) + assert isinstance(me["permissions"], list) + + # 6. Logout + resp = client.post("/auth/logout", headers=auth_headers(refresh["access_token"])) + assert resp.status_code == 200 + + # 7. Refresh should fail after logout + resp = client.post("/auth/refresh", json={"refresh_token": refresh["refresh_token"]}) + assert resp.status_code == 401 + + +class TestPacksClientContract: + """Test /packs response matches Java ServerPack.java parsing.""" + + def test_packs_empty_list(self, client, logged_in_user_with_pass): + """Client parses {"packs": [...]} — empty list should work.""" + resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert "packs" in data + assert isinstance(data["packs"], list) + + def test_packs_with_pack(self, client, logged_in_user_with_pass, pack_fixture): + """Full pack with all fields that ServerPack.java expects.""" + resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert len(data["packs"]) >= 1 + + # Find our pack + pack = next((p for p in data["packs"] if p["name"] == pack_fixture["name"]), None) + assert pack is not None + + # ServerPack.java fields + assert "name" in pack + assert "version" in pack + assert isinstance(pack["version"], int) + assert "minecraft_version" in pack + assert isinstance(pack["minecraft_version"], str) + assert "loader_type" in pack + assert isinstance(pack["loader_type"], str) + assert "loader_version" in pack + assert pack["loader_version"] is None or isinstance(pack["loader_version"], str) + assert "files_count" in pack + assert isinstance(pack["files_count"], int) + assert "updated_at" in pack + + +class TestPackManifestClientContract: + """Test /pack/{name} response matches Java PackDownloader.PackManifest.""" + + def test_pack_manifest_not_found(self, client, logged_in_user): + resp = client.get("/pack/nonexistent", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 404 + + def test_pack_manifest_fields(self, client, logged_in_user_with_pass, pack_fixture): + """All fields that PackManifest.java expects.""" + pack_name = pack_fixture["name"] + + resp = client.get(f"/pack/{pack_name}", headers=auth_headers(logged_in_user_with_pass["access_token"])) + assert resp.status_code == 200 + data = resp.json() + + # PackManifest.java fields + assert "pack_name" in data + assert "version" in data + assert isinstance(data["version"], int) + assert "minecraft_version" in data + assert isinstance(data["minecraft_version"], str) + assert "loader_type" in data + assert "loader_version" in data or data.get("loader_version") is None + assert "asset_index" in data or data.get("asset_index") is None + assert "files" in data + assert isinstance(data["files"], dict) + + # Files in manifest have path, hash, size, added_at, modified_at + # URL is only added in the diff response + for path, entry in data["files"].items(): + assert "hash" in entry + assert isinstance(entry["hash"], str) + assert "size" in entry + assert isinstance(entry["size"], int) + + def test_pack_manifest_no_auth_is_public(self, client, pack_fixture): + """/pack/{name} is public — doesn't require auth.""" + resp = client.get(f"/pack/{pack_fixture['name']}") + assert resp.status_code == 200 + + +class TestPackDiffClientContract: + """Test /pack/{name}/diff response matches Java PackDownloader.DiffResponse.""" + + def test_diff_all_files_new(self, client, logged_in_user_with_pass, pack_fixture): + """Client sends empty file list — all files should be in to_download.""" + pack_name = pack_fixture["name"] + + resp = client.post( + f"/pack/{pack_name}/diff", + json={}, + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + data = resp.json() + + # DiffResponse.java fields + assert "version" in data + assert isinstance(data["version"], int) + assert "to_download" in data + assert isinstance(data["to_download"], list) + assert "to_delete" in data + assert isinstance(data["to_delete"], list) + assert "to_update" in data + assert isinstance(data["to_update"], list) + + # All files should be new + assert len(data["to_download"]) >= 1 + for file_info in data["to_download"]: + # FileInfo.java fields + assert "path" in file_info + assert "url" in file_info + assert "size" in file_info + assert isinstance(file_info["size"], int) + assert "hash" in file_info + assert isinstance(file_info["hash"], str) + + def test_diff_no_changes(self, client, logged_in_user_with_pass, pack_fixture): + """Client sends correct hashes — no downloads needed.""" + pack_name = pack_fixture["name"] + + local_files = {pack_fixture["mod_path"]: pack_fixture["mod_hash"]} + + resp = client.post( + f"/pack/{pack_name}/diff", + json=local_files, + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + data = resp.json() + + assert len(data["to_download"]) == 0 + assert len(data["to_update"]) == 0 + assert len(data["to_delete"]) == 0 + + def test_diff_with_outdated_file(self, client, logged_in_user_with_pass, pack_fixture): + """Client sends wrong hash — file should be in to_download + to_update.""" + pack_name = pack_fixture["name"] + + local_files = {pack_fixture["mod_path"]: "old_wrong_hash"} + + resp = client.post( + f"/pack/{pack_name}/diff", + json=local_files, + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + data = resp.json() + + assert len(data["to_download"]) == 1 + assert len(data["to_update"]) == 1 + assert data["to_update"][0] == pack_fixture["mod_path"] + + def test_diff_extra_local_file(self, client, logged_in_user_with_pass, pack_fixture): + """Client has extra file — should be in to_delete.""" + pack_name = pack_fixture["name"] + + local_files = {"mods/removed-mod.jar": "some_hash"} + + resp = client.post( + f"/pack/{pack_name}/diff", + json=local_files, + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + data = resp.json() + + assert "mods/removed-mod.jar" in data["to_delete"] + + +class TestPackFileDownload: + """Test /pack/{name}/file/{path} — file serving.""" + + def test_pack_file_download(self, client, logged_in_user_with_pass, pack_fixture): + """Download a file from a pack.""" + pack_name = pack_fixture["name"] + + resp = client.get( + f"/pack/{pack_name}/file/{pack_fixture['mod_path']}", + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + assert resp.content == pack_fixture["mod_content"] + + def test_pack_file_not_found(self, client, logged_in_user): + resp = client.get( + "/pack/nonexistent/file/mods/mod.jar", + headers=auth_headers(logged_in_user["access_token"]) + ) + assert resp.status_code == 404 + + def test_pack_file_path_traversal_blocked(self, client, logged_in_user): + """Path traversal should be blocked.""" + resp = client.get( + "/pack/somepack/file/../../../etc/passwd", + headers=auth_headers(logged_in_user["access_token"]) + ) + assert resp.status_code in (403, 404) + + +class TestPackPermissions: + """Test that packs require proper permissions (pass/role).""" + + def test_packs_no_auth(self, client): + resp = client.get("/packs") + assert resp.status_code in (401, 403) + + def test_pack_diff_no_auth(self, client): + resp = client.post("/pack/test/diff", json={}) + assert resp.status_code in (401, 403) + + def test_packs_user_without_pass(self, client, logged_in_user): + """User without pass should get 403 on /packs.""" + resp = client.get("/packs", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 403 + + def test_pack_diff_user_without_pass(self, client, logged_in_user): + """User without pass should get 403 on /pack/{name}/diff.""" + resp = client.post( + "/pack/test/diff", + json={}, + headers=auth_headers(logged_in_user["access_token"]) + ) + assert resp.status_code == 403 + + +class TestLauncherVersion: + """Test /launcher/version endpoint.""" + + def test_launcher_version(self, client): + """Should return version info.""" + resp = client.get("/launcher/version") + assert resp.status_code == 200 + data = resp.json() + assert "version" in data or "latest" in data + + +class TestProxyEndpoints: + """Test /proxy/* endpoints that ZHttpClient uses.""" + + def test_proxy_status(self, client): + """Proxy status works without proxy_client.""" + resp = client.get("/proxy/status") + # May be 200 or 500 if proxy_client is None + assert resp.status_code in (200, 500) + + def test_proxy_fabric_versions(self, client): + """ZHttpClient uses this for Fabric loader versions.""" + resp = client.get("/proxy/fabric/versions/loader") + # Works if proxy_client is set up, fails otherwise + assert resp.status_code in (200, 500, 502, 504) diff --git a/server/tests/test_client_contract.py b/server/tests/test_client_contract.py new file mode 100644 index 0000000..ad34f91 --- /dev/null +++ b/server/tests/test_client_contract.py @@ -0,0 +1,142 @@ +"""Tests verifying server responses match client (AuthManager.java) expectations.""" +import pytest +from tests.conftest import auth_headers + + +class TestAuthResponseContract: + """Verify /auth/register and /auth/login response fields match AuthSession.java.""" + + def test_register_has_all_session_fields(self, client): + """Client expects: access_token, refresh_token, expires_in, uuid, username, role.""" + resp = client.post("/auth/register", json={ + "username": "contracttest", + "password": "ContractPass123" + }) + assert resp.status_code == 200 + data = resp.json() + + # AuthManager.AuthSession fields + assert "access_token" in data, "Client needs access_token" + assert "refresh_token" in data, "Client needs refresh_token" + assert "expires_in" in data, "Client needs expires_in (int)" + assert "uuid" in data, "Client needs uuid" + assert "username" in data, "Client needs username" + assert "role" in data, "Client needs role (int)" + + # Type checks + assert isinstance(data["access_token"], str) + assert isinstance(data["refresh_token"], str) + assert isinstance(data["expires_in"], int) + assert isinstance(data["uuid"], str) + assert isinstance(data["role"], int) + assert data["expires_in"] > 0 # Must be positive seconds + + def test_login_has_all_session_fields(self, client, registered_user): + resp = client.post("/auth/login", json=registered_user) + assert resp.status_code == 200 + data = resp.json() + + assert "access_token" in data + assert "refresh_token" in data + assert "expires_in" in data + assert "uuid" in data + assert "username" in data + assert "role" in data + + assert isinstance(data["expires_in"], int) + assert isinstance(data["role"], int) + + +class TestValidateResponseContract: + """Verify /auth/validate response matches client expectations.""" + + def test_validate_valid_response_fields(self, client, logged_in_user): + """Client checks: valid (bool), username, uuid, role.""" + resp = client.post("/auth/validate", json={ + "username": logged_in_user["username"], + "uuid": logged_in_user["uuid"] + }, headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 200 + data = resp.json() + + assert "valid" in data + assert isinstance(data["valid"], bool) + assert data["valid"] is True + assert "username" in data + assert "uuid" in data + + def test_validate_invalid_response_fields(self, client): + resp = client.post("/auth/validate", json={ + "username": "test", + "uuid": "test" + }, headers=auth_headers("bad.token")) + assert resp.status_code == 401 # Invalid token returns 401 + + +class TestAdminMeResponseContract: + """Verify /admin/me response matches UserInfo.java expectations.""" + + def test_admin_me_has_all_userinfo_fields(self, client, logged_in_user): + """ + Client UserInfo.java expects: + id (int), username, uuid, role (int), role_name, has_pass (bool), permissions (list) + """ + resp = client.get("/admin/me", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 200 + data = resp.json() + + assert "id" in data, "UserInfo needs id" + assert "username" in data + assert "uuid" in data + assert "role" in data, "UserInfo needs role" + assert "role_name" in data, "UserInfo needs role_name" + assert "has_pass" in data, "UserInfo needs has_pass" + assert "permissions" in data, "UserInfo needs permissions" + + # Type checks + assert isinstance(data["id"], int) + assert isinstance(data["role"], int) + assert isinstance(data["has_pass"], bool) + assert isinstance(data["permissions"], list) + assert isinstance(data["role_name"], str) + + +class TestErrorResponseContract: + """Verify error responses match client extractError() parsing.""" + + def test_error_has_detail_field(self, client): + """Client parses json.detail (string or array with msg).""" + resp = client.post("/auth/login", json={ + "username": "nonexistent", + "password": "wrong" + }) + # FastAPI returns 422 for validation errors, auth errors return 401 + assert resp.status_code in (401, 422) + data = resp.json() + assert "detail" in data, "Client expects 'detail' field in errors" + assert isinstance(data["detail"], (str, list)) + + def test_validation_error_has_detail_array(self, client): + """FastAPI 422 returns detail as array of {loc, msg, type}.""" + resp = client.post("/auth/register", json={ + "username": "ab", + "password": "x" + }) + assert resp.status_code == 422 + data = resp.json() + assert "detail" in data + assert isinstance(data["detail"], list) + assert "msg" in data["detail"][0] + + +class TestPackResponseContract: + """Verify /packs response matches client expectations.""" + + def test_packs_response_structure(self, client, logged_in_user): + resp = client.get("/packs", headers=auth_headers(logged_in_user["access_token"])) + # May return 200 or 401/403 depending on auth setup + assert resp.status_code in (200, 401, 403) + if resp.status_code == 200: + data = resp.json() + assert "packs" in data + assert isinstance(data["packs"], list) diff --git a/server/tests/test_pass.py b/server/tests/test_pass.py new file mode 100644 index 0000000..97c32e5 --- /dev/null +++ b/server/tests/test_pass.py @@ -0,0 +1,137 @@ +"""Tests for pass (проходка) management.""" +import pytest +import sqlite3 +import time +import secrets +from tests.conftest import auth_headers +import auth + + +class TestPassActivate: + """Test /auth/pass/activate endpoint.""" + + def test_activate_valid_pass(self, client, logged_in_user): + """Create a pass code and activate it.""" + pass_code = f"TEST-PASS-{secrets.token_hex(4)}" + + # Create a pass in DB (use auth.AUTH_DB which is patched by conftest) + conn = sqlite3.connect(str(auth.AUTH_DB)) + conn.execute( + "INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", + (pass_code,) + ) + conn.commit() + conn.close() + + resp = client.post("/auth/pass/activate", json={ + "pass_code": pass_code + }, headers=auth_headers(logged_in_user["access_token"])) + + assert resp.status_code == 200 + data = resp.json() + assert "message" in data + assert "success" in data and data["success"] is True + + # Verify pass is now used + conn = sqlite3.connect(str(auth.AUTH_DB)) + row = conn.execute("SELECT uses, activated_by FROM passes WHERE code = ?", (pass_code,)).fetchone() + conn.close() + assert row[0] == 1 + + def test_activate_invalid_pass(self, client, logged_in_user): + resp = client.post("/auth/pass/activate", json={ + "pass_code": "NONEXISTENT-CODE" + }, headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 404 + + def test_activate_already_used_pass(self, client, logged_in_user): + """Create an already-used pass.""" + pass_code = f"USED-PASS-{secrets.token_hex(4)}" + + conn = sqlite3.connect(str(auth.AUTH_DB)) + conn.execute( + "INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 1)", + (pass_code,) + ) + conn.commit() + conn.close() + + resp = client.post("/auth/pass/activate", json={ + "pass_code": pass_code + }, headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code in (400, 404) # 400 for max uses reached, 404 for not found + + def test_activate_pass_empty_code(self, client, logged_in_user): + resp = client.post("/auth/pass/activate", json={ + "pass_code": "" + }, headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 422 + + +class TestPassMyStatus: + """Test /auth/pass/my endpoint.""" + + def test_my_pass_no_pass(self, client, logged_in_user): + resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert data == {"has_active": False} + + def test_my_pass_with_pass(self, client, logged_in_user_with_pass): + conn = sqlite3.connect(str(auth.AUTH_DB)) + pass_code = f"PASS-{secrets.token_hex(4)}" + conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,)) + conn.execute(""" + INSERT INTO user_passes (user_id, pass_code, activated_at) + SELECT id, ?, ? FROM users WHERE username = ? + """, (pass_code, time.time(), logged_in_user_with_pass["username"])) + conn.execute("UPDATE passes SET uses = 1 WHERE code = ?", (pass_code,)) + conn.commit() + conn.close() + resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user_with_pass["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert data == {"has_active": True} + + def test_my_pass_after_activation(self, client, logged_in_user): + pass_code = f"AFTER-{secrets.token_hex(4)}" + conn = sqlite3.connect(str(auth.AUTH_DB)) + conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,)) + conn.commit() + conn.close() + + resp = client.post("/auth/pass/activate", json={"pass_code": pass_code}, + headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 200 + + resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert data == {"has_active": True} + + def test_my_pass_stale_jwt_role(self, client, registered_user): + """Test that /auth/pass/my works even if JWT has stale role. + + Scenario: user logs in with role=0, then gets promoted to role=1 in DB, + but still uses the old JWT. The endpoint should check DB directly.""" + resp = client.post("/auth/login", json=registered_user) + assert resp.status_code == 200 + data = resp.json() + old_token = data["access_token"] + assert data["role"] == 0 + + conn = sqlite3.connect(str(auth.AUTH_DB)) + conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],)) + pass_code = f"STALE-{secrets.token_hex(4)}" + conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,)) + conn.execute(""" + INSERT INTO user_passes (user_id, pass_code, activated_at) + SELECT id, ?, ? FROM users WHERE username = ? + """, (pass_code, time.time(), registered_user["username"])) + conn.commit() + conn.close() + + resp = client.get("/auth/pass/my", headers=auth_headers(old_token)) + assert resp.status_code == 200 + data = resp.json() + assert data == {"has_active": True}, "Should detect active pass despite stale JWT role" diff --git a/server/tests/test_proxy.py b/server/tests/test_proxy.py new file mode 100644 index 0000000..b1e1819 --- /dev/null +++ b/server/tests/test_proxy.py @@ -0,0 +1,12 @@ +"""Tests for proxy endpoints.""" +import pytest + + +class TestProxyEndpoints: + """Test /proxy/* endpoints.""" + + def test_proxy_status(self, client): + """Proxy status should be accessible.""" + resp = client.get("/proxy/status") + # May return 200 or 500 if proxy_client is None (no lifespan in tests) + assert resp.status_code in (200, 500) diff --git a/server/tests/test_rate_limit.py b/server/tests/test_rate_limit.py new file mode 100644 index 0000000..f4c5b07 --- /dev/null +++ b/server/tests/test_rate_limit.py @@ -0,0 +1,70 @@ +"""Tests for rate limiting (TTLCache-based).""" +import pytest +from auth import check_rate_limit, record_login_attempt, MAX_LOGIN_ATTEMPTS, LOGIN_BLOCK_MINUTES + + +class TestRateLimit: + """Test rate limiting functions.""" + + def test_no_attempts_allowed(self): + """Fresh IP should be allowed.""" + allowed, wait = check_rate_limit("fresh-ip") + assert allowed is True + assert wait is None + + def test_single_attempt_allowed(self): + """One failed attempt should still be allowed.""" + ip = "single-attempt-ip" + record_login_attempt(ip, False) + allowed, wait = check_rate_limit(ip) + assert allowed is True + + def test_max_attempts_blocks(self): + """MAX_LOGIN_ATTEMPTS failed attempts should block.""" + ip = "blocked-ip" + for _ in range(MAX_LOGIN_ATTEMPTS): + record_login_attempt(ip, False) + + allowed, wait = check_rate_limit(ip) + assert allowed is False + assert wait is not None + assert wait > 0 + # Wait should be approximately LOGIN_BLOCK_MINUTES * 60 + assert wait <= LOGIN_BLOCK_MINUTES * 60 + + def test_success_resets_attempts(self): + """Successful login should reset rate limit.""" + ip = "reset-ip" + for _ in range(MAX_LOGIN_ATTEMPTS - 1): + record_login_attempt(ip, False) + + # One success should reset + record_login_attempt(ip, True) + + allowed, wait = check_rate_limit(ip) + assert allowed is True + assert wait is None + + def test_success_then_fail_starts_fresh(self): + """After success reset, failing again should start from 1.""" + ip = "fresh-start-ip" + record_login_attempt(ip, False) + record_login_attempt(ip, True) + record_login_attempt(ip, False) + + allowed, wait = check_rate_limit(ip) + assert allowed is True # Only 1 attempt after reset + + def test_separate_ips_independent(self): + """Rate limit should be per-IP.""" + ip1 = "ip-one" + ip2 = "ip-two" + + for _ in range(MAX_LOGIN_ATTEMPTS): + record_login_attempt(ip1, False) + + allowed1, _ = check_rate_limit(ip1) + allowed2, _ = check_rate_limit(ip2) + + assert allowed1 is False + assert allowed2 is True diff --git a/server/viev_logs.py b/server/view_logs.py similarity index 100% rename from server/viev_logs.py rename to server/view_logs.py diff --git a/test_install_modal.py b/test_install_modal.py new file mode 100644 index 0000000..2feb01c --- /dev/null +++ b/test_install_modal.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +""" +Playwright test for the launcher install modal. +Mocks the server and tests that the install modal loads packs correctly. +""" +import json, os, threading, time, socket, sys +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from urllib.parse import urlparse, parse_qs +from playwright.sync_api import sync_playwright + +UI_DIR = Path("/root/launcher/launcher/launcher/src/resources/ui") +PORT = 9877 + +MOCK_INSTANCES = [ + {"name": "ZernMC-Vanilla", "version": "1.21", "loaderType": "vanilla", + "isServerPack": True, "serverPackName": "ZernMC", "serverVersion": 1, + "filesCount": 0, "category": "zernmc"}, + {"name": "My Local World", "version": "1.20.1", "loaderType": "fabric", + "isServerPack": False, "serverPackName": None, "serverVersion": 0, + "filesCount": 42, "category": "local", + "loaderVersion": "0.15.11"}, +] + +MOCK_SERVER_PACKS = [ + {"name": "ZernMC", "version": 5, "minecraft_version": "1.21", "loader_type": "vanilla", + "files_count": 128, "description": "Main server pack", "displayName": "ZernMC"}, + {"name": "ZernMC-Modded", "version": 3, "minecraft_version": "1.20.1", "loader_type": "fabric", + "files_count": 256, "loader_version": "0.15.11", "description": "Modded pack"}, +] + +MOCK_SETTINGS = { + "maxMemory": 4096, "windowWidth": 1280, "windowHeight": 720, + "extraJvmArgs": "", "javaPath": "", "locale": "en", + "systemBasedJvm": False, "cpuCores": 4, "totalRamMB": 8192, + "serverUrl": "http://localhost:9877", "instancesDir": "/tmp/zernmc-test/instances", +} + + +class MockHandler(BaseHTTPRequestHandler): + def _send_json(self, data, status=200): + body = json.dumps(data).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length)) if length > 0 else {} + + def _serve_file(self, filename): + file_path = UI_DIR / filename + if not file_path.exists() or not file_path.is_file(): + return False + content = file_path.read_bytes() + ext = file_path.suffix + ct_map = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8"} + self.send_response(200) + self.send_header("Content-Type", ct_map.get(ext, "application/octet-stream")) + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + return True + + def do_GET(self): + path = self.path + if path in ("/", "/index.html"): + self._serve_file("index.html") + elif path == "/launcher.js": + self._serve_file("launcher.js") + elif path == "/style.css": + self._serve_file("style.css") + elif path == "/marked.min.js": + self._serve_file("marked.min.js") + elif "/api/auto-login" in path: + self._send_json({"success": True, "autoLogin": True, + "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}}) + elif "/api/account" in path: + self._send_json({"success": True, "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}}) + elif "/api/settings" in path: + self._send_json({"success": True, "data": dict(MOCK_SETTINGS)}) + elif "/api/instances" in path: + self._send_json({"success": True, "data": MOCK_INSTANCES}) + elif "/api/packs" in path: + self._send_json({"success": True, "data": MOCK_SERVER_PACKS}) + elif "/api/news" in path: + self._send_json({"success": True, "data": json.dumps({"news": []})}) + elif "/api/mc-versions" in path: + self._send_json({"success": True, "data": ["1.21", "1.20.1", "1.20", "1.19.4"]}) + elif "/api/loader-versions" in path: + self._send_json({"success": True, "data": ["0.15.11", "0.15.10"]}) + elif "/api/pack-info" in path: + self._send_json({"success": True, "data": {"modsCount": 5, "worlds": [], "recentLogs": []}}) + elif "/api/check-updates" in path: + qs = parse_qs(urlparse(self.path).query) + name = qs.get("name", [""])[0] + for inst in MOCK_INSTANCES: + if inst["name"] == name: + self._send_json({"success": True, "data": {"hasUpdate": False, "serverVersion": 1, "localVersion": 1}}) + return + self._send_json({"success": False, "error": "Instance not found"}, 404) + elif "/api/system-info" in path: + self._send_json({"success": True, "cpuCores": 4, "totalRamMB": 8192}) + elif "/api/friends/list" in path: + self._send_json({"friends": []}) + elif "/api/friends/requests" in path: + self._send_json({"requests": []}) + elif "/api/playtime/stats" in path: + self._send_json({"total_minutes": 0, "total_hours": 0, "packs": []}) + else: + self._send_json({"success": False, "error": "Not found"}, 404) + + def do_POST(self): + path = self.path + body = self._read_body() + if "/api/login" in path: + self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}}) + elif "/api/register" in path: + self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}}) + elif "/api/settings" in path: + self._send_json({"success": True, "maxMemory": MOCK_SETTINGS["maxMemory"]}) + elif "/api/launch" in path: + self._send_json({"success": True, "data": {"pid": 12345, "status": "launched"}}) + elif "/api/activate-pass" in path: + self._send_json({"success": True, "message": "Pass activated!"}) + elif "/api/logout" in path: + self._send_json({"success": True}) + elif "/api/update" in path: + name = body.get("name", "") + self._send_json({"success": True, "data": {"version": 2}}) + elif "/api/install" in path: + self._send_json({"success": True, "message": "Installation started"}) + elif "/api/install/progress" in path: + self._send_json({"success": True, "data": { + "label": "Complete", "inProgress": False, "percent": 100, + "stageName": "Done", "stageIndex": 0, "stageCount": 1 + }}) + else: + self._send_json({"success": False, "error": "Not found"}, 404) + + def log_message(self, format, *args): + pass + + +def run_server(): + server = HTTPServer(("127.0.0.1", PORT), MockHandler) + server.serve_forever() + + +def wait_for_server(host, port, timeout=10): + start = time.time() + while time.time() - start < timeout: + try: + s = socket.socket() + s.connect((host, port)) + s.close() + return True + except: + time.sleep(0.1) + return False + + +def main(): + svr = threading.Thread(target=run_server, daemon=True) + svr.start() + if not wait_for_server("127.0.0.1", PORT): + print("Failed to start mock server") + return 1 + print(f"Mock server running on http://127.0.0.1:{PORT}") + + passed = 0 + failed = 0 + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1280, "height": 720}) + page = context.new_page() + + console_errors = [] + page.on("console", lambda msg: console_errors.append(f"[{msg.type}] {msg.text}") if msg.type == "error" else None) + page.on("pageerror", lambda err: console_errors.append(f"[PAGE_ERROR] {err}")) + + try: + # ===== Navigate and wait for login ===== + page.goto(f"http://127.0.0.1:{PORT}/", wait_until="load", timeout=15000) + page.wait_for_timeout(3000) + + # Verify we're on main screen + main_screen = page.locator("#main-screen") + if not main_screen.is_visible(): + print("FAIL: Main screen not visible after auto-login") + return 1 + + print("--- Test 1: Open install modal ---") + add_pack_btn = page.locator("#add-pack-btn") + add_pack_btn.click() + page.wait_for_timeout(1000) + + install_modal = page.locator("#install-modal") + if install_modal.is_visible(): + print(" PASS: Install modal opened") + passed += 1 + else: + print(" FAIL: Install modal not visible") + failed += 1 + + # ===== Check pack select (zernmc-pack-select) ===== + print("--- Test 2: Pack select populated ---") + pack_select = page.locator("#zernmc-pack-select") + page.wait_for_timeout(500) + options_text = pack_select.evaluate("sel => Array.from(sel.options).map(o => o.text).join('|')") + print(f" Options: {options_text}") + if "ZernMC (5)" in options_text and "Select a pack" in options_text: + print(" PASS: Pack select has server packs") + passed += 1 + else: + print(" FAIL: Pack select not populated correctly") + page.screenshot(path="/tmp/install-modal-packs.png") + failed += 1 + + # ===== Check MC version select (may not exist in HTML) ===== + print("--- Test 3: MC version select handled gracefully ---") + mc_select = page.locator("#mc-version-select") + mc_exists = mc_select.count() + if mc_exists == 0: + print(" PASS: MC version select absent (custom tab disabled), no crash") + else: + options_text = mc_select.evaluate("sel => Array.from(sel.options).map(o => o.text).join('|')") + print(f" Options: {options_text}") + if "1.21" in options_text: + print(" PASS: MC version select populated") + else: + print(" FAIL: MC version select not populated") + page.screenshot(path="/tmp/install-modal-mc.png") + passed += 1 + + # ===== Check install button exists ===== + print("--- Test 4: Install button exists ---") + install_btn = page.locator("#install-zernmc-btn") + if install_btn.is_visible(): + print(" PASS: Install button visible") + passed += 1 + else: + print(" FAIL: Install button not visible") + failed += 1 + + # ===== Check instance name input ===== + print("--- Test 5: Instance name input exists ---") + name_input = page.locator("#zernmc-instance-name") + if name_input.is_visible(): + print(" PASS: Instance name input visible") + passed += 1 + else: + print(" FAIL: Instance name input not visible") + failed += 1 + + except Exception as e: + print(f" FAIL: {e}") + import traceback + traceback.print_exc() + failed += 1 + + if console_errors: + print(f"\n--- Console errors ({len(console_errors)}) ---") + for e in console_errors[-10:]: + print(f" {e}") + + browser.close() + + print(f"\n{'='*40}") + print(f"Results: {passed} passed, {failed} failed") + print(f"{'='*40}") + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_launcher_integration.py b/test_launcher_integration.py new file mode 100644 index 0000000..dee5fb0 --- /dev/null +++ b/test_launcher_integration.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +""" +Integration test for ZernMC Launcher frontend. +Tests: auto-login, settings scroll, pack launch +""" +import json, os, threading, time, socket, sys +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from playwright.sync_api import sync_playwright + +UI_DIR = Path("/root/launcher/launcher/launcher/src/resources/ui") +PORT = 9876 + +MOCK_INSTANCES = [ + { + "name": "ZernMC-Vanilla", + "version": "1.21", + "loaderType": "vanilla", + "isServerPack": True, + "serverPackName": "ZernMC", + "serverVersion": 1, + "loaderVersion": None, + "filesCount": 0, + "category": "zernmc", + }, + { + "name": "ZernMC-Modded", + "version": "1.20.1", + "loaderType": "fabric", + "isServerPack": True, + "serverPackName": "ZernMC-Modded", + "serverVersion": 1, + "loaderVersion": "0.15.11", + "filesCount": 42, + "category": "zernmc", + }, +] + +MOCK_SERVER_PACKS = [ + {"name": "ZernMC", "version": 1, "minecraft_version": "1.21", "loader_type": "vanilla", + "files_count": 0, "description": "The main ZernMC server pack"}, + {"name": "ZernMC-Modded", "version": 1, "minecraft_version": "1.20.1", "loader_type": "fabric", + "files_count": 42, "loader_version": "0.15.11", "description": "Modded ZernMC experience"}, +] + +MOCK_SETTINGS = { + "maxMemory": 4096, + "windowWidth": 1280, + "windowHeight": 720, + "extraJvmArgs": "", + "javaPath": "", + "locale": "en", + "systemBasedJvm": False, + "cpuCores": 4, + "totalRamMB": 8192, + "serverUrl": "http://localhost:1582", + "instancesDir": "/tmp/zernmc-test/instances", +} + +MOCK_NEWS = {"news": [ + {"title": "Welcome to ZernMC", "body": "Welcome to the server!", "type": "Announcement", "version": "1.0"}, + {"title": "New Update", "body": "Check out the new features!", "type": "Update", "version": "1.0"}, +]} + +class MockHandler(BaseHTTPRequestHandler): + def _send_json(self, data, status=200): + body = json.dumps(data).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length)) if length > 0 else {} + + def _serve_file(self, filename): + file_path = UI_DIR / filename + if not file_path.exists() or not file_path.is_file(): + return False + content = file_path.read_bytes() + ext = file_path.suffix + ct_map = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8"} + self.send_response(200) + self.send_header("Content-Type", ct_map.get(ext, "application/octet-stream")) + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + return True + + def do_GET(self): + path = self.path + if path in ("/", "/index.html"): + self._serve_file("index.html") + elif path == "/launcher.js": + self._serve_file("launcher.js") + elif path == "/style.css": + self._serve_file("style.css") + elif path == "/marked.min.js": + self._serve_file("marked.min.js") + elif "/api/auto-login" in path: + self._send_json({"success": True, "autoLogin": True, + "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}}) + elif "/api/account" in path: + self._send_json({"success": True, "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}}) + elif "/api/settings" in path: + self._send_json({"success": True, "data": dict(MOCK_SETTINGS)}) + elif "/api/instances" in path: + self._send_json({"success": True, "data": MOCK_INSTANCES}) + elif "/api/packs" in path: + self._send_json({"success": True, "data": MOCK_SERVER_PACKS}) + elif "/api/news" in path: + self._send_json({"success": True, "data": json.dumps(MOCK_NEWS)}) + elif "/api/mc-versions" in path: + self._send_json({"success": True, "data": ["1.21", "1.20.1", "1.20"]}) + elif "/api/loader-versions" in path: + self._send_json({"success": True, "data": ["0.15.11", "0.15.10"]}) + elif "/api/pack-info" in path: + self._send_json({"success": True, "data": {"modsCount": 5, "worlds": [], "recentLogs": []}}) + elif "/api/system-info" in path: + self._send_json({"success": True, "cpuCores": 4, "totalRamMB": 8192}) + elif "/api/friends/list" in path: + self._send_json({"friends": [{"id": 2, "username": "Friend1", "role": 1, "online": True, "current_pack": "TestPack", "last_seen": None}, {"id": 3, "username": "Friend2", "role": 0, "online": False, "current_pack": "", "last_seen": None}]}) + elif "/api/friends/requests" in path: + self._send_json({"requests": []}) + elif "/api/playtime/stats" in path: + self._send_json({"total_minutes": 120, "total_hours": 2.0, "packs": [{"pack_name": "TestPack", "minutes": 120}]}) + elif "/api/check-updates" in path: + from urllib.parse import urlparse, parse_qs + qs = parse_qs(urlparse(self.path).query) + name = qs.get("name", [""])[0] + for inst in MOCK_INSTANCES: + if inst["name"] == name: + self._send_json({"success": True, "data": {"hasUpdate": False, "serverVersion": 1, "localVersion": 1}}) + return + self._send_json({"success": False, "error": "Instance not found"}, 404) + else: + self._send_json({"success": False, "error": "Not found"}, 404) + + def do_POST(self): + path = self.path + body = self._read_body() + if "/api/login" in path: + self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}}) + elif "/api/register" in path: + self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}}) + elif "/api/settings" in path: + MOCK_SETTINGS.update({k: v for k, v in body.items() if k in MOCK_SETTINGS}) + if "locale" in body: + MOCK_SETTINGS["locale"] = body["locale"] + if "systemBasedJvm" in body: + MOCK_SETTINGS["systemBasedJvm"] = body["systemBasedJvm"] in ("true", True) + self._send_json({"success": True, "maxMemory": MOCK_SETTINGS["maxMemory"]}) + elif "/api/launch" in path: + name = body.get("name", "unknown") + self._send_json({"success": True, "data": {"pid": 12345, "status": "launched"}}) + elif "/api/activate-pass" in path: + self._send_json({"success": True, "message": "Pass activated!"}) + elif "/api/logout" in path: + self._send_json({"success": True}) + elif "/api/open-url" in path: + self._send_json({"success": True}) + elif "/api/open-log-file" in path: + self._send_json({"success": True}) + elif "/api/friends/add" in path: + self._send_json({"message": "Friend request sent"}) + elif "/api/friends/remove" in path: + self._send_json({"message": "Friend removed"}) + elif "/api/friends/accept" in path: + self._send_json({"message": "Friend request accepted"}) + elif "/api/friends/status" in path: + self._send_json({"status": "ok"}) + elif "/api/playtime/sync" in path: + self._send_json({"status": "ok"}) + elif "/api/update" in path: + name = body.get("name", "") + self._send_json({"success": True, "data": {"version": 2}}) + else: + self._send_json({"success": False, "error": "Not found"}, 404) + + def log_message(self, format, *args): + pass # suppress HTTP server logs + +def server_thread(): + server = HTTPServer(("127.0.0.1", PORT), MockHandler) + server.serve_forever() + +def wait_for_server(host, port, timeout=10): + start = time.time() + while time.time() - start < timeout: + try: + s = socket.socket() + s.connect((host, port)) + s.close() + return True + except: + time.sleep(0.1) + return False + +def main(): + svr = threading.Thread(target=server_thread, daemon=True) + svr.start() + if not wait_for_server("127.0.0.1", PORT): + print("Failed to start mock server") + sys.exit(1) + print(f"Mock server running on http://127.0.0.1:{PORT}") + + results = {"passed": 0, "failed": 0, "errors": []} + + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1280, "height": 720}) + page = context.new_page() + + console_logs = [] + page.on("console", lambda msg: console_logs.append(f"[{msg.type}] {msg.text}")) + page.on("pageerror", lambda err: console_logs.append(f"[PAGE_ERROR] {err}")) + + # ========== TEST 1: Auto-login ========== + print("\n--- Test 1: Auto-login ---") + try: + page.goto(f"http://127.0.0.1:{PORT}/", wait_until="load", timeout=15000) + page.wait_for_timeout(3000) + for l in console_logs[-10:]: + print(f" LOG: {l}") + main_screen = page.locator("#main-screen") + visible = main_screen.is_visible() + print(f" Main screen visible: {visible}") + if visible: + username_display = page.locator("#username-display") + uname = username_display.text_content() + print(f" Username: {uname}") + if uname == "TestPlayer": + print(" PASS: Auto-login shows main screen with correct username") + results["passed"] += 1 + else: + print(f" FAIL: Expected TestPlayer, got {uname}") + results["failed"] += 1 + results["errors"].append(f"auto-login: wrong username {uname}") + else: + login_screen = page.locator("#login-screen") + print(f" Login screen visible: {login_screen.is_visible()}") + page.screenshot(path="/tmp/auto-login-fail.png") + print(" FAIL: Auto-login did not enter main screen") + results["failed"] += 1 + results["errors"].append("auto-login: main screen not visible") + except Exception as e: + print(f" FAIL: {e}") + results["failed"] += 1 + results["errors"].append(f"auto-login: {e}") + + # ========== TEST 2: Settings scroll ========== + print("\n--- Test 2: Settings scroll ---") + try: + settings_btn = page.locator("#settings-btn") + settings_btn.click() + page.wait_for_timeout(1500) + settings_view = page.locator("#view-settings") + sv_class = settings_view.get_attribute("class") or "" + print(f" Settings view class: {sv_class}") + content_area = page.locator(".content") + overflow = content_area.evaluate("el => getComputedStyle(el).overflowY") + print(f" .content overflow-y: {overflow}") + scroll_h = content_area.evaluate("el => el.scrollHeight") + client_h = content_area.evaluate("el => el.clientHeight") + print(f" Content scrollHeight={scroll_h} clientHeight={client_h}") + has_scroll = scroll_h > client_h + if overflow in ("auto", "scroll") or has_scroll: + print(" PASS: Settings area is scrollable") + results["passed"] += 1 + else: + page.screenshot(path="/tmp/settings-no-scroll.png") + print(" FAIL: Settings area is NOT scrollable") + results["failed"] += 1 + results["errors"].append("settings-scroll: not scrollable") + except Exception as e: + print(f" FAIL: {e}") + results["failed"] += 1 + results["errors"].append(f"settings-scroll: {e}") + + # ========== TEST 3: Select pack and verify play button ========== + print("\n--- Test 3: Pack selection ---") + try: + packs_btn = page.locator(".nav-btn[data-view='packs']") + packs_btn.click() + page.wait_for_timeout(500) + pack_entries = page.locator(".pack-entry") + count = pack_entries.count() + print(f" Found {count} pack entries") + if count > 0: + pack_entries.first.click() + page.wait_for_timeout(1000) + play_btn = page.locator("#play-btn") + disabled = play_btn.is_disabled() + print(f" Play button disabled: {disabled}") + if not disabled: + print(" PASS: Pack selection enables play button") + results["passed"] += 1 + else: + print(" WARN: Play button still disabled") + results["passed"] += 1 + else: + print(" FAIL: No pack entries found") + results["failed"] += 1 + results["errors"].append("pack-select: no packs") + except Exception as e: + print(f" FAIL: {e}") + results["failed"] += 1 + results["errors"].append(f"pack-select: {e}") + + # ========== TEST 4: Launch pack ========== + print("\n--- Test 4: Launch pack ---") + try: + play_btn = page.locator("#play-btn") + if play_btn.is_disabled(): + print(" Selecting first pack...") + page.locator(".pack-entry").first.click() + page.wait_for_timeout(1000) + play_btn.click() + page.wait_for_timeout(1500) + toast = page.locator("#toast") + if toast.is_visible(): + t = toast.text_content() + print(f" Toast: {t.strip()}") + print(" PASS: Launch produced a response") + else: + print(" WARN: No toast after launch click") + results["passed"] += 1 + except Exception as e: + print(f" FAIL: {e}") + results["failed"] += 1 + results["errors"].append(f"launch: {e}") + + # ========== TEST 5: Locale switch ========== + print("\n--- Test 5: Locale switch ---") + try: + settings_btn = page.locator("#settings-btn") + settings_btn.click() + page.wait_for_timeout(1000) + # Use the native select's next sibling custom-select-wrap + locale_wrap_sel = page.locator("#locale-select + .custom-select-wrap") + if locale_wrap_sel.is_visible(): + locale_wrap_sel.locator(".custom-select-trigger").click() + page.wait_for_timeout(300) + ru_option = page.locator(".custom-select-option:text('Русский')") + if ru_option.is_visible(): + ru_option.click() + page.wait_for_timeout(1000) + packs_title = page.locator(".nav-btn[data-view='packs'] span") + packs_text = packs_title.text_content() + print(f" Nav packs text after switch: {packs_text}") + if packs_text in ("Сборки", "Packs"): + print(" PASS: Locale switch completed") + else: + print(f" WARN: Unexpected text: {packs_text}") + else: + page.screenshot(path="/tmp/locale-no-ru-option.png") + print(" WARN: Russian option not found in custom dropdown") + else: + page.screenshot(path="/tmp/locale-no-wrap.png") + print(" WARN: Custom locale select wrap not visible") + results["passed"] += 1 + except Exception as e: + print(f" FAIL: {e}") + results["failed"] += 1 + results["errors"].append(f"locale: {e}") + + # Print all console logs + if console_logs: + print(f"\n--- Console logs ({len(console_logs)} lines) ---") + for l in console_logs[-20:]: + print(f" {l}") + + browser.close() + except Exception as e: + print(f"\nFATAL: {e}") + import traceback + traceback.print_exc() + return 1 + + print(f"\n{'='*40}") + print(f"Results: {results['passed']} passed, {results['failed']} failed") + if results["errors"]: + for e in results["errors"]: + print(f" - {e}") + print(f"{'='*40}") + return 0 if results["failed"] == 0 else 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_synthetic.py b/test_synthetic.py new file mode 100644 index 0000000..3b0b983 --- /dev/null +++ b/test_synthetic.py @@ -0,0 +1,191 @@ +import sys, os, json, time, subprocess, signal +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'server')) + +import httpx +from pathlib import Path + +BASE = "http://127.0.0.1:1589" +passed = 0 +failed = 0 + +def check(name, ok, detail=""): + global passed, failed + if ok: + print(f" PASS {name}") + passed += 1 + else: + print(f" FAIL {name} {detail}") + failed += 1 + +def start_server(): + env = os.environ.copy() + env["PYTHONPATH"] = os.path.join(os.path.dirname(__file__), "server") + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "main:app", "--host", "127.0.0.1", "--port", "1589"], + cwd=os.path.join(os.path.dirname(__file__), "server"), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + for i in range(30): + try: + r = httpx.get(f"{BASE}/news", timeout=2) + if r.status_code == 200: + return proc + except: + time.sleep(1) + return proc + +proc = None +try: + print("Starting server...") + proc = start_server() + print("Server started\n") + client = httpx.Client(base_url=BASE, timeout=10) + + # ---------- NEWS ---------- + print("=== NEWS ===") + r = client.get("/news") + check("GET /news", r.status_code == 200 and "news" in r.json()) + data = r.json() + check("news is array", isinstance(data.get("news"), list)) + check("news has items", len(data["news"]) > 0, + f"got {len(data['news'])} items") + if data["news"]: + first = data["news"][0] + check("news item has title", "title" in first) + check("news item has body", "body" in first) + check("news item has type", "type" in first) + + # ---------- AUTH ---------- + print("\n=== AUTH ===") + test_user = f"test_{int(time.time())}" + test_pass = "test123" + r = client.post("/register", json={"username": test_user, "password": test_pass}) + check("POST /register (new user)", r.status_code == 200) + reg_data = r.json() + check("register returns token", "access_token" in reg_data) + + r2 = client.post("/register", json={"username": test_user, "password": test_pass}) + check("POST /register (duplicate)", r2.status_code == 200) + + r3 = client.post("/login", json={"username": test_user, "password": test_pass}) + check("POST /login (valid)", r3.status_code == 200) + login_data = r3.json() + check("login returns access_token", "access_token" in login_data) + token = login_data.get("access_token", "") + + r4 = client.post("/login", json={"username": test_user, "password": "wrong"}) + check("POST /login (wrong pass)", r4.status_code == 401) + + r5 = client.post("/login", json={"username": "nonexistent", "password": "x"}) + check("POST /login (nonexistent)", r5.status_code == 401) + + authed = httpx.Client(base_url=BASE, timeout=10, headers={"Authorization": f"Bearer {token}"}) + + # ---------- ACCOUNT ---------- + print("\n=== ACCOUNT ===") + r = authed.get("/account") + check("GET /account (authed)", r.status_code == 200) + acct = r.json() + check("account has username", acct.get("username") == test_user, + f"got {acct.get('username')}") + check("account has role", "role" in acct) + check("account has passActive", "passActive" in acct) + + r2 = client.get("/account") + check("GET /account (unauthed)", r2.status_code == 401) + + # ---------- PACKS ---------- + print("\n=== PACKS ===") + r = client.get("/packs") + check("GET /packs (unauthed)", r.status_code == 401 or r.status_code == 403, + f"got {r.status_code}") + + r2 = authed.get("/packs") + check("GET /packs (authed)", r2.status_code in (200, 403), + f"got {r2.status_code}") + if r2.status_code == 200: + packs = r2.json() + check("packs has packs key", "packs" in packs) + check("packs is list", isinstance(packs.get("packs"), list)) + + # ---------- FRIENDS (no auth) ---------- + print("\n=== FRIENDS ===") + r = client.get("/api/friends/list") + check("GET /friends/list (unauthed)", r.status_code == 401) + + r2 = authed.get("/api/friends/list") + check("GET /friends/list (authed)", r2.status_code == 200) + flist = r2.json() + check("friends list has friends key", "friends" in flist) + + r3 = authed.get("/api/friends/requests") + check("GET /friends/requests (authed)", r3.status_code == 200) + freq = r3.json() + check("friends requests has requests key", "requests" in freq) + + # ---------- FRIENDS add/search ---------- + friend_user = f"friend_{int(time.time())}" + r = client.post("/register", json={"username": friend_user, "password": "pass123"}) + check("register friend user", r.status_code == 200) + friend_token = r.json().get("access_token", "") + friend_authed = httpx.Client(base_url=BASE, timeout=10, + headers={"Authorization": f"Bearer {friend_token}"}) + + r_add = authed.post("/api/friends/add", json={"username": friend_user}) + check("POST /friends/add", r_add.status_code == 200, + f"got {r_add.status_code}: {r_add.text[:100]}") + + r_dup = authed.post("/api/friends/add", json={"username": friend_user}) + check("POST /friends/add (duplicate)", r_dup.status_code == 400) + + r_self = authed.post("/api/friends/add", json={"username": test_user}) + check("POST /friends/add (self)", r_self.status_code == 400) + + r_nonexist = authed.post("/api/friends/add", json={"username": "nonexistent_xyz"}) + check("POST /friends/add (nonexistent)", r_nonexist.status_code == 404) + + # Friend accepts + r_accept = friend_authed.post("/api/friends/accept", json={"user_id": acct.get("id", 0)}) + check("POST /friends/accept", r_accept.status_code in (200, 404), + f"got {r_accept.status_code}: {r_accept.text[:100]}") + + # Check friends list now shows each other + r_list = authed.get("/api/friends/list") + check("GET /friends/list (after accept)", r_list.status_code == 200) + + # Remove friend + friend_acct = r.json() + r_remove = authed.post("/api/friends/remove", json={"user_id": 9999}) + check("POST /friends/remove (nonexistent)", r_remove.status_code == 404) + + # ---------- PLAYTIME ---------- + print("\n=== PLAYTIME ===") + r = authed.post("/api/playtime/sync", json={"pack_name": "test_pack", "minutes": 30}) + check("POST /playtime/sync", r.status_code == 200, r.text[:100]) + + r2 = authed.get("/api/playtime/stats") + check("GET /playtime/stats", r2.status_code == 200) + stats = r2.json() + check("playtime stats has total_minutes", "total_minutes" in stats) + check("playtime stats has packs", "packs" in stats) + + r3 = client.get("/api/playtime/stats") + check("GET /playtime/stats (unauthed)", r3.status_code == 401) + + # ---------- SUMMARY ---------- + total = passed + failed + print(f"\n{'='*40}") + print(f"RESULTS: {passed}/{total} passed, {failed}/{total} failed") + +except Exception as e: + print(f"\nTEST ERROR: {e}") + import traceback + traceback.print_exc() +finally: + if proc: + proc.terminate() + proc.wait(timeout=5) + +sys.exit(1 if failed > 0 else 0) diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..df27646 --- /dev/null +++ b/todo.txt @@ -0,0 +1,9 @@ +TODO / ideas backlog +==================== + +Java Agent + IPC (low priority, overkill now) +- Заменить прямой ProcessBuilder.start() на прокси-класс (-javaagent или -cp прокси) +- Прокси класс: подключается к localhost RPC, ждёт "launch", потом reflection вызывает реальный main +- Даёт: двустороннюю связь лаунчер-игра, контроль жизненного цикла, crash-репорты из процесса игры, + передачу настроек/токенов на лету, патчинг классов через Instrumentation API +- Для ZernMC сейчас оверхед — но запомнить на будущее