18 Commits

Author SHA1 Message Date
SashegDev 635d2d6223 fix: добавляем shell скрипт launcher.sh для запуска с javafx module-path 2026-05-05 11:36:36 +00:00
SashegDev 69ed8fd33c fix: исключаем javafx из shade, исправляем antrun 2026-05-05 11:35:33 +00:00
SashegDev 2a06aa8b29 feat: добавляем win/linux профили для сборки с разными javafx 2026-05-05 11:33:30 +00:00
SashegDev d9085d3e1f fix: исправляем синтаксис antrun copy 2026-05-05 11:29:01 +00:00
SashegDev 5d4bf70925 fix: копируем JavaFX JAR в builds и исключаем из shade 2026-05-05 11:25:44 +00:00
SashegDev 4b11a6e659 fix: добавляем JavaFX module-path и настройки для GUI запуска 2026-05-05 11:23:59 +00:00
SashegDev c6dce7f430 fix: добавляем Windows classifier для JavaFX и исключаем Linux jar из shade
- добавили <classifier>win</classifier> в javafx-controls и javafx-web
- добавили фильтр в shade plugin для исключения *-linux.jar
2026-05-05 09:42:49 +00:00
SashegDev f0365561a0 fix: убираем дублирующую проверку isHeadless из UIWindow
раньше UIWindow сам проверял isHeadless и выбрасывал исключение
теперь доверяем проверке в Main с учётом переменной DISPLAY
2026-05-05 09:37:01 +00:00
SashegDev 62f5687872 fix: добавляем проверку DISPLAY для запуска UI на сервере
раньше isHeadless() определял что нет дисплея даже когда он есть
теперь дополнительно проверяем переменную DISPLAY - если она есть,
значит графическая среда доступна и можно запускать UI
2026-05-05 09:36:01 +00:00
SashegDev 405982503e feat(ui): добавляем анимированный прогресс-бар при обновлении сборки
- обновили updateInstance() с поддержкой SSE для реального прогресса
- добавили showAnimatedProgress() и updateAnimatedProgress() методы
- добавили CSS анимацию shimmer для прогресс-бара
- теперь показывает: файл X из Y и процент текущего файла
2026-05-05 08:57:48 +00:00
SashegDev df783b0b03 feat(ui): добавляем прогресс-коллбэк и автообновление лаунчера
- добавили ProgressCallback в PackDownloader для отслеживания прогресса
- SSE эндпоинт /instances/{name}/install/stream для стриминга прогресса
- добавили checkLauncherUpdate() в WebServer для обновления самого лаунчера
- теперь при запуске UI проверяются обновления лаунчера автоматически
2026-05-05 08:53:01 +00:00
SashegDev 53ff8a63f0 fix: исправляем баг с версией сборки
раньше latestVersion вычислялся как currentVersion + 1
теперь честно получаем версию с сервера через PackDownloader.checkForUpdates()

так же обновили места в LaunchMenu и UpdateMenu где используется этот метод
2026-05-05 08:42:51 +00:00
SashegDev df16d17e7d feat(ui): добавляем верификацию файлов и кнопку ОБНОВИТЬ
- спиздили апи файлы из alpha (ApiResponse, AuthService, InstanceService, LaunchService)
- добавили в InstanceInfo поля isServerPack и serverPackName
- CSS: добавили оранжевую .btn-update кнопку
- JS: при загрузке инстанса проверяем целостность (verify) и обновления (updates)
- Кнопка ИГРАТЬ теперь меняется на ОБНОВИТЬ если есть косяки
- ОБНОВИТЬ докачивает/обновляет файлы через повторный install

всё как ты хотел, красава
2026-05-05 08:21:10 +00:00
SashegDev 2e2f89ded6 Merge pull request #5 from SashegDev/ui
feat(ui): add Web UI with JavaFX, install service, and new tests
2026-05-05 09:54:09 +03:00
SashegDev 16882d77e1 feat(ui): add Web UI with JavaFX, install service, and new tests
- Add JavaFX WebView for native window UI (fallback to TUI on headless)
- Create WebServer with Javalin HTTP server
- Add webapp with dark theme and grid animation
- Create InstallService for ZernMC pack installation
- Integrate CLI installation logic via PackDownloader
- Add verifyHashes() using /pack/{name}/diff endpoint
- Add API endpoints: /instances/zernmc/install, /instances/{name}/updates, /instances/{name}/verify, /instances/{name}/playtime
- Add 14 new tests (WebServerTest, HeadlessDetectionTest, InstanceServiceTest)
- Total 44 tests now passing
2026-05-05 06:52:13 +00:00
SashegDev e021d499a9 feat(ui): add Web UI with JavaFX, install service, and new tests
- Add JavaFX WebView for native window UI (fallback to TUI on headless)
- Create WebServer with Javalin HTTP server
- Add webapp with dark theme and grid animation
- Create InstallService for ZernMC pack installation
- Integrate CLI installation logic via PackDownloader
- Add verifyHashes() using /pack/{name}/diff endpoint
- Add API endpoints: /instances/zernmc/install, /instances/{name}/updates, /instances/{name}/verify, /instances/{name}/playtime
- Add 14 new tests (WebServerTest, HeadlessDetectionTest, InstanceServiceTest)
- Total 44 tests now passing
2026-05-05 06:48:27 +00:00
SashegDev 26fb4c3083 Немного подправил инфу 2026-05-05 02:16:06 +03:00
SashegDev 0d9e1b9b95 Merge pull request #4 from SashegDev/alpha
Ептить медж из альфы! СПУСТЯ СТОЛЬКО ВРЕМЕНИ ЕБААААТ
2026-05-05 02:12:31 +03:00
134 changed files with 7106 additions and 23637 deletions
+1 -11
View File
@@ -2,22 +2,12 @@ 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
.env
+33 -50
View File
@@ -8,44 +8,32 @@
- Написан полностью с нуля, без использования готовых решений
- Удобная загрузка и обновление сборок прямо с сервера ZernMC
- Чистый и относительно читаемый код как клиента, так и сервера
- Графический интерфейс на **JavaFX** (по умолчанию) + **TUI** (режим `--cli`)
- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.), обход блокировок через выбор домена и SNI
- Поддержка загрузчиков **Fabric**, **Forge**, **NeoForge** и ванильных версий (установка через встроенный JRE)
- Ротационные логи в стиле Minecraft: `latest.log` + архивы `.log.gz`
- Инкрементальное обновление JavaFX и автообновление лаунчера
- Подпись exe самоподписным сертификатом Zern.cc (jsign/Authenticode)
- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.)
- Возможность запуска клиента даже без графического интерфейса (TUI-режим)
- Полное отсутствие рекламы и телеметрии
- Не собирает никаких данных о пользователе
- Работает «из коробки»: достаточно распаковать архив и запустить `zernmc.exe`
- Работает «из коробки»: достаточно распаковать архив и запустить `ZernMCLauncher.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 рядом не нужен.
## Чего пока нет в лаунчере
- Готовых пресетов оптимизации JVM (настраиваются Java и выделенная память)
- Поддержки Quilt, LabyMod и других лоадеров
- Графического интерфейса (GUI) — только TUI
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
- Поддержки **Forge** (в разработке) (технически уже есть вместе с NeoForge (science PR№4))
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
- Раздела новостей об обновлениях Minecraft и лаунчера
- Выбора готовых пресетов оптимизации JVM
- Кастомных модов (UI, спавнеры, DPI, карточки)
- Сайта для лаунчера и сервера (текущий требует переделки)
- Трекинга наигранного времени (обновление раз в час вместо минуты)
- Сайта для лаунчера и сервера
- Трекинга наигранного времени
## Что планируется доработать в ближайшее время
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
- **GUI мод** — привести в единый стиль с главным меню
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпиленной проверкой, уведомления админу в Telegram с технической информацией
- **Сайт** — полноценный сайт для лаунчера и сервера
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
- **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
@@ -62,7 +50,7 @@ Bootstrap-модуль встроен прямо в `.exe`, отдельный j
### Управление в лаунчере (TUI)
Консольные версии (`zernmc-cli.exe`, а также режим `--cli`) используют **текстовый интерфейс (TUI)**:
Лаунчер использует **текстовый интерфейс (TUI)**:
- `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
- `ENTER` — выбор пункта
@@ -70,23 +58,17 @@ Bootstrap-модуль встроен прямо в `.exe`, отдельный j
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
Основная версия `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
@@ -94,9 +76,9 @@ Bootstrap-модуль встроен прямо в `.exe`, отдельный j
**Инструкция:**
1. Скачайте zip-архив
2. Распакуйте в удобную папку (например `C:\ZernMC`)
3. Запустите `zernmc.exe` (или `zernmc-cli.exe` для консольного режима)
4. Войдите в аккаунт и нажмите **«Начать игру»**
2. Распакуйте в удобную папку
3. Запустите `ZernMCLauncher.exe`
4. Нажмите **«Начать игру»**
### Установка первой сборки
@@ -104,31 +86,28 @@ Bootstrap-модуль встроен прямо в `.exe`, отдельный j
1. Выберите **«Установить новую сборку»**
2. Выберите тип сборки:
- **ZernMC** — готовые сборки с нашего сервера
- **Модовый загрузчик** — Fabric / Forge / NeoForge
- **Модовый загрузчик** — Fabric / Forge / Quilt и т.д.
- **Ваниль** — чистая версия от 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.
**Решение:**
1. Запустите версию и посмотрите в логах, какой `.json` файл он пытается загрузить.
2. Скопируйте правильный файл из `assets/indexes/` и переименуйте его в нужное имя.
### Forge / NeoForge
Работает через установщики лоадеров. Если у вас нет внешнего доступа к Maven-репозиториям — используйте прокси-режим.
### Forge версии
Запуск Forge пока работает нестабильно. Рекомендуется дождаться исправления генерации команды запуска.
### Прокси-режим
Если нет доступа к Fabric/Forge Maven или серверам Mojang — лаунчер пытается использовать наш сервер как прокси. Если и к нему нет доступа — загрузка может не работать.
## Как сообщить об ошибке (Issue)
1. Скопируйте весь текст ошибки из логов (`latest.log`)
1. Скопируйте весь текст ошибки из логов (лаунчер не очищает экран, поэтому просто скролльте вверх)
2. Укажите, что вы делали перед ошибкой
3. Укажите версию лаунчера
4. Создайте Issue на GitHub
@@ -139,21 +118,25 @@ Bootstrap-модуль встроен прямо в `.exe`, отдельный j
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 — за бета-тест лаунчера и фидбеки
Glitch135 - За бета-тест лаунчера и фидбеки
### помощь
Я был бы рад если нашлись люди кто мог бы помочь в разработке лаунчера и добавления к нему хотя бы базового UI чтоб было намногл удобнее работать с ним
Буду рад помощи в разработке!
Особенно приветствуется помощь с:
- Переходом на нормальный графический интерфейс (GUI)
- Улучшением генерации команды запуска
- Добавлением поддержки Quilt / LabyMod
- Добавлением поддержки Forge / NeoForge / Quilt
- Стабилизацией серверной части
- Вообще улучшением клиента
- вообще улучшением клиента
-24
View File
@@ -1,24 +0,0 @@
# 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/
-56
View File
@@ -1,56 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version>
</parent>
<artifactId>zernmc-bootstrap</artifactId>
<packaging>jar</packaging>
<name>ZernMC Bootstrap</name>
<description>Bootstrap module - handles updates and Java launching</description>
<dependencies>
<!-- Minimal dependencies for Bootstrap -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/zernmc-bootstrap.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
File diff suppressed because it is too large Load Diff
+75 -9
View File
@@ -33,6 +33,49 @@
</manifestEntries>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
<filter>
<artifact>org.openjfx:*</artifact>
<excludes>
<exclude>**/*</exclude>
</excludes>
</filter>
</filters>
<dependencySet>
<outputDirectory>/</outputDirectory>
<useProjectArtifact>false</useProjectArtifact>
<unpack>true</unpack>
<scope>runtime</scope>
<excludes>
<exclude>org.openjfx:*</exclude>
</excludes>
</dependencySet>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>copy-javafx</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib-javafx</outputDirectory>
<includeScope>runtime</includeScope>
<includeGroupIds>org.openjfx</includeGroupIds>
</configuration>
</execution>
</executions>
@@ -51,12 +94,16 @@
<configuration>
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
<jar>../server/builds/ZernMCLauncher.jar</jar>
<headerType>console</headerType>
<headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<jre>
<path>jre21</path>
<minVersion>21</minVersion>
<opts>
<opt>--module-path=lib-javafx</opt>
<opt>--add-modules=javafx.controls,javafx.web</opt>
<opt>--add-reads=javafx.graphics=ALL-UNNAMED</opt>
</opts>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
@@ -72,6 +119,9 @@
</configuration>
</execution>
</executions>
<configuration>
<skip>${skip.launch4j}</skip>
</configuration>
</plugin>
<plugin>
<artifactId>maven-antrun-plugin</artifactId>
@@ -85,15 +135,12 @@
<configuration>
<target>
<echo>${project.version}</echo>
<delete />
<mkdir />
<copy>
<fileset>
<include />
<include />
</fileset>
<fileset />
</copy>
<copy>
<fileset />
</copy>
<move />
<zip />
</target>
</configuration>
@@ -119,6 +166,22 @@
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
<profile>
<id>win</id>
<properties>
<os.suffix>win</os.suffix>
<javafx.classifier>win</javafx.classifier>
<skip.launch4j>false</skip.launch4j>
</properties>
</profile>
<profile>
<id>linux</id>
<properties>
<os.suffix>linux</os.suffix>
<javafx.classifier>linux</javafx.classifier>
<skip.launch4j>true</skip.launch4j>
</properties>
</profile>
</profiles>
<dependencies>
<dependency>
@@ -147,7 +210,10 @@
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<maven.compiler.source>21</maven.compiler.source>
<project.organization.name>ZernMC</project.organization.name>
<javafx.classifier>win</javafx.classifier>
<skip.launch4j>false</skip.launch4j>
<maven.compiler.target>21</maven.compiler.target>
<os.suffix>win</os.suffix>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.inceptionYear>2026</project.inceptionYear>
</properties>
-75
View File
@@ -1,75 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version>
</parent>
<artifactId>zernmc-diag</artifactId>
<packaging>jar</packaging>
<name>ZernMC Network Diagnostics</name>
<description>Standalone network diagnostics utility (DNS, connectivity, report upload)</description>
<dependencies>
<dependency>
<groupId>dnsjava</groupId>
<artifactId>dnsjava</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/bin/zernmcdiag.jar</outputFile>
<filters>
<filter>
<artifact>dnsjava:dnsjava</artifact>
<excludes>
<exclude>META-INF/services/java.net.spi.InetAddressResolverProvider</exclude>
<exclude>META-INF/services/sun.net.spi.nameservice.NameServiceDescriptor</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.diag.DiagMain</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
<Implementation-Title>ZernMC Network Diagnostics</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,29 +0,0 @@
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();
}
}
@@ -1,55 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.util.List;
import java.util.Map;
public final class DiagConstants {
public static final String HOST_API = "api.zern.cc";
public static final List<String> HOSTS = List.of(
HOST_API,
"api.zernmc.ru",
"api.zernmc.online",
"api.pl.zern.cc",
"api.swe.zern.cc",
"api.ru.zern.cc"
);
public static final List<String> KNOWN_SERVER_IPS = List.of(
"87.120.187.36", // main (Frankfurt)
"212.22.82.243", // ru geo (Moscow)
"2.26.5.202", // pl geo (Warsaw)
"2.26.55.218" // swe geo (Stockholm)
);
public static final String PRIMARY_API = "https://" + HOST_API;
public static final String DIRECT_API = "http://87.120.187.36:1582";
public static final List<Integer> TLS_PORTS = List.of(443, 80);
public static final int DIRECT_PORT = 1582;
public static final String VERSION_PATH = "/launcher/version";
public static final String CLIENT_IP_PATH = "/launcher/ip";
public static final String DIAG_UPLOAD_PATH = "/diag/upload";
public static final String DOH_GOOGLE_URL = "https://dns.google/resolve?name=%s&type=A";
public static final String DOH_CLOUDFLARE_URL = "https://cloudflare-dns.com/dns-query?name=%s&type=A";
public static final Map<String, String> DNS_PROVIDERS = Map.ofEntries(
Map.entry("Google 8.8.8.8", "8.8.8.8"),
Map.entry("Google 8.8.4.4", "8.8.4.4"),
Map.entry("Cloudflare 1.1.1.1", "1.1.1.1"),
Map.entry("Cloudflare 1.0.0.1", "1.0.0.1"),
Map.entry("Yandex 77.88.8.8", "77.88.8.8"),
Map.entry("Yandex 77.88.8.1", "77.88.8.1"),
Map.entry("OpenDNS 208.67.222.222", "208.67.222.222"),
Map.entry("Quad9 9.9.9.9", "9.9.9.9")
);
public static final int CONNECT_TIMEOUT_MS = 8000;
public static final int READ_TIMEOUT_MS = 8000;
private DiagConstants() {
}
}
@@ -1,79 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
public final class DiagMain {
public static void main(String[] args) {
List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli");
Path baseDir = baseDirFromArgs(argList);
if (cliMode) {
runCli(baseDir);
return;
}
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception ignored) {
}
DiagnosticsUI ui = new DiagnosticsUI(baseDir);
ui.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
System.exit(0);
}
});
ui.setVisible(true);
ui.start();
});
}
private static Path baseDirFromArgs(List<String> args) {
int idx = args.indexOf("--dir");
if (idx >= 0 && idx + 1 < args.size()) {
return Path.of(args.get(idx + 1)).toAbsolutePath();
}
return Path.of("").toAbsolutePath();
}
private static void runCli(Path baseDir) {
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir, System.out::println, null);
Path log = diag.run();
System.out.println("Diagnostics log: " + (log != null ? log.toAbsolutePath() : "n/a"));
System.out.println("Client IP: " + diag.getClientIp());
if (log != null) {
System.out.print("Send report to the Zern server? [y/N]: ");
System.out.flush();
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line = reader.readLine();
if (line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"))) {
ReportSender.SendResult r = ReportSender.send(log, diag.getClientIp());
System.out.println("Send result: " + (r.ok() ? "OK via " + r.endpoint() : "FAILED (" + r.detail() + ")"));
}
} catch (Exception e) {
System.out.println("Send skipped: " + e.getMessage());
}
}
int exitCode = 0;
for (DiagCheck c : diag.getChecks()) {
if ("FAIL".equals(c.status()) && c.category().equals("HTTP")) {
exitCode = 1;
}
}
System.exit(exitCode);
}
}
@@ -1,194 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.GridLayout;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingWorker;
import javax.swing.border.EmptyBorder;
public final class DiagnosticsUI extends JFrame {
private static final Color BG = new Color(0x15151a);
private static final Color PANEL = new Color(0x1e1e24);
private static final Color TEXT = new Color(0xe8e8ea);
private static final Color ACCENT = new Color(0x5dade2);
private final Path baseDir;
private final JTextArea output = new JTextArea();
private final JButton sendButton = new JButton("Отправить на сервер");
private final JLabel statusLabel = new JLabel("Запуск диагностики...");
private volatile Path diagLog;
private volatile String clientIp = "unknown";
public DiagnosticsUI(Path baseDir) {
this.baseDir = baseDir;
setTitle("ZernMC — Диагностика сети");
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(760, 560);
setMinimumSize(new Dimension(600, 420));
setLocationRelativeTo(null);
getContentPane().setBackground(BG);
((JPanel) getContentPane()).setBorder(new EmptyBorder(16, 16, 16, 16));
JLabel header = new JLabel("Проверка связи с серверами Zern");
header.setFont(new Font("Segoe UI", Font.BOLD, 17));
header.setForeground(ACCENT);
header.setBorder(new EmptyBorder(0, 0, 10, 0));
output.setEditable(false);
output.setFont(new Font("Consolas", Font.PLAIN, 12));
output.setBackground(PANEL);
output.setForeground(TEXT);
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JScrollPane scroll = new JScrollPane(output);
scroll.setBorder(BorderFactory.createLineBorder(PANEL));
statusLabel.setForeground(TEXT);
statusLabel.setBorder(new EmptyBorder(8, 0, 0, 0));
JButton openButton = new JButton("Открыть папку с логом");
JButton closeButton = new JButton("Закрыть");
sendButton.setEnabled(false);
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
buttons.setOpaque(false);
buttons.add(openButton);
buttons.add(sendButton);
buttons.add(closeButton);
JPanel south = new JPanel(new BorderLayout());
south.setOpaque(false);
south.add(statusLabel, BorderLayout.NORTH);
south.add(buttons, BorderLayout.SOUTH);
setLayout(new BorderLayout());
add(header, BorderLayout.NORTH);
add(scroll, BorderLayout.CENTER);
add(south, BorderLayout.SOUTH);
openButton.addActionListener(e -> openFolder());
closeButton.addActionListener(e -> dispose());
sendButton.addActionListener(e -> sendReport());
}
public void start() {
SwingWorker<Void, String> worker = new SwingWorker<>() {
@Override
protected Void doInBackground() {
publish("\u2014 Сервер Zern недоступен, выполняем диагностику \u2014");
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir,
this::publish,
c -> publish(renderCheck(c)));
diagLog = diag.run();
clientIp = diag.getClientIp();
publish("Диагностика завершена. Лог: " + (diagLog != null ? diagLog.toAbsolutePath() : "n/a"));
return null;
}
@Override
protected void process(java.util.List<String> chunks) {
for (String line : chunks) {
appendLine(line);
}
}
@Override
protected void done() {
sendButton.setEnabled(diagLog != null);
statusLabel.setText("Готово. Можете отправить лог на сервер или сохранить его.");
}
};
worker.execute();
}
private static String renderCheck(DiagCheck c) {
String prefix = switch (c.status()) {
case "OK" -> "[OK] ";
case "FAIL" -> "[FAIL]";
default -> "[SKIP]";
};
StringBuilder sb = new StringBuilder(prefix).append(' ').append(c.category()).append(" / ").append(c.name());
if (c.latencyMs() >= 0) {
sb.append(" (").append(c.latencyMs()).append(" ms)");
}
if (c.detail() != null && !c.detail().isEmpty()) {
sb.append(" -> ").append(c.detail());
}
return sb.toString();
}
private void appendLine(String line) {
output.append(line);
if (!line.endsWith("\n")) {
output.append("\n");
}
output.setCaretPosition(output.getDocument().getLength());
}
private void openFolder() {
try {
Path dir = diagLog != null ? diagLog.getParent() : baseDir;
if (dir != null && Files.exists(dir)) {
Desktop.getDesktop().open(dir.toFile());
} else {
JOptionPane.showMessageDialog(this, "Папка ещё не создана.");
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "Не удалось открыть папку: " + e.getMessage());
}
}
private void sendReport() {
if (diagLog == null) {
return;
}
sendButton.setEnabled(false);
statusLabel.setText("Отправка отчёта на сервер...");
SwingWorker<ReportSender.SendResult, Void> worker = new SwingWorker<>() {
@Override
protected ReportSender.SendResult doInBackground() throws Exception {
return ReportSender.send(diagLog, clientIp);
}
@Override
protected void done() {
sendButton.setEnabled(true);
try {
ReportSender.SendResult r = get();
if (r.ok()) {
statusLabel.setText("Отчёт отправлен: " + r.endpoint());
JOptionPane.showMessageDialog(DiagnosticsUI.this,
"Отчёт успешно отправлен на сервер Zern.",
"Отправлено", JOptionPane.INFORMATION_MESSAGE);
} else {
statusLabel.setText("Не удалось отправить отчёт.");
JOptionPane.showMessageDialog(DiagnosticsUI.this,
"Не удалось отправить отчёт на сервер.\n"
+ "Сохраните лог и отправьте его вручную:\n" + diagLog.toAbsolutePath()
+ "\n\nОшибка: " + r.detail(),
"Ошибка отправки", JOptionPane.WARNING_MESSAGE);
}
} catch (Exception e) {
statusLabel.setText("Ошибка отправки: " + e.getMessage());
}
}
};
worker.execute();
}
}
@@ -1,128 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.net.InetAddress;
import java.net.URL;
import java.net.URLConnection;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import org.xbill.DNS.ARecord;
import org.xbill.DNS.Lookup;
import org.xbill.DNS.Record;
import org.xbill.DNS.SimpleResolver;
import org.xbill.DNS.Type;
public final class DnsResolver {
private DnsResolver() {
}
public static List<String> resolveSystem(String host) {
try {
InetAddress[] addrs = InetAddress.getAllByName(host);
List<String> ips = new ArrayList<>();
for (InetAddress a : addrs) {
String ip = a.getHostAddress();
if (ip != null && !ips.contains(ip)) {
ips.add(ip);
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveDoHGoogle(String host) {
try {
String url = String.format(DiagConstants.DOH_GOOGLE_URL, host);
String body = doGet(url, "application/json");
JSONObject json = new JSONObject(body);
JSONArray answers = json.optJSONArray("Answer");
if (answers == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (int i = 0; i < answers.length(); i++) {
JSONObject a = answers.optJSONObject(i);
if (a != null && a.optInt("type", 0) == 1) {
String data = a.optString("data", null);
if (data != null && !ips.contains(data)) {
ips.add(data);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveDoHCloudflare(String host) {
try {
String url = String.format(DiagConstants.DOH_CLOUDFLARE_URL, host);
String body = doGet(url, "application/dns-json");
JSONObject json = new JSONObject(body);
JSONArray answers = json.optJSONArray("Answer");
if (answers == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (int i = 0; i < answers.length(); i++) {
JSONObject a = answers.optJSONObject(i);
if (a != null && a.optInt("type", 0) == 1) {
String data = a.optString("data", null);
if (data != null && !ips.contains(data)) {
ips.add(data);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveUdp(String providerIp, String host) {
try {
SimpleResolver resolver = new SimpleResolver(providerIp);
resolver.setTimeout(Duration.ofSeconds(5));
Lookup lookup = new Lookup(org.xbill.DNS.Name.fromString(host + "."), Type.A);
lookup.setResolver(resolver);
lookup.setCache(null);
Record[] records = lookup.run();
if (records == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (Record r : records) {
if (r instanceof ARecord a) {
String ip = a.getAddress().getHostAddress();
if (ip != null && !ips.contains(ip)) {
ips.add(ip);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
private static String doGet(String url, String accept) throws Exception {
URLConnection conn = new URL(url).openConnection();
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
conn.setRequestProperty("Accept", accept);
conn.setRequestProperty("User-Agent", "ZernMC-Diag");
conn.connect();
byte[] buf;
try (var in = conn.getInputStream()) {
buf = in.readAllBytes();
}
return new String(buf, java.nio.charset.StandardCharsets.UTF_8);
}
}
@@ -1,58 +0,0 @@
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();
}
}
}
}
@@ -1,263 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.json.JSONObject;
public final class NetworkDiagnostics {
private final Path baseDir;
private final Consumer<String> onLine;
private final Consumer<DiagCheck> onCheck;
private final List<DiagCheck> checks = new CopyOnWriteArrayList<>();
private final StringBuilder report = new StringBuilder();
private String clientIp = "unknown";
public NetworkDiagnostics(Path baseDir, Consumer<String> onLine, Consumer<DiagCheck> onCheck) {
this.baseDir = baseDir;
this.onLine = onLine;
this.onCheck = onCheck;
}
public List<DiagCheck> getChecks() {
return checks;
}
public String getClientIp() {
return clientIp;
}
public Path run() {
checks.clear();
report.setLength(0);
report.append("=== ZernMC Network Diagnostics Report ===\n");
report.append("Timestamp: ").append(timestamp()).append('\n');
report.append("Base dir: ").append(baseDir.toAbsolutePath()).append('\n');
report.append("Zern server known IP(s): ").append(String.join(", ", DiagConstants.KNOWN_SERVER_IPS)).append('\n');
report.append("Primary API: ").append(DiagConstants.PRIMARY_API).append('\n');
report.append("Direct API: ").append(DiagConstants.DIRECT_API).append('\n');
runDnsChecks();
runConnectivityChecks();
runHttpChecks();
clientIp = determineClientIp();
int ok = 0, fail = 0, skip = 0;
for (DiagCheck c : checks) {
switch (c.status()) {
case "OK" -> ok++;
case "FAIL" -> fail++;
default -> skip++;
}
}
report.append("=== Summary ===\n");
report.append("OK: ").append(ok).append(", FAIL: ").append(fail).append(", SKIP: ").append(skip).append('\n');
report.append("--- JSON ---\n");
JSONObject json = new JSONObject();
json.put("client_ip", clientIp);
json.put("timestamp", timestamp());
JSONObject jsonChecks = new JSONObject();
for (DiagCheck c : checks) {
JSONObject j = new JSONObject();
j.put("status", c.status());
j.put("detail", c.detail() == null ? "" : c.detail());
j.put("latency_ms", c.latencyMs());
jsonChecks.put(c.category() + "/" + c.name(), j);
}
json.put("checks", jsonChecks);
report.append(json.toString(2)).append('\n');
return writeLog();
}
private Path writeLog() {
try {
Files.createDirectories(baseDir);
Path log = baseDir.resolve("diag.log");
Files.writeString(log, report.toString(), StandardCharsets.UTF_8);
if (onLine != null) {
onLine.accept("\nDiagnostic log written to: " + log.toAbsolutePath());
}
return log;
} catch (IOException e) {
if (onLine != null) {
onLine.accept("Failed to write diag.log: " + e.getMessage());
}
return null;
}
}
private void runDnsChecks() {
report.append("\n=== DNS resolution ===\n");
for (String host : DiagConstants.HOSTS) {
report.append("Host: ").append(host).append('\n');
submit(resolveToCheck(host, "system", DnsResolver.resolveSystem(host)));
}
List<Callable<DiagCheck>> tasks = new ArrayList<>();
List<String> taskKeys = new ArrayList<>();
for (String host : DiagConstants.HOSTS) {
tasks.add(() -> resolveToCheck(host, "DoH Google", DnsResolver.resolveDoHGoogle(host)));
taskKeys.add("DoH Google/" + host);
tasks.add(() -> resolveToCheck(host, "DoH Cloudflare", DnsResolver.resolveDoHCloudflare(host)));
taskKeys.add("DoH Cloudflare/" + host);
for (String provider : DiagConstants.DNS_PROVIDERS.keySet()) {
String providerIp = DiagConstants.DNS_PROVIDERS.get(provider);
tasks.add(() -> resolveToCheck(host, "UDP " + provider, DnsResolver.resolveUdp(providerIp, host)));
taskKeys.add("UDP " + provider + "/" + host);
}
}
ExecutorService pool = Executors.newFixedThreadPool(Math.min(8, tasks.size()), r -> {
Thread t = new Thread(r, "diag-dns");
t.setDaemon(true);
return t;
});
try {
List<Future<DiagCheck>> futures = new ArrayList<>();
for (Callable<DiagCheck> task : tasks) {
futures.add(pool.submit(task));
}
int idx = 0;
for (Future<DiagCheck> f : futures) {
String key = taskKeys.get(idx);
try {
DiagCheck c = f.get(20, TimeUnit.SECONDS);
submit(c);
} catch (Exception e) {
submit(new DiagCheck("DNS", key, "FAIL", "query error: " + e.getMessage(), -1));
}
idx++;
}
} finally {
pool.shutdownNow();
}
}
static DiagCheck resolveToCheck(String host, String source, List<String> ips) {
if (ips.isEmpty()) {
return new DiagCheck("DNS", host + " / " + source, "FAIL", "no A records", -1);
}
boolean matchesKnown = false;
for (String ip : ips) {
if (DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
matchesKnown = true;
break;
}
}
if (matchesKnown) {
return new DiagCheck("DNS", host + " / " + source, "OK", String.join(", ", ips), -1);
}
String detail = "resolved to " + String.join(", ", ips) + " (DIFFERENT from known " + String.join(", ", DiagConstants.KNOWN_SERVER_IPS) + ")";
return new DiagCheck("DNS", host + " / " + source, "FAIL", detail, -1);
}
private void runConnectivityChecks() {
report.append("\n=== Connectivity ===\n");
for (String ip : DiagConstants.KNOWN_SERVER_IPS) {
for (int port : DiagConstants.TLS_PORTS) {
submit(tcpToCheck(ip, port));
}
}
submit(tcpToCheck("87.120.187.36", DiagConstants.DIRECT_PORT));
List<String> systemIps = DnsResolver.resolveSystem(DiagConstants.HOST_API);
for (String ip : systemIps) {
if (!DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
submit(tcpToCheck(ip, 443));
}
}
}
private DiagCheck tcpToCheck(String host, int port) {
NetProbe.TcpResult r = NetProbe.tcpConnect(host, port, DiagConstants.CONNECT_TIMEOUT_MS);
return new DiagCheck("TCP", host + ":" + port, r.ok() ? "OK" : "FAIL", r.detail(), r.latencyMs());
}
private void runHttpChecks() {
report.append("\n=== HTTP ===\n");
for (String host : DiagConstants.HOSTS) {
submit(httpToCheck("https://" + host + DiagConstants.VERSION_PATH));
}
submit(httpToCheck(DiagConstants.DIRECT_API + DiagConstants.VERSION_PATH));
}
private DiagCheck httpToCheck(String url) {
NetProbe.HttpResult r = NetProbe.httpGet(url, DiagConstants.READ_TIMEOUT_MS);
String detail = r.ok() ? r.detail() + (r.bodySnippet().isEmpty() ? "" : " body=" + r.bodySnippet()) : r.detail();
return new DiagCheck("HTTP", url, r.ok() ? "OK" : "FAIL", detail, r.latencyMs());
}
private String determineClientIp() {
String ip = null;
NetProbe.HttpResult direct = NetProbe.httpGet(DiagConstants.DIRECT_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
if (direct.ok()) {
ip = parseIp(direct.bodySnippet());
}
if (ip == null) {
NetProbe.HttpResult primary = NetProbe.httpGet(DiagConstants.PRIMARY_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
if (primary.ok()) {
ip = parseIp(primary.bodySnippet());
}
}
String result = ip != null ? ip : "unknown";
submit(new DiagCheck("INFO", "client public IP", ip != null ? "OK" : "FAIL", ip != null ? ip : "could not determine", -1));
report.append("=== Client IP ===\nClient public IP: ").append(result).append('\n');
return result;
}
static String parseIp(String snippet) {
if (snippet == null) {
return null;
}
try {
int start = snippet.indexOf("\"ip\"");
if (start < 0) {
return null;
}
int colon = snippet.indexOf(':', start);
int q1 = snippet.indexOf('"', colon);
int q2 = snippet.indexOf('"', q1 + 1);
if (q1 < 0 || q2 < 0) {
return null;
}
String ip = snippet.substring(q1 + 1, q2);
return ip.matches("[\\d.]+") ? ip : null;
} catch (Exception e) {
return null;
}
}
private void submit(DiagCheck c) {
checks.add(c);
report.append(c.renderLine()).append('\n');
if (onCheck != null) {
onCheck.accept(c);
}
if (onLine != null) {
onLine.accept(c.renderLine());
}
}
private static String timestamp() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
}
}
@@ -1,69 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
public final class ReportSender {
public record SendResult(boolean ok, String endpoint, String detail) {
}
private ReportSender() {
}
public static String buildFilename(String clientIp) {
String ts = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
String ip = (clientIp == null || clientIp.isBlank()) ? "unknown" : clientIp;
return "diag_" + ts + "_" + ip + ".log";
}
public static SendResult send(Path logFile, String clientIp) throws IOException {
String content = Files.readString(logFile, StandardCharsets.UTF_8);
String name = buildFilename(clientIp);
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8);
List<String> endpoints = List.of(
DiagConstants.PRIMARY_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded,
DiagConstants.DIRECT_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded
);
String lastDetail = null;
for (String endpoint : endpoints) {
try {
HttpURLConnection conn = (HttpURLConnection) URI.create(endpoint).toURL().openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "text/plain; charset=utf-8");
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
byte[] body = content.getBytes(StandardCharsets.UTF_8);
conn.setFixedLengthStreamingMode(body.length);
try (OutputStream out = conn.getOutputStream()) {
out.write(body);
}
int status = conn.getResponseCode();
boolean ok = status >= 200 && status < 300;
String detail = "HTTP " + status;
conn.disconnect();
return new SendResult(ok, endpoint, detail);
} catch (Exception e) {
lastDetail = e.getMessage();
}
}
return new SendResult(false, lastEndpointUsed(endpoints), lastDetail != null ? lastDetail : "all endpoints failed");
}
private static String lastEndpointUsed(List<String> endpoints) {
return endpoints.get(endpoints.size() - 1);
}
}
@@ -1,35 +0,0 @@
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());
}
}
@@ -1,52 +0,0 @@
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\"}"));
}
}
@@ -1,24 +0,0 @@
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);
}
}
-412
View File
@@ -1,412 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version>
</parent>
<artifactId>zernmclauncher</artifactId>
<packaging>jar</packaging>
<name>ZernMC Launcher</name>
<description>Main launcher module with JFX UI</description>
<dependencies>
<!-- HTTP Client -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<!-- JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>
<!-- Console/Terminal -->
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
</dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
</dependency>
<dependency>
<groupId>me.tongfei</groupId>
<artifactId>progressbar</artifactId>
</dependency>
<!-- IO -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
<!-- JavaFX - Windows -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>23.0.1</version>
<classifier>win</classifier>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>23.0.1</version>
<classifier>win</classifier>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>23.0.1</version>
<classifier>win</classifier>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>23.0.1</version>
<classifier>win</classifier>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>23.0.1</version>
<classifier>win</classifier>
<scope>provided</scope>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/resources</directory>
<targetPath>assets</targetPath>
<includes>
<include>ui/**</include>
</includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/zernmclauncher.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<!-- Launch4j для создания .exe из bootstrap JAR -->
<plugin>
<groupId>com.akathist.maven.plugins.launch4j</groupId>
<artifactId>launch4j-maven-plugin</artifactId>
<version>2.5.0</version>
<executions>
<!-- GUI версия (основная) - без консоли -->
<execution>
<id>l4j-gui</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<icon>${project.basedir}/src/main/icons/zernmc.ico</icon>
<headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<addDependencies>false</addDependencies>
</classPath>
<cmdLine>--jfx</cmdLine>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher</fileDescription>
<copyright>Copyright (c) 2023-2026 ZernMC</copyright>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC</productName>
<companyName>ZernMC</companyName>
<trademarks>ZernMC</trademarks>
<internalName>zernmc</internalName>
<originalFilename>zernmc.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
<!-- CLI версия - с консолью -->
<execution>
<id>l4j-cli</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc-cli.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<addDependencies>false</addDependencies>
</classPath>
<cmdLine>--cli</cmdLine>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher CLI</fileDescription>
<copyright>Copyright (c) 2023-2026 ZernMC</copyright>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC CLI</productName>
<companyName>ZernMC</companyName>
<trademarks>ZernMC</trademarks>
<internalName>zernmc-cli</internalName>
<originalFilename>zernmc-cli.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
<!-- CLI+JFX версия: консоль видима + форсим JFX UI -->
<execution>
<id>l4j-cli-jfx</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc-cli-jfx.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<addDependencies>false</addDependencies>
</classPath>
<cmdLine>--jfx</cmdLine>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher CLI + JFX</fileDescription>
<copyright>Copyright (c) 2023-2026 ZernMC</copyright>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC CLI JFX</productName>
<companyName>ZernMC</companyName>
<trademarks>ZernMC</trademarks>
<internalName>zernmc-cli-jfx</internalName>
<originalFilename>zernmc-cli-jfx.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
</executions>
</plugin>
<!-- Post-build: копирование JRE и создание ZIP -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>run</goal></goals>
<configuration>
<target>
<echo file="../../server/builds/build.version">${project.version}.${hotfix}</echo>
<!-- Удаляем старую папку lib если есть -->
<delete dir="../../server/builds/lib"/>
<!-- Создаем папку lib -->
<mkdir dir="../../server/builds/lib"/>
<!-- Копируем JRE в lib/jre21 -->
<copy todir="../../server/builds/lib/jre21" overwrite="true">
<fileset dir="${user.home}/launcher/jre/jre21">
<include name="*"/>
<include name="**/*"/>
</fileset>
</copy>
<!-- Копируем JavaFX модули в lib/javafx -->
<mkdir dir="../../server/builds/lib/javafx"/>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/23.0.1">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/23.0.1">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/23.0.1">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/23.0.1">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/23.0.1">
<include name="*win.jar"/>
</fileset>
</copy>
<!-- Создаем папку bin и копируем JAR -->
<mkdir dir="../../server/builds/bin"/>
<copy file="../../server/builds/zernmclauncher.jar"
tofile="../../server/builds/bin/zernmclauncher.jar" overwrite="true"/>
<!-- Копируем UI в assets -->
<mkdir dir="../../server/builds/assets"/>
<copy todir="../../server/builds/assets/ui" overwrite="true">
<fileset dir="${project.basedir}/src/resources/ui">
<include name="**/*"/>
</fileset>
</copy>
<!-- Подпись экзешников самоподписным сертификатом Zern.cc (publisher: ZernMC Launcher) -->
<loadfile property="pfxpass" srcFile="/root/cert/codesign/.pfxpass">
<filterchain>
<striplinebreaks/>
</filterchain>
</loadfile>
<exec executable="java" failonerror="true">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/zernmc.exe"/>
</exec>
<exec executable="java" failonerror="true">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/zernmc-cli.exe"/>
</exec>
<exec executable="java" failonerror="true">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/zernmc-cli-jfx.exe"/>
</exec>
<!-- Создаём README -->
<echo file="../../server/builds/README.txt">
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)
</echo>
<!-- Создаём один архив со всем -->
<zip destfile="../../server/builds/ZernMC-win-${revision}.${hotfix}.zip"
basedir="../../server/builds"
includes="zernmc.exe,zernmc-cli.exe,zernmc-cli-jfx.exe,bin/**,assets/**,lib/**,README.txt"
excludes="build.version,*.jar"/>
</target>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="64" fill="url(#g)"/>
<path d="M82 128 L128 82 L174 128 L128 174 Z" fill="white" opacity="0.9"/>
</svg>

Before

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

@@ -1,337 +0,0 @@
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<String> 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<Path> 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<String> 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<String> 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;
}
}
@@ -1,137 +0,0 @@
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<String> 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<String> 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();
}
}
}
}
@@ -1,199 +0,0 @@
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<AuthService.SessionInfo> checkSession() {
return authService.checkSession();
}
public ApiResponse<AuthService.LoginResult> login(String username, String password) {
return authService.login(username, password);
}
public ApiResponse<Boolean> logout() {
return authService.logout();
}
public ApiResponse<Boolean> activatePass(String passCode) {
return authService.activatePass(passCode);
}
public ApiResponse<AuthService.LoginResult> register(String username, String password) {
return authService.register(username, password);
}
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
return instanceService.getAllInstances();
}
public ApiResponse<LaunchService.InstanceInfo> getLaunchInfo(String instanceName) {
return launchService.getLaunchInfo(instanceName);
}
public ApiResponse<LaunchService.LaunchInfo> prepareLaunch(String instanceName) {
return launchService.prepareLaunch(instanceName);
}
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
return launchService.launch(instanceName);
}
public ApiResponse<List<String>> getMCVersions() {
try {
org.json.JSONObject manifest = ZHttpClient.getMojangVersionManifest();
org.json.JSONArray versions = manifest.getJSONArray("versions");
List<String> 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<List<String>> getLoaderVersions(String mcVersion, String loader) {
try {
List<String> 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("<version>", idx)) != -1) {
int start = idx + 9;
int end = xml.indexOf("</version>", 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("<version>", neoidx)) != -1) {
int start = neoidx + 9;
int end = neoforgeXml.indexOf("</version>", 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<List<Map<String, String>>> 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<Map<String, String>> packs = new ArrayList<>();
if (arr != null) {
for (int i = 0; i < arr.length(); i++) {
org.json.JSONObject pack = arr.getJSONObject(i);
Map<String, String> 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());
}
}
}
@@ -1,302 +0,0 @@
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<Long, Process> 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<LaunchInfo> 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<String> 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<ProcessInfo> 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<ProcessInfo> launchOffline(String instanceName, String nickname) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName);
}
LauncherLogger.info("Offline launch: " + instanceName);
String safeName = (nickname == null || nickname.isBlank()) ? "Player" : nickname.trim();
LaunchOptions options = createOptions();
options.setUsername(safeName);
options.setAccessToken("0");
options.setUuid(java.util.UUID.nameUUIDFromBytes(
("OfflinePlayer:" + safeName).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString());
return launchProcess(instance, instanceName, options);
} catch (Exception e) {
LauncherLogger.error("Offline launch error for " + instanceName, e);
return ApiResponse.error("Offline launch error: " + e.getMessage());
}
}
private ApiResponse<ProcessInfo> launchProcess(Instance instance, String instanceName, LaunchOptions options) {
try {
long t0 = System.currentTimeMillis();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms");
LauncherLogger.info("Generated command for " + instanceName + ":");
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<Boolean> 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<InstanceInfo> 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<String> 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<String> command;
private String workingDirectory;
public LaunchInfo(String instanceName, List<String> command, String workingDirectory) {
this.instanceName = instanceName;
this.command = command;
this.workingDirectory = workingDirectory;
}
public String getInstanceName() { return instanceName; }
public List<String> 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; }
}
}
@@ -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<String> 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.");
}
}
@@ -1,836 +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.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<ServerPack> 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<ServerPack> parsePacksResponse(String responseBody) {
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray packsArray = root.getAsJsonArray("packs");
List<ServerPack> 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<String, String> 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<String> 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<String, String> 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<String, String> localFiles = scanLocalFiles();
// Detect user-modified files (skip overwriting)
Set<String> 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<String, String> updatedHashes = scanLocalFiles();
saveUserFilesSnapshot(updatedHashes);
instance.setServerVersion(serverVersion);
LauncherLogger.info("Pack updated to v" + serverVersion);
}
return success;
}
/**
* Scan local files and compute hashes
*/
private Map<String, String> scanLocalFiles() throws IOException {
Map<String, String> files = new HashMap<>();
Path instancePath = instance.getPath();
// Игнорируемые директории
Set<String> 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<String, String> localFiles) throws Exception {
return getDiff(packName, localFiles, null);
}
private DiffResponse getDiff(String packName, Map<String, String> 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<String> protectedFiles) {
return applyDiff(diff, packName, protectedFiles, null);
}
private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles, String preset) {
LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size());
LauncherLogger.info("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<String, String> 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<String, String> loadUserFilesSnapshot() {
Path path = getUserFilesPath();
if (!Files.exists(path)) return new HashMap<>();
try {
String content = Files.readString(path);
Map<String, Object> raw = gson.fromJson(content, Map.class);
Map<String, String> result = new HashMap<>();
if (raw != null) {
for (Map.Entry<String, Object> 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<String> getUserModifiedFiles(Map<String, String> currentHashes) {
Map<String, String> snapshot = loadUserFilesSnapshot();
if (snapshot.isEmpty()) return new HashSet<>();
Set<String> modified = new HashSet<>();
for (Map.Entry<String, String> 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<String, Object> 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<String, Object> 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/&lt;mc&gt;/&lt;mc&gt;.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<FileInfo> to_download;
private List<String> to_delete;
private List<String> to_update;
public int getVersion() { return version; }
public List<FileInfo> getToDownload() { return to_download != null ? to_download : new ArrayList<>(); }
public List<String> getToDelete() { return to_delete != null ? to_delete : new ArrayList<>(); }
public List<String> 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; }
}
}
@@ -1,249 +0,0 @@
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("<latest>") + 8;
int end = xml.indexOf("</latest>", 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);
}
}
@@ -1,219 +0,0 @@
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());
}
}
@@ -1,234 +0,0 @@
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());
}
}
@@ -1,208 +0,0 @@
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());
}
}
@@ -1,370 +0,0 @@
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<MinecraftVersion> 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<MinecraftVersion> 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<CompletableFuture<Void>> 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<Void> 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");
}
}
@@ -1,101 +0,0 @@
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;
}
}
}
@@ -1,269 +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 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";
}
}
@@ -1,133 +0,0 @@
package me.sashegdev.zernmc.launcher.utils;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Picks the fastest reachable ZernMC API domain at launcher startup.
* TSPU/SNI-обход: если основной api.zern.cc недоступен (заблокирован ТСПУ),
* пробиваем legacy-домены и гео-зеркала и переключаемся на первый живой/быстрый.
*/
public final class DomainSelector {
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8))
.version(HttpClient.Version.HTTP_1_1)
.build();
private static final List<String> FALLBACK_CANDIDATES = List.of(
"https://api.zern.cc",
"https://api.zernmc.ru",
"https://api.zernmc.online",
"https://api.pl.zern.cc",
"https://api.swe.zern.cc"
);
private static final String MIRRORS_PATH = "/launcher/mirrors";
private static final String VERSION_PATH = "/launcher/version";
private DomainSelector() {
}
/**
* @return выбранный base URL или null, если ни один домен не ответил.
*/
public static String selectBest() {
List<String> candidates = new ArrayList<>();
String configured = Config.getServerUrl();
if (configured != null && !configured.isBlank() && !candidates.contains(configured)) {
candidates.add(configured);
}
for (String c : FALLBACK_CANDIDATES) {
if (!candidates.contains(c)) {
candidates.add(c);
}
}
candidates.addAll(fetchMirrors());
String best = null;
long bestMs = Long.MAX_VALUE;
for (String base : candidates) {
Probe r = probe(base);
if (r.ok && r.latencyMs >= 0 && r.latencyMs < bestMs) {
bestMs = r.latencyMs;
best = base;
}
}
if (best != null) {
// 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<String> fetchMirrors() {
List<String> mirrors = new ArrayList<>();
for (String candidate : FALLBACK_CANDIDATES) {
Probe p = probe(candidate);
if (!p.ok || p.body == null) {
continue;
}
try {
org.json.JSONObject json = new org.json.JSONObject(p.body);
var arr = json.optJSONArray("mirrors");
if (arr == null) {
continue;
}
for (int i = 0; i < arr.length(); i++) {
String url = arr.optJSONObject(i) != null
? arr.optJSONObject(i).optString("url", null) : null;
if (url != null && !url.isBlank() && !mirrors.contains(url)) {
mirrors.add(url);
}
}
break; // первый живой источник списка достаточен
} catch (Exception ignored) {
// continue to next candidate
}
}
return mirrors;
}
private static Probe probe(String baseUrl) {
long t0 = System.currentTimeMillis();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + MIRRORS_PATH))
.timeout(Duration.ofSeconds(8))
.GET()
.header("User-Agent", "ZernMC-Launcher/DomainSelector")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
long ms = System.currentTimeMillis() - t0;
boolean ok = response.statusCode() == 200;
return new Probe(ok, ms, ok ? response.body() : null);
} catch (Exception e) {
return new Probe(false, System.currentTimeMillis() - t0, null);
}
}
private record Probe(boolean ok, long latencyMs, String body) {
}
}
@@ -1,42 +0,0 @@
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;
}
}
@@ -1,209 +0,0 @@
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<String> stdoutQueue = new LinkedBlockingQueue<>(STDOUT_QUEUE_CAPACITY);
private static final Thread stdoutWriterThread;
private static final Pattern ANSI_PATTERN = Pattern.compile("\u001B\\[[0-9;]*[A-Za-z]");
static {
stdoutWriterThread = new Thread(() -> {
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();
}
}
}
@@ -1,123 +0,0 @@
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";
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

@@ -1,618 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<canvas id="bg-canvas"></canvas>
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-container" id="login-container">
<div class="login-header">
<div class="brand-icon">
<svg width="40" height="40" viewBox="0 0 56 56" fill="none">
<rect width="56" height="56" rx="14" fill="url(#brandGrad)"/>
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
<defs>
<linearGradient id="brandGrad" x1="0" y1="0" x2="56" y2="56">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
</svg>
</div>
<h1 class="login-title" id="login-title" data-i18n="login.title">Sign In</h1>
<p class="login-subtitle" id="login-subtitle" data-i18n="login.subtitle">Welcome back to ZernMC</p>
<span id="version" class="hidden">__LAUNCHER_VERSION__</span>
</div>
<form id="login-form" class="login-form">
<div class="field">
<label for="username" data-i18n="login.username">Username</label>
<input type="text" id="username" autocomplete="username" required>
</div>
<div class="field">
<label for="password" data-i18n="login.password">Password</label>
<input type="password" id="password" autocomplete="current-password" required>
<span class="field-hint hidden" id="password-hint" data-i18n="login.passTooShort"></span>
</div>
<div class="field hidden" id="confirm-field">
<label for="confirm-password" data-i18n="login.confirm">Confirm Password</label>
<input type="password" id="confirm-password" autocomplete="new-password">
</div>
<p id="login-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="login-btn">
<span class="btn-label" id="login-btn-label" data-i18n="login.title">Sign In</span>
<div class="spinner hidden"></div>
</button>
<div class="login-footer" id="login-footer">
<span id="footer-hint" data-i18n="login.hint">Need an account?</span>
<span class="login-footer-action" id="footer-action" data-i18n="login.register">Register</span>
</div>
</form>
</div>
</div>
<!-- Offline Screen -->
<div id="offline-screen" class="screen hidden">
<div class="offline-container">
<div class="offline-badge">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
</div>
<h2 class="offline-title" data-i18n="offline.title">Offline Mode</h2>
<p class="offline-subtitle" data-i18n="offline.subtitle">The Zern server is unreachable. You can still launch locally installed packs without an account.</p>
<form id="offline-form" class="login-form">
<div class="field">
<label for="offline-nickname" data-i18n="offline.nickname">Nickname</label>
<input type="text" id="offline-nickname" maxlength="16" autocomplete="off">
</div>
<div class="field">
<label for="offline-instance" data-i18n="offline.instance">Pack</label>
<select id="offline-instance"></select>
</div>
<p id="offline-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="offline-launch-btn">
<span class="btn-label" data-i18n="offline.launch">Launch Offline</span>
<div class="spinner hidden"></div>
</button>
</form>
<div class="offline-footer">
<button type="button" class="btn-ghost" id="offline-retry-btn" data-i18n="offline.retry">Try again online</button>
<button type="button" class="btn-ghost" id="offline-exit-btn" data-i18n="offline.exit">Exit</button>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="overlay hidden">
<div class="loader-ring"></div>
<p class="loader-text" data-i18n="loading.text">Loading...</p>
</div>
<!-- Main Screen -->
<div id="main-screen" class="screen hidden">
<div class="shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-brand">
<svg width="32" height="32" viewBox="0 0 56 56" fill="none">
<rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/>
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
<defs>
<linearGradient id="brandGrad2" x1="0" y1="0" x2="56" y2="56">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
</svg>
<div class="sidebar-brand-text">
<span class="sidebar-brand-name">ZernMC</span>
<span class="sidebar-brand-ver">v<span id="header-version">__LAUNCHER_VERSION__</span></span>
</div>
</div>
<nav class="sidebar-nav">
<div class="sidebar-nav-primary">
<button class="nav-btn active" data-view="packs" title="Packs">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
<span data-i18n="nav.packs">Packs</span>
</button>
<button class="nav-btn" data-view="news" title="News">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
<span data-i18n="nav.news">News</span>
</button>
</div>
<div class="sidebar-nav-secondary" id="nav-secondary">
<button class="nav-btn" data-view="friends" title="Friends">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="nav.friends">Friends</span>
</button>
<button class="nav-btn hidden" data-view="admin" title="Admin" id="admin-nav-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
<span data-i18n="nav.admin">Admin</span>
</button>
</div>
</nav>
<div class="sidebar-section section-collapsible" id="server-packs-section">
<div class="section-header" id="server-packs-header" title="Toggle">
<span class="section-title-wrap">
<svg class="section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
</span>
<button class="btn-icon" id="add-server-pack-btn" title="Install server pack">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="pack-list-collapse"><div id="server-packs-list" class="pack-list"></div></div>
</div>
<div class="sidebar-section section-collapsible" id="local-packs-section">
<div class="section-header" id="local-packs-header" title="Toggle">
<span class="section-title-wrap">
<svg class="section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
</span>
<button class="btn-icon" id="add-pack-btn" title="Add pack">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="pack-list-collapse"><div id="local-packs-list" class="pack-list"></div></div>
</div>
</div>
<div class="sidebar-bottom">
<div class="user-card">
<div class="user-avatar" id="user-avatar">Z</div>
<div class="user-info">
<span class="user-name" id="username-display">Player</span>
<span class="user-badges">
<span id="account-status" class="badge badge-free">FREE</span>
<span id="account-role" class="badge badge-role hidden"></span>
</span>
</div>
<button class="btn-icon" id="settings-btn" title="Settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
<button class="btn-icon" id="logout-btn" title="Log out">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</div>
</aside>
<main class="content">
<!-- Packs View -->
<div id="view-packs" class="view active">
<div class="view-header">
<div>
<h2 class="view-title" id="selected-pack-title">Select a pack</h2>
<p class="view-subtitle" id="selected-pack-meta">Choose a pack from the sidebar to get started</p>
</div>
<div class="view-actions">
<button id="update-btn" class="btn-secondary hidden">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
<span data-i18n="pack.update">Update</span>
</button>
<button id="delete-pack-btn" class="btn-secondary btn-danger hidden">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<span data-i18n="pack.delete">Delete</span>
</button>
</div>
</div>
<div class="pack-detail" id="pack-detail">
<div class="pack-empty" id="pack-empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<h3 data-i18n="pack.emptyState.title">No pack selected</h3>
<p data-i18n="pack.emptyState.desc">Select a pack from the sidebar or add a new one</p>
</div>
<div id="pack-detail-content" class="pack-detail-content hidden">
<div class="pack-hero">
<div class="pack-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
<div>
<h3 id="detail-name" class="detail-name">pack</h3>
<div class="detail-tags">
<span class="tag tag-mc" id="detail-mc">1.21</span>
<span class="tag tag-loader" id="detail-loader">fabric</span>
<span class="tag tag-server hidden" id="detail-server">v1</span>
</div>
</div>
</div>
<div class="pack-stats">
<div class="stat"><span class="stat-value" id="detail-loader-ver">-</span><span class="stat-label" data-i18n="stat.loaderVer">Loader Ver</span></div>
<div class="stat"><span class="stat-value" id="detail-files">0</span><span class="stat-label" data-i18n="stat.files">Files</span></div>
<div class="stat"><span class="stat-value" id="detail-size">-</span><span class="stat-label" data-i18n="stat.size">Size</span></div>
<div class="stat"><span class="stat-value" id="detail-playtime">-</span><span class="stat-label" data-i18n="playtime.label">Playtime</span></div>
</div>
<div id="pack-description" class="pack-description">
<p id="pack-description-text" class="pack-description-text" data-i18n="pack.description.loading">Loading description...</p>
<div id="pack-gallery" class="pack-gallery">
</div>
</div>
<div id="whitelist-mods-section" class="whitelist-mods-section hidden">
<div class="section-header"><span data-i18n="whitelist.title">Additional Mods</span></div>
<div id="whitelist-mods-list" class="whitelist-mods-list">
<div class="whitelist-loading" data-i18n="whitelist.loading">Loading...</div>
</div>
</div>
</div>
</div>
<div id="inline-install-progress" class="inline-install-progress hidden">
<div class="progress-track">
<div class="progress-fill" id="inline-progress-fill"></div>
</div>
<p class="progress-label" id="inline-progress-label">Installing...</p>
<p class="progress-stage hidden" id="inline-progress-stage"></p>
</div>
<div class="play-bar" id="play-bar">
<div class="play-bar-info">
<span id="play-bar-name">Select a pack</span>
</div>
<button id="play-btn" class="btn-play" disabled>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span data-i18n="playBar.play">Play</span>
</button>
</div>
</div>
<!-- News View -->
<div id="view-news" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="news.title">News</h2>
</div>
<div id="news-grid" class="news-grid">
<div class="news-loading" data-i18n="news.loading">Loading news...</div>
</div>
</div>
<!-- Friends View -->
<div id="view-friends" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="friends.title">Friends</h2>
<div class="view-actions">
<button id="friends-add-btn" class="btn-primary btn-sm" onclick="app.showAddFriend()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span data-i18n="friends.add">Add</span>
</button>
</div>
</div>
<div class="friends-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="friends-search-input" placeholder="Search friends..." data-i18n-placeholder="friends.search" oninput="app.filterFriends()">
</div>
<div id="friends-list" class="friends-list">
<div class="friends-empty" data-i18n="friends.empty">No friends yet</div>
</div>
<div id="friend-requests-section" class="friend-requests-section hidden">
<div class="section-header"><span data-i18n="friends.requests">Friend Requests</span></div>
<div id="friend-requests-list" class="friend-requests-list"></div>
</div>
</div>
<!-- Admin View -->
<div id="view-admin" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="admin.title">Admin Panel</h2>
</div>
<div class="admin-tabs">
<button class="admin-tab active" data-atab="clients"><span data-i18n="admin.clients">Clients</span></button>
<button class="admin-tab" data-atab="mods"><span data-i18n="admin.mods">Mods</span></button>
<button class="admin-tab" data-atab="users"><span data-i18n="admin.users">Users</span></button>
<button class="admin-tab" data-atab="passes"><span data-i18n="admin.passes">Passes</span></button>
<button class="admin-tab" data-atab="packs"><span data-i18n="admin.packs">Packs</span></button>
<button class="admin-tab" data-atab="news"><span data-i18n="admin.news">News</span></button>
</div>
<div class="admin-content">
<!-- Clients -->
<div id="atab-clients" class="admin-tab-content active">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.clientsOnline">Online Clients</span></div>
<div id="admin-clients-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Whitelist Mods -->
<div id="atab-mods" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.modsUpload">Upload Mod</span></div>
<div class="admin-row">
<input type="file" id="admin-mod-file" accept=".jar" class="admin-file-input">
<button class="btn-primary btn-sm" onclick="app.adminUploadMod()"><span data-i18n="admin.modsUploadBtn">Upload</span></button>
</div>
<div id="admin-mod-status" class="admin-status hidden"></div>
</div>
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.modsList">Whitelist Mods</span></div>
<div id="admin-mods-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Users -->
<div id="atab-users" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.usersSearch">User Management</span></div>
<div class="admin-row">
<input type="text" id="admin-user-search" class="admin-input" placeholder="Username..." data-i18n-placeholder="admin.usersSearchPlaceholder">
<button class="btn-primary btn-sm" onclick="app.adminSearchUser()"><span data-i18n="admin.search">Search</span></button>
</div>
<div id="admin-user-results" class="admin-list"></div>
</div>
<div id="admin-user-detail" class="admin-card hidden">
<div class="admin-card-header" id="admin-user-detail-name"></div>
<div class="admin-form">
<div class="admin-row">
<span data-i18n="admin.role">Role:</span>
<select id="admin-user-role-select" class="admin-select">
<option value="0">USER</option>
<option value="1">PASS_HOLDER</option>
<option value="2">MODERATOR</option>
<option value="3">ELDER</option>
<option value="4">CREATOR</option>
</select>
<button class="btn-primary btn-sm" onclick="app.adminSetRole()"><span data-i18n="admin.save">Save</span></button>
</div>
<div class="admin-row">
<button class="btn-secondary btn-sm btn-danger" onclick="app.adminResetPassword()"><span data-i18n="admin.resetPass">Reset Password</span></button>
</div>
</div>
</div>
</div>
<!-- Passes -->
<div id="atab-passes" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.passesList">Pass Management</span></div>
<div id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Packs -->
<div id="atab-packs" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.packsList">Pack Management</span></div>
<div id="admin-packs-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- News -->
<div id="atab-news" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.newsCreate">Create News</span></div>
<div class="admin-form">
<div class="admin-row">
<input type="text" id="admin-news-title" class="admin-input" placeholder="Title" data-i18n-placeholder="admin.newsTitlePlaceholder">
</div>
<div class="admin-row">
<select id="admin-news-type" class="admin-select">
<option value="Update">Update</option>
<option value="Announcement">Announcement</option>
<option value="Event">Event</option>
</select>
<input type="text" id="admin-news-version" class="admin-input" placeholder="Version" style="max-width:120px">
</div>
<div class="admin-row">
<textarea id="admin-news-body" class="admin-textarea" placeholder="Body (Markdown)" data-i18n-placeholder="admin.newsBodyPlaceholder"></textarea>
</div>
<div class="admin-row">
<button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button>
</div>
<div id="admin-news-status" class="admin-status hidden"></div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.newsManage">Manage News</span></div>
<div id="admin-news-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
</div>
</div>
<!-- Settings View -->
<div id="view-settings" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="settings.title">Settings</h2>
</div>
<div class="settings-grid">
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.activatePass.title">Activate Pass</h4>
<p data-i18n="settings.activatePass.desc">Enter your pass code to access server packs</p>
</div>
<div class="setting-control setting-pass">
<input type="text" id="pass-code" placeholder="Pass code" data-i18n-placeholder="settings.activatePass.placeholder" class="pass-input">
<button id="activate-pass-btn" class="btn-primary btn-sm" onclick="app.activatePass()"><span data-i18n="settings.activatePass.button">Activate</span></button>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.ram.title">Allocated RAM</h4>
<p id="ram-info" data-i18n="settings.ram.info">Loading...</p>
</div>
<div class="setting-control">
<input type="range" id="ram-slider" min="1024" max="16384" step="512" value="4096">
<span class="setting-value" id="ram-value">4 GB</span>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.resolution.title">Game Resolution</h4>
<p data-i18n="settings.resolution.desc">Width x Height</p>
</div>
<div class="setting-control" style="gap:6px">
<input type="number" id="win-width" min="640" max="7680" step="1" value="1280" class="setting-input" style="width:80px">
<span style="color:var(--text-muted)">x</span>
<input type="number" id="win-height" min="480" max="4320" step="1" value="720" class="setting-input" style="width:80px">
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.jvmArgs.title">Extra JVM Arguments</h4>
<p data-i18n="settings.jvmArgs.desc">Additional Java VM options</p>
</div>
<div class="setting-control">
<input type="text" id="jvm-args" placeholder="-XX:+UseZGC" class="setting-input" style="width:280px">
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.javaPath.title">Java Path</h4>
<p id="java-path">~/.zernmc/jre/</p>
</div>
<div class="setting-control">
<input type="text" id="java-path-input" placeholder="java" class="setting-input" style="width:280px">
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.server.title">Server</h4>
<p id="server-url">http://87.120.187.36:1582</p>
</div>
<div class="setting-control">
<span class="setting-badge" id="server-status">Checking...</span>
</div>
</div>
<div class="setting-card" id="network-card">
<div class="setting-info">
<h4 data-i18n="settings.network.title">Network</h4>
<p data-i18n="settings.network.desc">Per-service connection mode (direct / via proxy)</p>
</div>
<div class="setting-network-list" id="network-list">
<span class="setting-badge" data-i18n="settings.network.loading">Checking...</span>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.language.title">Language</h4>
<p data-i18n="settings.language.desc">Interface language</p>
</div>
<div class="setting-control">
<select id="locale-select" class="setting-input" style="width:160px">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.systemJvm.title">System-based JVM Optimization</h4>
<p id="system-jvm-info">-</p>
</div>
<div class="setting-control">
<label class="toggle" id="system-jvm-toggle-wrapper">
<input type="checkbox" id="system-jvm-toggle">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.logViewer.title">Game Log</h4>
<p data-i18n="settings.logViewer.desc">View real-time game logs</p>
</div>
<div class="setting-control">
<button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Add Friend Modal -->
<div id="add-friend-modal" class="modal-backdrop hidden">
<div class="modal modal-sm">
<div class="modal-head">
<h3 data-i18n="friends.addTitle">Add Friend</h3>
<button class="modal-close" onclick="app.closeAddFriend()">&times;</button>
</div>
<div class="modal-body">
<div class="field">
<input type="text" id="add-friend-input" placeholder=" " data-i18n-placeholder="friends.addPlaceholder">
<label data-i18n="friends.addLabel">Username</label>
</div>
<button id="add-friend-submit" class="btn-primary" onclick="app.submitAddFriend()"><span data-i18n="friends.add">Add Friend</span></button>
<p id="add-friend-error" class="error-msg hidden"></p>
</div>
</div>
</div>
<!-- Log Viewer Overlay -->
<div id="log-viewer-overlay" class="modal-backdrop hidden">
<div class="modal modal-log">
<div class="modal-head">
<h3 data-i18n="logViewer.title">Game Log</h3>
<div class="log-viewer-actions">
<button class="btn-secondary btn-sm" id="copy-log-btn" onclick="app.copyLogs()"><span data-i18n="logViewer.copy">Copy</span></button>
<button class="btn-secondary btn-sm" onclick="app.openLogFile()"><span data-i18n="logViewer.openFile">Open File</span></button>
<button class="modal-close" id="close-log-viewer-btn" onclick="app.closeLogViewer()">&times;</button>
</div>
</div>
<div class="modal-body log-viewer-body">
<div id="log-viewer-content" class="log-viewer-content"></div>
</div>
</div>
</div>
<!-- Install Modal -->
<div id="install-modal" class="modal-backdrop hidden">
<div class="modal">
<div class="modal-head">
<h3 data-i18n="install.title">Install Pack</h3>
<button class="modal-close" id="close-modal-btn">&times;</button>
</div>
<div class="modal-body">
<div class="modal-tabs">
<button class="modal-tab active" data-tab="zernmc"><span data-i18n="install.tab.serverPack">Server Pack</span></button>
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span> <span class="tag-wip">WIP</span></button>
</div>
<div id="tab-zernmc" class="modal-tab-content active">
<div class="field">
<label data-i18n="install.serverPack.label">Server Pack</label>
<select id="zernmc-pack-select">
<option value="">Loading...</option>
</select>
</div>
<div id="preset-select-container" class="field hidden">
<label data-i18n="install.preset.label">Preset (optional)</label>
<select id="preset-select">
<option value=""><span data-i18n="install.preset.default">Default</span></option>
</select>
</div>
<button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
</div>
<div id="tab-custom" class="modal-tab-content">
<div class="disabled-tab">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<h3 data-i18n="install.custom.unavailable">Not available yet</h3>
<p data-i18n="install.custom.desc">Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.</p>
</div>
</div>
<div id="install-progress" class="install-progress hidden">
<div class="progress-track">
<div class="progress-fill" id="progress-fill"></div>
</div>
<p class="progress-label" id="progress-label" data-i18n="install.progress.installing">Installing...</p>
<p class="progress-stage hidden" id="progress-stage"></p>
</div>
</div>
</div>
</div>
<!-- Notification Toast -->
<div id="toast" class="toast hidden"></div>
</div>
<script src="marked.min.js"></script>
<script src="launcher.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,985 +0,0 @@
: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); }
@@ -1,274 +0,0 @@
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());
}
}
@@ -1,252 +0,0 @@
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<String> 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<String> 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=<path> -p <mp> --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;
}
}
}
@@ -1,563 +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 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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=<path> -p <classpath> --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);
}
}
+208 -57
View File
@@ -6,30 +6,22 @@
<modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version>
<packaging>pom</packaging>
<name>ZernMC Launcher Parent</name>
<description>ZernMC Launcher - Multi-module project</description>
<modules>
<module>bootstrap</module>
<module>diag</module>
<module>launcher</module>
</modules>
<version>1.0.8</version>
<packaging>jar</packaging>
<properties>
<revision>1.1.0</revision>
<hotfix>0</hotfix>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.organization.name>ZernMC</project.organization.name>
<project.inceptionYear>2026</project.inceptionYear>
<project.description>ZernMC Launcher - Multi-module project</project.description>
<project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<javafx.classifier>win</javafx.classifier>
<os.suffix>win</os.suffix>
<skip.launch4j>false</skip.launch4j>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
@@ -71,68 +63,52 @@
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
<!-- JavaFX for Windows -->
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<version>6.1.3</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.11</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>23.0.1</version>
<version>21.0.2</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>23.0.1</version>
<version>21.0.2</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>23.0.1</version>
<classifier>win</classifier>
<artifactId>javafx-controls</artifactId>
<version>21.0.2</version>
<classifier>linux</classifier>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>23.0.1</version>
<classifier>win</classifier>
<artifactId>javafx-web</artifactId>
<version>21.0.2</version>
<classifier>linux</classifier>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>dnsjava</groupId>
<artifactId>dnsjava</artifactId>
<version>3.6.5</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>23.0.1</version>
<classifier>win</classifier>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>1.6.0</version>
<configuration>
<updatePomFile>true</updatePomFile>
<flattenMode>resolveCiFriendliesOnly</flattenMode>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals><goal>flatten</goal></goals>
</execution>
<execution>
<id>flatten-clean</id>
<phase>clean</phase>
<goals><goal>clean</goal></goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
@@ -151,12 +127,12 @@
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/ZernMCLauncher.jar</outputFile>
<outputFile>../server/builds/ZernMCLauncher.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
<Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor>
<Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
@@ -164,6 +140,138 @@
</manifestEntries>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
<!-- Исключаем JavaFX из shade полностью (он будет в lib-javafx) -->
<filter>
<artifact>org.openjfx:*</artifact>
<excludes>
<exclude>**/*</exclude>
</excludes>
</filter>
</filters>
<dependencySet>
<outputDirectory>/</outputDirectory>
<useProjectArtifact>false</useProjectArtifact>
<unpack>true</unpack>
<scope>runtime</scope>
<excludes>
<exclude>org.openjfx:*</exclude>
</excludes>
</dependencySet>
</configuration>
</execution>
</executions>
</plugin>
<!-- Copy JavaFX dependencies -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>copy-javafx</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib-javafx</outputDirectory>
<includeScope>runtime</includeScope>
<includeGroupIds>org.openjfx</includeGroupIds>
</configuration>
</execution>
</executions>
</plugin>
<!-- Launch4j для создания .exe (только для Windows) -->
<plugin>
<groupId>com.akathist.maven.plugins.launch4j</groupId>
<artifactId>launch4j-maven-plugin</artifactId>
<version>2.5.0</version>
<configuration>
<skip>${skip.launch4j}</skip>
</configuration>
<executions>
<execution>
<id>l4j</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
<jar>../server/builds/ZernMCLauncher.jar</jar>
<headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar>
<jre>
<path>jre21</path>
<minVersion>21</minVersion>
<opts>
<opt>--module-path=lib-javafx</opt>
<opt>--add-modules=javafx.controls,javafx.web</opt>
<opt>--add-reads=javafx.graphics=ALL-UNNAMED</opt>
</opts>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC Launcher</productName>
<companyName>ZernMC(SashegDev)</companyName>
<internalName>ZernMCLauncher</internalName>
<originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
</executions>
</plugin>
<!-- Antrun: копирование JRE и создание build.version + zip -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>run</goal></goals>
<configuration>
<target>
<echo file="../server/builds/build.version">${project.version}</echo>
<!-- Копируем содержимое jre/jre21 в папку jre21 (без лишней вложенности) -->
<copy todir="../server/builds/jre21" overwrite="true">
<fileset dir="${user.home}/launcher/jre/jre21"/>
</copy>
<!-- Копируем JavaFX JAR в builds -->
<copy todir="../server/builds/lib-javafx" overwrite="true">
<fileset dir="${project.build.directory}/lib-javafx"/>
</copy>
<!-- Копируем shell script для Linux -->
<copy file="${project.basedir}/src/main/resources/launcher.sh"
todir="../server/builds"
overwrite="true"/>
<chmod file="../server/builds/launcher.sh" perm="+x"/>
<!-- Создаём zip с .exe, jre21, lib-javafx и launcher.sh (без .jar и build.version) -->
<zip destfile="../server/builds/ZernMCLauncher-${project.version}-${os.suffix}.zip"
basedir="../server/builds"
includes="ZernMCLauncher.exe,ZernMCLauncher.jar,jre21/**,lib-javafx/**,launcher.sh"
excludes="build.version"/>
</target>
</configuration>
</execution>
</executions>
@@ -171,14 +279,57 @@
</plugins>
</build>
<profiles>
<!-- ==================== GLOBAL ==================== -->
<profile>
<id>global</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<build.profile>global</build.profile>
<launcher.title>ZernMC Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url>
<!-- Можно добавить флаги для отключения некоторых фич -->
</properties>
</profile>
<!-- ==================== ZERNMC ==================== -->
<profile>
<id>zernmc</id>
<properties>
<build.profile>zernmc</build.profile>
<launcher.title>ZernMC Private Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
<!-- ==================== WINDOWS BUILD ==================== -->
<profile>
<id>win</id>
<activation>
<os>
<family>windows</family>
</os>
</activation>
<properties>
<javafx.classifier>win</javafx.classifier>
<os.suffix>win</os.suffix>
<skip.launch4j>false</skip.launch4j>
</properties>
</profile>
<!-- ==================== LINUX BUILD ==================== -->
<profile>
<id>linux</id>
<activation>
<os>
<family>unix</family>
</os>
</activation>
<properties>
<javafx.classifier>linux</javafx.classifier>
<os.suffix>linux</os.suffix>
<skip.launch4j>true</skip.launch4j>
</properties>
</profile>
</profiles>
@@ -0,0 +1,303 @@
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.utils.*;
import me.sashegdev.zernmc.launcher.web.UIWindow;
import me.sashegdev.zernmc.launcher.web.WebServer;
import java.awt.GraphicsEnvironment;
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.Arrays;
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 Exception {
boolean cliMode = Arrays.asList(args).contains("--cli") || Arrays.asList(args).contains("-c");
if (cliMode) {
runTUI(args);
} else {
try {
startWebUI(args);
} catch (Exception e) {
System.err.println(ZAnsi.red("UI не запустился: " + e.getMessage()));
e.printStackTrace();
System.out.println(ZAnsi.yellow("Переключаюсь на режим TUI..."));
runTUI(args);
}
}
}
private static void startWebUI(String[] args) throws Exception {
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
System.setProperty("file.encoding", "UTF-8");
int startPort = 8080;
for (int i = 0; i < args.length - 1; i++) {
if (args[i].equals("--port") || args[i].equals("-p")) {
startPort = Integer.parseInt(args[i + 1]);
}
}
System.out.println(ZAnsi.brightGreen("Запуск Web UI..."));
System.out.println(ZAnsi.cyan("Поиск свободного порта..."));
int port = WebServer.findFreePort(startPort);
// Запускаем WebServer в отдельном потоке
Thread serverThread = new Thread(() -> {
try {
WebServer.start(port);
} catch (Exception e) {
System.err.println("WebServer error: " + e.getMessage());
}
});
serverThread.setDaemon(true);
serverThread.start();
// Даем серверу время запуститься
Thread.sleep(1000);
// Проверяем headless перед запуском JavaFX (только для не-Windows систем)
if (!System.getProperty("os.name").toLowerCase().contains("win")) {
boolean isHeadless = java.awt.GraphicsEnvironment.isHeadless();
String display = System.getenv("DISPLAY");
if (isHeadless && (display == null || display.isEmpty())) {
System.out.println(ZAnsi.yellow("Дисплей недоступен, переключаюсь на TUI..."));
WebServer.stop();
runTUI(args);
return;
}
}
// Проверка обновлений лаунчера
checkAndAutoUpdateLauncher();
// Запускаем JavaFX окно
UIWindow.start(port);
}
private static void runTUI(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");
ZAnsi.install();
System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION) + ZAnsi.cyan(" [CLI mode]"));
// Проверка всех сервисов при старте
ZHttpClient.checkAllServicesOnStartup();
checkAndAutoUpdateLauncher();
// === АВТОРИЗАЦИЯ (используем новый API) ===
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
var sessionResponse = api.checkSession();
if (!sessionResponse.isSuccess()) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.out.println(ZAnsi.yellow("До свидания!"));
ZAnsi.uninstall();
System.exit(0);
}
} else {
var sessionInfo = sessionResponse.getData();
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.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<Path> 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)"));
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();
String launcherDir = jarPath.substring(0, jarPath.lastIndexOf(java.io.File.separator));
String javafxPath = launcherDir + java.io.File.separator + "lib-javafx";
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
ProcessBuilder pb = new ProcessBuilder(
javaPath,
"--module-path=" + javafxPath,
"--add-modules=javafx.controls,javafx.web",
"--add-reads=javafx.graphics=ALL-UNNAMED",
"-jar", jarPath
);
pb.inheritIO();
pb.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 {
if (Config.isZernMCBuild()) {
zernMCFlow();
} else {
globalFlow();
}
}
// ====================== ZERNMC FLOW ======================
private static void zernMCFlow() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
// 1. Проверка подключения к серверу
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
try {
String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("✓ Сервер доступен"));
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу"));
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage()));
ConsoleUtils.pause();
System.exit(1);
}
// 2. Авторизация
boolean sessionRestored = AuthManager.loadSavedSession();
if (!sessionRestored) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.exit(0);
}
} else {
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
}
// 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow)
LaunchMenu launchMenu = new LaunchMenu();
launchMenu.show(); // Здесь будет вызван showZernMCOnly() внутри
}
// ====================== GLOBAL FLOW ======================
private static void globalFlow() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
List<String> options = List.of(
"Запустить игру",
"Проверка обновлений",
"Настройки",
"Проверка подключения к серверам",
"Выход"
);
ArrowMenu menu = new ArrowMenu("Главное меню", options);
int choice = menu.show();
if (choice == -1 || choice == 4) {
System.out.println(ZAnsi.yellow("До свидания!"));
break;
}
switch (choice) {
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
case 1 -> new UpdateMenu().show();
case 2 -> new SettingsMenu().show();
case 3 -> new ServerCheckMenu().show();
}
}
}
}
@@ -0,0 +1,81 @@
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.install.InstallService;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import java.util.List;
/**
* Центральный фасад для внутреннего API лаунчера.
* Используется как единая точка входа для UI и других компонентов.
*/
public class LauncherAPI {
private final AuthService authService;
private final InstanceService instanceService;
private final LaunchService launchService;
private final InstallService installService;
public LauncherAPI() {
this.authService = new AuthService();
this.instanceService = new InstanceService();
this.launchService = new LaunchService();
this.installService = new InstallService();
}
public AuthService auth() {
return authService;
}
public InstanceService instances() {
return instanceService;
}
public LaunchService launch() {
return launchService;
}
public InstallService install() {
return installService;
}
// ====================== Удобные методы ======================
public boolean isLoggedIn() {
return authService.isLoggedIn();
}
public String getCurrentUsername() {
return authService.getCurrentUsername();
}
public ApiResponse<AuthService.SessionInfo> checkSession() {
return authService.checkSession();
}
public ApiResponse<AuthService.LoginResult> login(String username, String password) {
return authService.login(username, password);
}
public ApiResponse<Boolean> logout() {
return authService.logout();
}
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
return instanceService.getAllInstances();
}
public ApiResponse<LaunchService.InstanceInfo> getLaunchInfo(String instanceName) {
return launchService.getLaunchInfo(instanceName);
}
public ApiResponse<LaunchService.LaunchInfo> prepareLaunch(String instanceName) {
return launchService.prepareLaunch(instanceName);
}
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
return launchService.launch(instanceName);
}
}
@@ -1,7 +1,5 @@
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;
@@ -10,34 +8,6 @@ import java.io.IOException;
public class AuthService {
public ApiResponse<LoginResult> 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<LoginResult> login(String username, String password) {
try {
AuthManager.AuthResult result = AuthManager.login(username, password);
@@ -47,7 +17,7 @@ public class AuthService {
}
return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
} catch (Exception e) {
return ApiResponse.error(AuthManager.friendlyConnectionError(e));
return ApiResponse.error("Ошибка авторизации: " + e.getMessage());
}
}
@@ -56,7 +26,7 @@ public class AuthService {
AuthManager.logout();
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Logout error: " + e.getMessage());
return ApiResponse.error("Ошибка при выходе: " + e.getMessage());
}
}
@@ -67,27 +37,23 @@ public class AuthService {
SessionInfo info = new SessionInfo(
AuthManager.getUsername(),
AuthManager.getAccessToken(),
AuthManager.hasActivePass(),
AuthManager.getRole(),
AuthManager.getRoleName()
AuthManager.hasActivePass()
);
return ApiResponse.success(info);
}
return ApiResponse.error("Session not found");
return ApiResponse.error("Сессия не найдена");
} catch (Exception e) {
return ApiResponse.error("Session check error: " + e.getMessage());
return ApiResponse.error("Ошибка проверки сессии: " + e.getMessage());
}
}
public ApiResponse<Boolean> activatePass(String passCode) {
try {
JsonObject json = new JsonObject();
json.addProperty("pass_code", passCode);
String response = post("/auth/pass/activate", json.toString());
AuthManager.refreshUserInfo();
String response = post("/auth/pass/activate",
"{\"code\":\"" + passCode + "\"}");
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Pass activation error: " + e.getMessage());
return ApiResponse.error("Ошибка активации проходки: " + e.getMessage());
}
}
@@ -137,10 +103,6 @@ public class AuthService {
return AuthManager.getUsername();
}
public String getCurrentToken() {
return AuthManager.getAccessToken();
}
public static class LoginResult {
private String username;
private String token;
@@ -158,21 +120,15 @@ public class AuthService {
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) {
public SessionInfo(String username, String token, boolean passActive) {
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; }
}
}
@@ -0,0 +1,226 @@
package me.sashegdev.zernmc.launcher.api.install;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class InstallService {
private PackDownloader.ProgressCallback progressCallback;
public void setProgressCallback(PackDownloader.ProgressCallback callback) {
this.progressCallback = callback;
}
public ApiResponse<InstallResult> installZernMCPack(String packName, String instanceName) {
try {
boolean created = InstanceManager.createInstanceFolder(instanceName);
if (!created) {
return ApiResponse.error("Сборка с таким именем уже существует: " + instanceName);
}
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Не удалось создать директорию сборки");
}
PackDownloader downloader = new PackDownloader(instance);
if (progressCallback != null) {
downloader.setProgressCallback(progressCallback);
}
// Получаем список доступных сборок
List<ServerPack> availablePacks = downloader.getAvailablePacks();
// Находим нужную сборку
ServerPack selectedPack = availablePacks.stream()
.filter(p -> p.getName().equals(packName))
.findFirst()
.orElse(null);
if (selectedPack == null) {
return ApiResponse.error("Сборка не найдена: " + packName);
}
boolean success = downloader.installOrUpdatePack(packName, selectedPack);
if (success) {
return ApiResponse.success(new InstallResult(
instanceName,
selectedPack.getMinecraftVersion(),
selectedPack.getLoaderType(),
selectedPack.getVersion()
));
} else {
return ApiResponse.error("Не удалось установить сборку");
}
} catch (Exception e) {
return ApiResponse.error("Ошибка установки: " + e.getMessage());
}
}
public ApiResponse<UpdateCheckResult> checkForUpdates(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null || !instance.isServerPack()) {
return ApiResponse.success(new UpdateCheckResult(false, false, 0, 0));
}
PackDownloader downloader = new PackDownloader(instance);
int serverVersion = downloader.checkForUpdates(instance.getServerPackName());
boolean hasUpdate = serverVersion > 0;
return ApiResponse.success(new UpdateCheckResult(
hasUpdate,
true,
instance.getServerVersion(),
serverVersion
));
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки обновлений: " + e.getMessage());
}
}
public ApiResponse<HashCheckResult> verifyHashes(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
if (!instance.isServerPack() || instance.getServerPackName() == null) {
return ApiResponse.success(new HashCheckResult(false, List.of()));
}
PackDownloader downloader = new PackDownloader(instance);
Map<String, String> localFiles = downloader.scanLocalFiles();
// Отправляем хеши на сервер через diff
var diff = downloader.getDiff(instance.getServerPackName(), localFiles);
List<String> mismatched = new ArrayList<>();
for (var f : diff.getToDownload()) {
mismatched.add(f.getPath());
}
mismatched.addAll(diff.getToUpdate());
mismatched.addAll(diff.getToDelete());
boolean hasMismatches = !mismatched.isEmpty();
return ApiResponse.success(new HashCheckResult(hasMismatches, mismatched));
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки хешей: " + e.getMessage());
}
}
public ApiResponse<PlayTimeInfo> getPlayTime(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
if (instance.isServerPack()) {
// TODO: Для ZernMC получаем время с сервера
// String response = ZHttpClient.get("/users/me/playtime?pack=" + instance.getServerPackName());
// Пока возвращаем 0 - в будущем интегрировать с сервером
return ApiResponse.success(new PlayTimeInfo(0, true));
}
// Для локальных сборок возвращаем 0
return ApiResponse.success(new PlayTimeInfo(0, false));
} catch (Exception e) {
return ApiResponse.error("Ошибка получения времени: " + e.getMessage());
}
}
private int extractPlayTime(String json) {
try {
// Простой парсинг JSON
String minutes = json.replaceAll(".*\"minutes\"\\s*:\\s*(\\d+).*", "$1");
return Integer.parseInt(minutes);
} catch (Exception e) {
return 0;
}
}
public static class InstallResult {
private String name;
private String mcVersion;
private String loaderType;
private int serverVersion;
public InstallResult(String name, String mcVersion, String loaderType, int serverVersion) {
this.name = name;
this.mcVersion = mcVersion;
this.loaderType = loaderType;
this.serverVersion = serverVersion;
}
public String getName() { return name; }
public String getMcVersion() { return mcVersion; }
public String getLoaderType() { return loaderType; }
public int getServerVersion() { return serverVersion; }
}
public static class UpdateCheckResult {
private boolean hasUpdate;
private boolean isServerPack;
private int currentVersion;
private int latestVersion;
public UpdateCheckResult(boolean hasUpdate, boolean isServerPack, int currentVersion, int latestVersion) {
this.hasUpdate = hasUpdate;
this.isServerPack = isServerPack;
this.currentVersion = currentVersion;
this.latestVersion = latestVersion;
}
public boolean isHasUpdate() { return hasUpdate; }
public boolean isServerPack() { return isServerPack; }
public int getCurrentVersion() { return currentVersion; }
public int getLatestVersion() { return latestVersion; }
}
public static class HashCheckResult {
private boolean hasMismatches;
private List<String> mismatchedFiles;
public HashCheckResult(boolean hasMismatches, List<String> mismatchedFiles) {
this.hasMismatches = hasMismatches;
this.mismatchedFiles = mismatchedFiles;
}
public boolean hasMismatches() { return hasMismatches; }
public List<String> getMismatchedFiles() { return mismatchedFiles; }
}
public static class PlayTimeInfo {
private int totalMinutes;
private boolean fromServer;
public PlayTimeInfo(int totalMinutes, boolean fromServer) {
this.totalMinutes = totalMinutes;
this.fromServer = fromServer;
}
public int getTotalMinutes() { return totalMinutes; }
public boolean isFromServer() { return fromServer; }
public String getFormattedTime() {
int hours = totalMinutes / 60;
int minutes = totalMinutes % 60;
if (hours > 0) {
return hours + "ч " + minutes + "м";
}
return minutes + "м";
}
}
}
@@ -18,7 +18,7 @@ public class InstanceService {
.collect(Collectors.toList());
return ApiResponse.success(infoList);
} catch (IOException e) {
return ApiResponse.error("Error getting instances list: " + e.getMessage());
return ApiResponse.error("Ошибка получения списка сборок: " + e.getMessage());
}
}
@@ -26,11 +26,11 @@ public class InstanceService {
try {
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
return ApiResponse.error("Pack not found: " + name);
return ApiResponse.error("Сборка не найдена: " + name);
}
return ApiResponse.success(toInstanceInfo(instance));
} catch (Exception e) {
return ApiResponse.error("Error getting pack: " + e.getMessage());
return ApiResponse.error("Ошибка получения сборки: " + e.getMessage());
}
}
@@ -38,12 +38,12 @@ public class InstanceService {
try {
boolean created = InstanceManager.createInstanceFolder(name);
if (!created) {
return ApiResponse.error("A pack with this name already exists: " + name);
return ApiResponse.error("Сборка с таким именем уже существует: " + name);
}
Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(toInstanceInfo(instance));
} catch (IOException e) {
return ApiResponse.error("Error creating pack: " + e.getMessage());
return ApiResponse.error("Ошибка создания сборки: " + e.getMessage());
}
}
@@ -51,11 +51,11 @@ public class InstanceService {
try {
boolean deleted = InstanceManager.deleteInstance(name);
if (!deleted) {
return ApiResponse.error("Failed to delete pack: " + name);
return ApiResponse.error("Не удалось удалить сборку: " + name);
}
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Error deleting pack: " + e.getMessage());
return ApiResponse.error("Ошибка удаления сборки: " + e.getMessage());
}
}
@@ -64,23 +64,17 @@ public class InstanceService {
Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(instance != null);
} catch (Exception e) {
return ApiResponse.error("Error checking pack: " + e.getMessage());
return ApiResponse.error("Ошибка проверки сборки: " + 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()
);
}
@@ -90,22 +84,15 @@ public class InstanceService {
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) {
public InstanceInfo(String name, String path, String version, String loaderType, boolean isServerPack, 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;
}
@@ -113,10 +100,7 @@ public class InstanceService {
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; }
}
}
@@ -0,0 +1,157 @@
package me.sashegdev.zernmc.launcher.api.launch;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
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 java.io.IOException;
import java.nio.file.Path;
import java.util.List;
public class LaunchService {
public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions();
List<String> command = builder.build(options);
LaunchInfo info = new LaunchInfo(
instanceName,
command,
instance.getPath().toString()
);
return ApiResponse.success(info);
} catch (Exception e) {
return ApiResponse.error("Ошибка подготовки запуска: " + e.getMessage());
}
}
public ApiResponse<ProcessInfo> launch(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions();
List<String> command = builder.build(options);
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile());
processBuilder.inheritIO();
Process process = processBuilder.start();
ProcessInfo info = new ProcessInfo(
instanceName,
process.pid(),
"RUNNING"
);
return ApiResponse.success(info);
} catch (Exception e) {
return ApiResponse.error("Ошибка запуска: " + e.getMessage());
}
}
public ApiResponse<Boolean> isReady(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
Path versionJson = instance.getPath().resolve("version.json");
boolean hasVersionJson = versionJson.toFile().exists();
return ApiResponse.success(hasVersionJson);
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки готовности: " + e.getMessage());
}
}
public ApiResponse<InstanceInfo> getLaunchInfo(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + 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("Ошибка получения информации: " + e.getMessage());
}
}
public static class LaunchInfo {
private String instanceName;
private List<String> command;
private String workingDirectory;
public LaunchInfo(String instanceName, List<String> command, String workingDirectory) {
this.instanceName = instanceName;
this.command = command;
this.workingDirectory = workingDirectory;
}
public String getInstanceName() { return instanceName; }
public List<String> 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; }
}
}
@@ -6,12 +6,10 @@ 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;
@@ -27,71 +25,37 @@ public class AuthManager {
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;
}
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) {
LauncherLogger.warn("loadSavedSession: invalid auth.json content, deleting");
Files.deleteIfExists(AUTH_FILE);
return false;
}
if (loaded == null || loaded.accessToken == null) return false;
session = loaded;
LauncherLogger.info("loadSavedSession: loaded session for " + loaded.username
+ " expiresAt=" + loaded.expiresAt + " hasRefresh=" + (loaded.refreshToken != null));
refreshUserInfo();
userInfo = fetchUserInfo();
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;
return tryRefresh();
}
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);
}
@@ -108,115 +72,49 @@ public class AuthManager {
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 if (resp.statusCode() == 422) {
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
} else {
return AuthResult.fail(friendlyHttpError(resp.statusCode(), resp.body()));
return AuthResult.fail(extractError(resp.body()));
}
} catch (Exception e) {
LauncherLogger.warn("authRequest failed (" + endpoint + "): " + e.getMessage());
return AuthResult.fail(friendlyConnectionError(e));
e.printStackTrace();
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
}
}
/**
* 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());
}
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
} catch (Exception ignored) {}
}
session = null;
userInfo = null;
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception e) {
LauncherLogger.warn("Failed to delete auth.json: " + e.getMessage());
}
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
}
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";
return session != null ? session.username : "Player";
}
public static String getUuid() {
AuthSession localSession = session;
return localSession != null ? localSession.uuid : "00000000-0000-0000-0000-000000000000";
return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000";
}
public static String getAccessToken() {
AuthSession localSession = session;
if (localSession == null) return "0";
if (session == null) return "0";
if (isAccessTokenExpired()) {
boolean refreshed = tryRefresh();
if (!refreshed) {
localSession = session;
if (localSession == null) return "0";
return localSession.accessToken != null ? localSession.accessToken : "0";
tryRefresh();
}
}
localSession = session;
return localSession != null && localSession.accessToken != null ? localSession.accessToken : "0";
return session != null && session.accessToken != null ? session.accessToken : "0";
}
private static boolean isAccessTokenExpired() {
@@ -225,70 +123,41 @@ public class AuthManager {
}
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;
}
if (session == null || session.refreshToken == null) return false;
try {
JsonObject json = new JsonObject();
json.addProperty("refresh_token", session.refreshToken);
SimpleHttpResponse resp = post("/auth/refresh", json.toString());
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;
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() {
} catch (Exception ignored) {}
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);
}
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));
LauncherLogger.info("Session saved to " + AUTH_FILE);
} catch (IOException e) {
LauncherLogger.error("Failed to save session", e);
System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage()));
}
}
// ==================== ПОЛУЧЕНИЕ ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ====================
private static UserInfo fetchUserInfo() {
if (!isLoggedIn() || session.accessToken == null) return null;
try {
// Используем существующий метод ZHttpClient.get() + вручную добавляем токен
java.net.HttpURLConnection conn = null;
try {
URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me");
@@ -315,52 +184,36 @@ public class AuthManager {
if (conn != null) conn.disconnect();
}
} catch (Exception e) {
LauncherLogger.warn("Failed to get UserInfo: " + e.getMessage());
System.err.println("Не удалось получить 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;
return getRole() >= ROLE_PASS_HOLDER;
}
public static boolean canViewPacks() {
if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_VIEW_PACKS);
}
return hasPass();
return hasPass(); // fallback для старых аккаунтов
}
public static boolean canDownloadPacks() {
if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
}
return hasPass();
return hasPass(); // fallback
}
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";
}
// ====================== POST ======================
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
HttpURLConnection conn = null;
@@ -392,16 +245,12 @@ public class AuthManager {
}
int statusCode = conn.getResponseCode();
InputStream is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
var 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);
@@ -423,49 +272,36 @@ public class AuthManager {
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();
try {
String response = ZHttpClient.get("/auth/pass/my");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
return json.has("has_active") && json.get("has_active").getAsBoolean();
} catch (Exception e) {
System.err.println(ZAnsi.red("Не удалось проверить проходки: ") + e.getMessage());
return false;
}
}
public static String getPassStatus() {
if (!isLoggedIn()) return "Not logged in";
if (!isLoggedIn()) return "Не авторизован";
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";
return hasActive ? "Есть активная проходка" : "Проходка отсутствует";
} catch (Exception e) {
return "Check error";
return "Ошибка проверки";
}
}
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
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 transient long expiresAt;
public String username;
public String uuid;
public int role;
@@ -501,22 +337,9 @@ public class AuthManager {
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;
@@ -25,31 +25,82 @@ import java.util.stream.Collectors;
public class LaunchMenu {
public static class ExitToMainMenuException extends Exception {}
public void show() throws Exception {
try {
if (Config.isZernMCBuild()) {
showZernMCOnly();
} else {
showGlobal();
}
} catch (ExitToMainMenuException e) {
// Возвращаемся в главное меню - ничего не делаем, просто выходим
}
}
// ====================== ZERNMC BUILD ======================
private void showZernMCOnly() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
System.out.println(ZAnsi.cyan("Доступны только серверные сборки"));
if (!awaitActivePass()) {
return;
}
PackDownloader tempDownloader = new PackDownloader(null);
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("На данный момент нет доступных сборок на сервере."));
ConsoleUtils.pause();
return;
}
List<String> 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);
installAndRunServerPack(selected);
}
}
private boolean awaitActivePass() throws Exception {
if (AuthManager.hasActivePass()) {
System.out.println(ZAnsi.brightGreen("Active pass confirmed"));
System.out.println(ZAnsi.brightGreen("Активная проходка подтверждена"));
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(ZAnsi.brightRed("У вас нет активной проходки!"));
System.out.println(ZAnsi.white("Для доступа к сборкам ZernMC требуется активная проходка."));
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"));
System.out.println(ZAnsi.cyan("Ожидаем активацию проходки... (проверка каждые 10 секунд)"));
System.out.println(ZAnsi.white("Нажмите Enter для отмены"));
for (int i = 0; i < 60; i++) {
try {
if (System.in.available() > 0) {
Input.readLine();
System.out.println(ZAnsi.yellow("\nWaiting cancelled."));
System.out.println(ZAnsi.yellow("\nОжидание отменено."));
return false;
}
} catch (Exception ignored) {}
@@ -57,7 +108,7 @@ public class LaunchMenu {
Thread.sleep(10000);
if (AuthManager.hasActivePass()) {
System.out.println(ZAnsi.brightGreen("\n✓ Pass activated successfully!"));
System.out.println(ZAnsi.brightGreen("\n✓ Проходка успешно активирована!"));
return true;
}
@@ -65,27 +116,66 @@ public class LaunchMenu {
if ((i + 1) % 6 == 0) System.out.println();
}
System.out.println(ZAnsi.brightRed("\n\nWaiting time expired."));
System.out.println(ZAnsi.brightRed("\n\nВремя ожидания истекло."));
return false;
}
private void openActivationWebsite() {
//String url = "https://launcher.ru.zernmc.ru/activate-pass";
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));
System.out.println(ZAnsi.cyan("Браузер открыт: " + url));
} else {
System.out.println(ZAnsi.yellow("Could not open browser automatically."));
System.out.println(ZAnsi.white("Open manually: " + url));
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически."));
System.out.println(ZAnsi.white("Откройте вручную: " + url));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error opening browser: " + e.getMessage()));
System.out.println(ZAnsi.white("Link: " + url));
System.out.println(ZAnsi.brightRed("Ошибка открытия браузера: " + e.getMessage()));
System.out.println(ZAnsi.white("Ссылка: " + url));
}
}
private void installAndRunServerPack(ServerPack selected) throws Exception {
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() != null ? " " + selected.getLoaderVersion() : ""));
System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion());
System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount());
String localName = askPackName();
if (localName == null) return;
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.brightRed("\n[FAIL] Не удалось установить сборку."));
ConsoleUtils.pause();
return;
}
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
ConsoleUtils.pause();
launchExistingInstance(newInstance);
}
// ====================== GLOBAL BUILD ======================
private void showGlobal() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
@@ -95,10 +185,10 @@ public class LaunchMenu {
.map(Instance::toString)
.collect(Collectors.toList());
options.add("Install new pack");
options.add("Back to main menu");
options.add("Установить новую сборку");
options.add("Назад в главное меню");
ArrowMenu menu = new ArrowMenu("Manage packs", options);
ArrowMenu menu = new ArrowMenu("Управление сборками", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) break;
@@ -117,13 +207,13 @@ public class LaunchMenu {
ConsoleUtils.clearScreen();
List<String> options = List.of(
"Install pack from ZernMC server",
"Install Vanilla Minecraft",
"Create custom pack (Fabric/Forge)",
"Back"
"Установить сборку с сервера ZernMC",
"Установить Vanilla Minecraft",
"Создать сборку вручную (Fabric/Forge)",
"Назад"
);
ArrowMenu menu = new ArrowMenu("Install new pack", options);
ArrowMenu menu = new ArrowMenu("Установка новой сборки", options);
int choice = menu.show();
if (choice == -1 || choice == 3) return;
@@ -139,28 +229,28 @@ public class LaunchMenu {
if (!awaitActivePass()) return;
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Fetching available packs..."));
System.out.println(ZAnsi.cyan("Получение списка доступных сборок..."));
PackDownloader tempDownloader = new PackDownloader(null);
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("No packs available on the server."));
System.out.println(ZAnsi.yellow("Нет доступных сборок на сервере."));
ConsoleUtils.pause();
return;
}
List<String> options = availablePacks.stream()
.map(p -> String.format("%s [%s + %s v%d] - %d files",
.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("Back");
options.add("Назад");
ArrowMenu menu = new ArrowMenu("Select a pack to install", options);
ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return;
@@ -168,14 +258,14 @@ public class LaunchMenu {
ServerPack selected = availablePacks.get(choice);
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Installing pack: " + selected.getName()));
System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
System.out.print(ZAnsi.white("\nEnter local pack name (Enter = pack name): "));
System.out.print(ZAnsi.white("\nВведите название локальной сборки (Enter = имя пака): "));
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!"));
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause();
return;
}
@@ -187,36 +277,46 @@ public class LaunchMenu {
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
} else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
}
ConsoleUtils.pause();
}
// ====================== manageInstance полностью восстановлен ======================
private void manageInstance(Instance instance) throws Exception {
while (true) {
// Проверяем, существует ли сборка (на случай если она была удалена вручную)
Instance currentInstance = InstanceManager.getInstance(instance.getName());
if (currentInstance == null) {
System.out.println(ZAnsi.yellow("Сборка была удалена или не существует."));
ConsoleUtils.pause();
throw new ExitToMainMenuException(); // Выходим в главное меню
}
instance = currentInstance; // Обновляем ссылку на актуальный объект
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() +
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("Server pack: v" + instance.getServerVersion()));
System.out.println(ZAnsi.green("Серверная сборка: v" + instance.getServerVersion()));
}
List<String> options = new ArrayList<>();
options.add("Launch pack");
options.add("Запустить сборку");
if (instance.isServerPack()) {
options.add("Check for updates");
options.add("Проверить обновления");
}
options.add("Change loader version");
options.add("Delete pack");
options.add("Back");
options.add("Изменить версию лоадера");
options.add("Удалить сборку");
options.add("Назад");
ArrowMenu menu = new ArrowMenu("Actions", options);
ArrowMenu menu = new ArrowMenu("Действия", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return;
@@ -235,49 +335,54 @@ public class LaunchMenu {
changeLoaderVersion(instance);
} else {
deleteInstance(instance);
throw new ExitToMainMenuException(); // Выходим в главное меню
}
}
case 3 -> deleteInstance(instance);
case 3 -> {
deleteInstance(instance);
throw new ExitToMainMenuException(); // Выходим в главное меню после удаления
}
}
}
}
private void checkAndUpdateServerPack(Instance instance) throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName()));
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName()));
PackDownloader downloader = new PackDownloader(instance);
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
int serverVersion = downloader.checkForUpdates(instance.getServerPackName());
boolean hasUpdate = serverVersion > 0;
if (!hasUpdate) {
System.out.println(ZAnsi.green("Pack is up to date (v" + instance.getServerVersion() + ")"));
System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")"));
ConsoleUtils.pause();
return;
}
System.out.println(ZAnsi.brightYellow("Update available!"));
if (Input.confirm("Update pack")) {
System.out.println(ZAnsi.brightYellow("Доступно обновление!"));
if (Input.confirm("Обновить сборку")) {
boolean success = downloader.updatePack(instance.getServerPackName());
if (success) {
System.out.println(ZAnsi.brightGreen("Pack updated successfully!"));
System.out.println(ZAnsi.brightGreen("Сборка успешно обновлена!"));
} else {
System.out.println(ZAnsi.brightRed("Failed to update pack."));
System.out.println(ZAnsi.brightRed("Не удалось обновить сборку."));
}
} else {
System.out.println(ZAnsi.yellow("Update cancelled."));
System.out.println(ZAnsi.yellow("Обновление отменено."));
}
ConsoleUtils.pause();
}
private void changeLoaderVersion(Instance instance) throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Changing loader version for " + instance.getName()));
System.out.println(ZAnsi.cyan("Изменение версии лоадера для " + 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."));
System.out.println(ZAnsi.yellow("Это vanilla сборка. Нельзя изменить лоадер."));
ConsoleUtils.pause();
return;
}
@@ -293,7 +398,7 @@ public class LaunchMenu {
if (newLoaderVersion == null) return;
System.out.println(ZAnsi.cyan("Reinstalling loader " + currentLoader + " -> " + newLoaderVersion + "..."));
System.out.println(ZAnsi.cyan("Переустановка лоадера " + currentLoader + " -> " + newLoaderVersion + "..."));
MinecraftLib lib = new MinecraftLib(instance);
boolean success;
@@ -308,12 +413,12 @@ public class LaunchMenu {
}
if (success) {
System.out.println(ZAnsi.brightGreen("Loader version changed successfully!"));
System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!"));
} else {
System.out.println(ZAnsi.brightRed("Failed to change loader version."));
System.out.println(ZAnsi.brightRed("Не удалось изменить версию лоадера."));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error changing loader: " + e.getMessage()));
System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage()));
}
ConsoleUtils.pause();
@@ -323,12 +428,12 @@ public class LaunchMenu {
ConsoleUtils.clearScreen();
List<String> confirmOptions = List.of(
"Yes, delete pack",
"No, cancel"
"Да, удалить сборку",
"Нет, отменить"
);
ArrowMenu confirmMenu = new ArrowMenu(
"Are you sure you want to delete '" + instance.getName() + "'?",
"Вы действительно хотите удалить сборку '" + instance.getName() + "'?",
confirmOptions
);
@@ -337,34 +442,31 @@ public class LaunchMenu {
if (choice == 0) {
boolean deleted = InstanceManager.deleteInstance(instance.getName());
if (deleted) {
System.out.println(ZAnsi.brightGreen("Pack '" + instance.getName() + "' deleted successfully."));
System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена."));
// НЕ делаем pause(), сразу возвращаемся в manageInstance для выхода в меню сборок
} else {
System.out.println(ZAnsi.brightRed("Failed to delete pack."));
}
} else {
System.out.println(ZAnsi.yellow("Deletion cancelled."));
}
System.out.println(ZAnsi.brightRed("Не удалось удалить сборку."));
ConsoleUtils.pause();
}
} else {
System.out.println(ZAnsi.yellow("Удаление отменено."));
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!"));
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
ConsoleUtils.pause();
return;
}
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + 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());
@@ -373,18 +475,20 @@ public class LaunchMenu {
try {
lib.launch(options);
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error launching: " + e.getMessage()));
System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage()));
e.printStackTrace();
}
ConsoleUtils.pause();
}
// ====================== Остальные вспомогательные методы ======================
private String askPackName() {
System.out.print(ZAnsi.white("\nEnter new pack name: "));
System.out.print(ZAnsi.white("\nВведите название новой сборки: "));
String name = Input.readLine().trim();
if (name.isEmpty()) {
System.out.println(ZAnsi.yellow("Cancelled."));
System.out.println(ZAnsi.yellow("Отменено."));
return null;
}
return name;
@@ -392,7 +496,7 @@ public class LaunchMenu {
private void createVanillaInstance() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
@@ -400,9 +504,9 @@ public class LaunchMenu {
List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList());
versionOptions.add("Back");
versionOptions.add("Назад");
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
@@ -414,7 +518,7 @@ public class LaunchMenu {
if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause();
return;
}
@@ -426,9 +530,9 @@ public class LaunchMenu {
boolean success = lib.installMinecraft(mcVersion);
if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla pack '" + packName + "' created successfully!"));
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!"));
} else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to create pack."));
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку."));
}
ConsoleUtils.pause();
@@ -436,7 +540,7 @@ public class LaunchMenu {
private void createCustomInstance() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
@@ -444,9 +548,9 @@ public class LaunchMenu {
List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList());
versionOptions.add("Back");
versionOptions.add("Назад");
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
@@ -455,7 +559,7 @@ public class LaunchMenu {
String mcVersion = selectedMc.getId();
List<String> loaderOptions = buildLoaderOptions(mcVersion);
ArrowMenu loaderMenu = new ArrowMenu("Select mod loader for " + mcVersion, loaderOptions);
ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions);
int loaderChoice = loaderMenu.show();
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
@@ -491,7 +595,7 @@ public class LaunchMenu {
if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause();
return;
}
@@ -511,9 +615,9 @@ public class LaunchMenu {
}
if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + packName + "' installed successfully!"));
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!"));
} else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to install pack."));
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
}
ConsoleUtils.pause();
@@ -526,7 +630,7 @@ public class LaunchMenu {
if (isNeoForgeSupported(mcVersion)) options.add("NeoForge");
if (isForgeSupported(mcVersion)) options.add("Forge");
options.add("Vanilla");
options.add("Back");
options.add("Назад");
return options;
}
@@ -542,22 +646,21 @@ public class LaunchMenu {
}
private boolean isNeoForgeSupported(String version) {
return version.matches("^1\\.20\\.[1-9].*") ||
version.matches("^1\\.21.*") ||
version.matches("^\\d{2}\\..*");
// ВРЕМЕННО ОТКЛЮЧЕНО: в разработке
return false;
}
private String askFabricLoaderVersion() throws Exception {
System.out.println(ZAnsi.cyan("Fetching Fabric Loader versions..."));
System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader..."));
List<String> versions = ZHttpClient.getFabricLoaderVersions();
List<String> options = versions.stream()
.limit(30)
.map(v -> "Fabric Loader " + v)
.collect(Collectors.toList());
options.add("Back");
options.add("Назад");
ArrowMenu menu = new ArrowMenu("Select Fabric Loader version", options);
ArrowMenu menu = new ArrowMenu("Выбор версии Fabric Loader", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null;
@@ -565,7 +668,7 @@ public class LaunchMenu {
}
private String askForgeVersion(String mcVersion) throws Exception {
System.out.println(ZAnsi.cyan("Fetching Forge versions for " + mcVersion + "..."));
System.out.println(ZAnsi.cyan("Получение списка версий Forge для " + mcVersion + "..."));
List<String> allForgeVersions = getAllForgeVersions();
@@ -575,7 +678,7 @@ public class LaunchMenu {
.collect(Collectors.toList());
if (compatibleVersions.isEmpty()) {
System.out.println(ZAnsi.yellow("No compatible Forge versions found for " + mcVersion));
System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion));
ConsoleUtils.pause();
return null;
}
@@ -584,9 +687,9 @@ public class LaunchMenu {
.limit(30)
.map(v -> "Forge " + v)
.collect(Collectors.toList());
options.add("Back");
options.add("Назад");
ArrowMenu menu = new ArrowMenu("Select Forge version for " + mcVersion, options);
ArrowMenu menu = new ArrowMenu("Выбор версии Forge для " + mcVersion, options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null;
@@ -615,7 +718,7 @@ public class LaunchMenu {
}
private String askNeoForgeVersion(String mcVersion) throws Exception {
System.out.println(ZAnsi.cyan("Fetching NeoForge versions for " + mcVersion + "..."));
System.out.println(ZAnsi.cyan("Получение списка версий NeoForge для " + mcVersion + "..."));
List<String> allNeoForgeVersions = getAllNeoForgeVersions();
@@ -624,7 +727,7 @@ public class LaunchMenu {
.collect(Collectors.toList());
if (compatibleVersions.isEmpty()) {
System.out.println(ZAnsi.yellow("No compatible NeoForge versions found for " + mcVersion));
System.out.println(ZAnsi.yellow("Не найдено совместимых версий NeoForge для " + mcVersion));
ConsoleUtils.pause();
return null;
}
@@ -633,9 +736,9 @@ public class LaunchMenu {
.limit(30)
.map(v -> "NeoForge " + v)
.collect(Collectors.toList());
options.add("Back");
options.add("Назад");
ArrowMenu menu = new ArrowMenu("Select NeoForge version for " + mcVersion, options);
ArrowMenu menu = new ArrowMenu("Выбор версии NeoForge для " + mcVersion, options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null;
@@ -677,6 +780,7 @@ public class LaunchMenu {
index = end;
}
} catch (Exception e) {
// Skip if one maven doesn't have the artifact
}
}
@@ -10,20 +10,30 @@ 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<String> options = List.of(
"Sign In",
"Create Account",
"Exit Launcher"
"Войти в аккаунт",
"Создать аккаунт",
"Выйти из лаунчера"
);
ArrowMenu menu = new ArrowMenu("Welcome to ZernMC!", options);
ArrowMenu menu = new ArrowMenu("Добро пожаловать в ZernMC!", options);
int choice = menu.show();
if (choice == -1 || choice == 2) return false;
@@ -35,56 +45,62 @@ public class LoginMenu {
};
if (success) return true;
// Если не успех покажем меню снова (ошибка уже напечатана внутри методов)
}
}
/**
* Показывается когда пользователь уже вошёл предлагает выйти из аккаунта.
*/
public void showAccountMenu() throws IOException {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== Account ==="));
System.out.println(ZAnsi.header("=== Аккаунт ==="));
System.out.println();
System.out.println(ZAnsi.white(" Player: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid()));
System.out.println();
List<String> options = List.of(
"Log Out",
"Back"
"Выйти из аккаунта",
"Назад"
);
ArrowMenu menu = new ArrowMenu("Account Management", options);
ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options);
int choice = menu.show();
if (choice == 0) {
AuthManager.logout();
System.out.println(ZAnsi.yellow("Logged out."));
System.out.println(ZAnsi.yellow("Вы вышли из аккаунта."));
ConsoleUtils.pause();
}
}
// ====================== ПРИВАТНЫЕ МЕТОДЫ ======================
private boolean doLogin() throws IOException {
ConsoleUtils.clearScreen();
printBanner();
System.out.println(ZAnsi.cyan(" [ Sign In ]"));
System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]"));
System.out.println();
String username = Input.readLine(ZAnsi.white(" Username: "));
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
if (username.isEmpty()) return false;
String password = readPassword(" Password: ");
String password = readPassword(" Пароль: ");
if (password.isEmpty()) return false;
System.out.println();
System.out.print(ZAnsi.cyan(" Signing in..."));
System.out.print(ZAnsi.cyan(" Выполняем вход..."));
AuthResult result = AuthManager.login(username, password);
if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Welcome, " + AuthManager.getUsername() + "! "));
System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause();
return true;
} else {
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
ConsoleUtils.pause();
return false;
}
@@ -93,41 +109,45 @@ public class LoginMenu {
private boolean doRegister() throws IOException {
ConsoleUtils.clearScreen();
printBanner();
System.out.println(ZAnsi.cyan(" [ Create Account ]"));
System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]"));
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(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(" Username: "));
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
if (username.isEmpty()) return false;
String password = readPassword(" Password: ");
String password = readPassword(" Пароль: ");
if (password.isEmpty()) return false;
String confirm = readPassword(" Confirm password: ");
String confirm = readPassword(" Повторите пароль: ");
if (!password.equals(confirm)) {
System.out.println(ZAnsi.brightRed("\n Passwords do not match!"));
System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!"));
ConsoleUtils.pause();
return false;
}
System.out.println();
System.out.print(ZAnsi.cyan(" Creating account..."));
System.out.print(ZAnsi.cyan(" Создаём аккаунт..."));
AuthResult result = AuthManager.register(username, password);
if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Account created! Welcome, " + AuthManager.getUsername() + "! "));
System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause();
return true;
} else {
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
ConsoleUtils.pause();
return false;
}
}
/**
* Читаем пароль стараемся скрыть вывод через Console,
* если недоступно (IDE/терминал без TTY) читаем обычным способом.
*/
private String readPassword(String prompt) throws IOException {
org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder()
.system(true)
@@ -145,26 +165,27 @@ public class LoginMenu {
int key = passTerminal.reader().read();
if (key == 27) {
int next = passTerminal.reader().read();
if (next == 91) {
passTerminal.reader().read();
// Escape sequence consume remaining bytes (arrow keys, etc.)
int next = passTerminal.reader().read(50);
if (next == 91) { // '[' arrow key sequence
passTerminal.reader().read(50); // consume 'A'/'B'/'C'/'D'
}
continue;
}
if (key == 13 || key == 10) {
if (key == 13 || key == 10) { // Enter
passTerminal.writer().println();
break;
} else if (key == 127 || key == 8) {
} else if (key == 127 || key == 8) { // Backspace
if (password.length() > 0) {
password.setLength(password.length() - 1);
passTerminal.writer().print("\b \b");
passTerminal.writer().flush();
}
} else if (key == 3) {
} else if (key == 3) { // Ctrl+C
passTerminal.writer().println();
System.exit(0);
} else if (key >= 32 && key < 127) {
} else if (key >= 32 && key < 127) { // Printable characters
password.append((char) key);
passTerminal.writer().print('*');
passTerminal.writer().flush();
@@ -18,17 +18,17 @@ public class ServerCheckMenu {
public void show() throws IOException {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Connection Diagnostics"));
System.out.println(ZAnsi.header("Диагностика подключения"));
List<String> options = List.of(
"Check ZernMC server connection",
"Check Mojang (Minecraft) access",
"Check Fabric Meta access",
"Check Forge Maven access",
"Back to main menu"
"Проверить подключение к ZernMC серверу",
"Проверить доступ к Mojang (Minecraft)",
"Проверить доступ к Fabric Meta",
"Проверить доступ к Forge Maven",
"Назад в главное меню"
);
ArrowMenu menu = new ArrowMenu("Select check", options);
ArrowMenu menu = new ArrowMenu("Выберите проверку", options);
int choice = menu.show();
if (choice == -1 || choice == 4) {
@@ -49,20 +49,20 @@ public class ServerCheckMenu {
}
private void checkZernServer() {
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
try {
String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("[OK] ZernMC server connected successfully!"));
System.out.println(ZAnsi.white("Server response: ") + response);
System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!"));
System.out.println(ZAnsi.white("Ответ сервера: ") + response);
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Could not connect to ZernMC server"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу"));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
}
}
private void checkMojang() {
System.out.println(ZAnsi.cyan("Checking Mojang access..."));
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
try {
HttpClient client = HttpClient.newBuilder()
@@ -77,18 +77,18 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Mojang is accessible"));
System.out.println(ZAnsi.brightGreen("[OK] Mojang доступен"));
} else {
System.out.println(ZAnsi.brightRed("[FAIL] Mojang returned code " + response.statusCode()));
System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode()));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Mojang"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang"));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
}
}
private void checkFabric() {
System.out.println(ZAnsi.cyan("Checking Fabric Meta access..."));
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
try {
HttpClient client = HttpClient.newBuilder()
@@ -103,18 +103,18 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta is accessible"));
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta доступен"));
} else {
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta returned code " + response.statusCode()));
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta вернул код " + response.statusCode()));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Fabric Meta"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta"));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
}
}
private void checkForge() {
System.out.println(ZAnsi.cyan("Checking Forge Maven access..."));
System.out.println(ZAnsi.cyan("Проверка доступа к Forge Maven..."));
try {
HttpClient client = HttpClient.newBuilder()
@@ -129,13 +129,13 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven is accessible"));
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven доступен"));
} else {
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven returned code " + response.statusCode()));
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven вернул код " + response.statusCode()));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Forge Maven"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Forge Maven"));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
}
}
}
@@ -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<String> 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("В будущем здесь будет список предустановленных оптимизаций.");
}
}
@@ -18,12 +18,12 @@ public class UpdateMenu {
public void show() throws IOException {
List<String> options = List.of(
"Check pack updates",
"Check launcher updates",
"Back to main menu"
"Проверить обновления сборки (модпака)",
"Проверить обновления лаунчера",
"Назад в главное меню"
);
ArrowMenu menu = new ArrowMenu("Update Check", options);
ArrowMenu menu = new ArrowMenu("Проверка обновлений", 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("Error: " + e.getMessage()));
System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage()));
e.printStackTrace();
ConsoleUtils.pause();
}
@@ -44,7 +44,7 @@ public class UpdateMenu {
}
private void checkPackUpdates() throws Exception {
System.out.println(ZAnsi.cyan("Checking pack updates..."));
System.out.println(ZAnsi.cyan("Проверка обновлений сборок..."));
List<Instance> instances = InstanceManager.getAllInstances();
List<Instance> serverInstances = instances.stream()
@@ -52,12 +52,12 @@ public class UpdateMenu {
.collect(Collectors.toList());
if (serverInstances.isEmpty()) {
System.out.println(ZAnsi.yellow("No server-installed packs found."));
System.out.println(ZAnsi.yellow("Нет сборок, установленных с сервера."));
ConsoleUtils.pause();
return;
}
System.out.println(ZAnsi.cyan("\nChecking updates for server packs:\n"));
System.out.println(ZAnsi.cyan("\nПроверка обновлений для серверных сборок:\n"));
boolean hasUpdates = false;
List<Instance> updatableInstances = new ArrayList<>();
@@ -66,43 +66,45 @@ public class UpdateMenu {
PackDownloader downloader = new PackDownloader(instance);
try {
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
int serverVersion = downloader.checkForUpdates(instance.getServerPackName());
boolean hasUpdate = serverVersion > 0;
if (hasUpdate) {
System.out.println(ZAnsi.yellow(instance.getName() + " - Update available!"));
System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!"));
updatableInstances.add(instance);
hasUpdates = true;
} else {
System.out.println(ZAnsi.green(instance.getName() + " - Up to date"));
System.out.println(ZAnsi.green(instance.getName() + " - Актуальна"));
}
} catch (Exception e) {
System.out.println(ZAnsi.red(instance.getName() + " - Check error: " + e.getMessage()));
System.out.println(ZAnsi.red(instance.getName() + " - Ошибка проверки: " + e.getMessage()));
}
}
if (!hasUpdates) {
System.out.println(ZAnsi.green("\nAll packs are up to date!"));
System.out.println(ZAnsi.green("\nВсе сборки актуальны!"));
ConsoleUtils.pause();
return;
}
// Предлагаем обновить каждую сборку отдельно
for (Instance instance : updatableInstances) {
System.out.println(ZAnsi.brightYellow("\nUpdate pack '" + instance.getName() + "'?"));
if (Input.confirm("Update")) {
System.out.println(ZAnsi.cyan("Updating " + instance.getName() + "..."));
System.out.println(ZAnsi.brightYellow("\nОбновить сборку '" + instance.getName() + "'?"));
if (Input.confirm("Обновить")) {
System.out.println(ZAnsi.cyan("Обновление " + instance.getName() + "..."));
PackDownloader downloader = new PackDownloader(instance);
try {
boolean success = downloader.updatePack(instance.getServerPackName());
if (success) {
System.out.println(ZAnsi.brightGreen(instance.getName() + " updated"));
System.out.println(ZAnsi.brightGreen(instance.getName() + " обновлен"));
} else {
System.out.println(ZAnsi.brightRed(instance.getName() + " update failed"));
System.out.println(ZAnsi.brightRed(instance.getName() + " не удалось обновить"));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage()));
}
} else {
System.out.println(ZAnsi.yellow(" Skipped: " + instance.getName()));
System.out.println(ZAnsi.yellow(" Пропущено: " + instance.getName()));
}
}
@@ -110,27 +112,28 @@ public class UpdateMenu {
}
private void checkLauncherUpdates() {
System.out.println(ZAnsi.cyan("Checking launcher updates..."));
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
try {
String json = ZHttpClient.getLauncherVersionInfo();
String serverVersion = extractVersion(json);
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
System.out.println(ZAnsi.white("Current version: ") + currentVersion);
System.out.println(ZAnsi.white("Server version: ") + serverVersion);
System.out.println(ZAnsi.white("Текущая версия: ") + currentVersion);
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion);
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
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."));
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия!"));
if (Input.confirm("Обновить лаунчер?")) {
// Обновление будет при следующем запуске
System.out.println(ZAnsi.green("Лаунчер будет обновлен при следующем запуске."));
}
} else {
System.out.println(ZAnsi.brightGreen("Launcher is up to date."));
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Could not check launcher updates."));
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
}
ConsoleUtils.pause();
@@ -1,7 +1,5 @@
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;
@@ -23,7 +21,6 @@ public class Instance {
private int serverVersion; // версия сборки на сервере
private String serverPackName; // имя пака на сервере
private String fabricVersionId;
private String presetId;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
@@ -91,15 +88,6 @@ public class Instance {
saveMetadata();
}
public String getPresetId() {
return presetId;
}
public void setPresetId(String presetId) {
this.presetId = presetId;
saveMetadata();
}
public String getServerPackName() {
return serverPackName;
}
@@ -142,17 +130,14 @@ public class Instance {
this.isServerPack = meta.isServerPack;
this.serverVersion = meta.serverVersion;
this.serverPackName = meta.serverPackName;
this.presetId = meta.presetId;
} catch (Exception e) {
LauncherLogger.warn("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
}
} catch (Exception ignored) {}
}
private void saveMetadata() {
Path metaFile = path.resolve("instance.json");
InstanceMeta meta = new InstanceMeta(
minecraftVersion, loaderType, loaderVersion, assetIndex,
isServerPack, serverVersion, serverPackName, presetId
isServerPack, serverVersion, serverPackName
);
try {
Files.writeString(metaFile, GSON.toJson(meta));
@@ -169,13 +154,12 @@ 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 presetId) {
String serverPackName) {
this.minecraftVersion = minecraftVersion;
this.loaderType = loaderType;
this.loaderVersion = loaderVersion;
@@ -183,7 +167,6 @@ public class Instance {
this.isServerPack = isServerPack;
this.serverVersion = serverVersion;
this.serverPackName = serverPackName;
this.presetId = presetId;
}
}
}
@@ -1,7 +1,5 @@
package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.Config;
import java.io.IOException;
@@ -53,7 +51,7 @@ public class InstanceManager {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
LauncherLogger.warn("Не удалось удалить: " + path);
System.err.println("Не удалось удалить: " + path);
}
});
return true;
@@ -1,24 +1,18 @@
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.ForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
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 {
@@ -44,13 +38,13 @@ public class MinecraftLib {
}
public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception {
return new ModLoaderInstaller(instance)
.install(minecraftVersion, forgeVersion, ModLoaderInstaller.LoaderType.FORGE);
ForgeInstaller installer = new ForgeInstaller(instance);
return installer.install(minecraftVersion, forgeVersion);
}
public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception {
return new ModLoaderInstaller(instance)
.install(minecraftVersion, neoforgeVersion, ModLoaderInstaller.LoaderType.NEOFORGE);
NeoForgeInstaller installer = new NeoForgeInstaller(instance);
return installer.install(minecraftVersion, neoforgeVersion);
}
public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception {
@@ -58,7 +52,7 @@ public class MinecraftLib {
boolean success = installer.install(minecraftVersion, loaderVersion);
if (success) {
// Save info to Instance
// Сохраняем информацию в Instance
instance.setMinecraftVersion(minecraftVersion);
instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVersion);
@@ -67,119 +61,81 @@ public class MinecraftLib {
}
/**
* Full pack install (vanilla + loader + mods)
* Stub - will be expanded
* Полная установка сборки (vanilla + loader + моды)
* Пока заглушка будем расширять
*/
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
LauncherLogger.info("Starting full pack install: " + packName);
System.out.println(ZAnsi.cyan("Начинается полная установка сборки: " + packName));
// 1. Install Minecraft
// 1. Устанавливаем Minecraft
boolean mcInstalled = installMinecraft(minecraftVersion);
if (!mcInstalled) {
LauncherLogger.error("Failed to install Minecraft " + minecraftVersion);
System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion));
return false;
}
// 2. Install loader
// 2. Устанавливаем лоадер
if ("fabric".equalsIgnoreCase(loaderType)) {
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
if (!fabricInstalled) {
LauncherLogger.error("Failed to install Fabric");
System.out.println(ZAnsi.brightRed("Не удалось установить Fabric"));
return false;
}
} else if ("forge".equalsIgnoreCase(loaderType)) {
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
if (!forgeInstalled) {
LauncherLogger.error("Failed to install Forge");
System.out.println(ZAnsi.brightRed("Не удалось установить Forge"));
return false;
}
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
if (!neoforgeInstalled) {
LauncherLogger.error("Failed to install NeoForge");
System.out.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
return false;
}
}
// 3. In the future: diff and mod download
// 3. В будущем здесь будет diff и скачивание модов
LauncherLogger.info("Basic pack install complete!");
System.out.println(ZAnsi.brightGreen("Базовая установка сборки завершена!"));
return true;
}
//Launch
//Запуск
public void launch(LaunchOptions options) throws Exception {
LauncherLogger.info("Launching pack: " + instance.getName());
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
cleanupOldLoaders();
validateJarFiles();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
LauncherLogger.info("Launch command (" + command.size() + " args) written below");
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);
// 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...");
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
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);
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
}
private void safeDeleteDirectory(Path dir) {
try (var stream = Files.walk(dir)) {
stream.sorted((a, b) -> b.compareTo(a))
try {
Files.walk(dir)
.sorted((a, b) -> b.compareTo(a))
.forEach(p -> {
try { Files.deleteIfExists(p); }
catch (IOException e) { /* ignore */ }
catch (IOException ignored) {}
});
} catch (IOException e) {
LauncherLogger.warn("safeDeleteDirectory: " + e.getMessage());
}
} catch (IOException ignored) {}
}
private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException {
@@ -214,9 +170,9 @@ public class MinecraftLib {
if (currentLoaderVer == null) return;
LauncherLogger.info("Cleaning old loader versions...");
System.out.println(ZAnsi.yellow("Выполняем очистку старых версий лоадера..."));
// Delete all old fabric-loader / forge
// Удаляем все старые fabric-loader / forge
Path libraries = instance.getPath().resolve("libraries");
if ("fabric".equals(loaderType)) {
@@ -234,40 +190,6 @@ public class MinecraftLib {
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;
}
@@ -0,0 +1,631 @@
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.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 ProgressCallback progressCallback;
public interface ProgressCallback {
void onProgress(ProgressInfo info);
}
public static class ProgressInfo {
private String phase;
private int totalFiles;
private int downloadedFiles;
private String currentFile;
private long fileSize;
private long downloadedBytes;
private int filePercent;
private int totalPercent;
private String eta;
public ProgressInfo(String phase, int totalFiles, int downloadedFiles, String currentFile,
long fileSize, long downloadedBytes, int filePercent, int totalPercent, String eta) {
this.phase = phase;
this.totalFiles = totalFiles;
this.downloadedFiles = downloadedFiles;
this.currentFile = currentFile;
this.fileSize = fileSize;
this.downloadedBytes = downloadedBytes;
this.filePercent = filePercent;
this.totalPercent = totalPercent;
this.eta = eta;
}
public String getPhase() { return phase; }
public int getTotalFiles() { return totalFiles; }
public int getDownloadedFiles() { return downloadedFiles; }
public String getCurrentFile() { return currentFile; }
public long getFileSize() { return fileSize; }
public long getDownloadedBytes() { return downloadedBytes; }
public int getFilePercent() { return filePercent; }
public int getTotalPercent() { return totalPercent; }
public String getEta() { return eta; }
}
public PackDownloader(Instance instance) {
this.instance = instance;
}
public void setProgressCallback(ProgressCallback callback) {
this.progressCallback = callback;
}
/**
* Получить список доступных паков с сервера
*/
public List<ServerPack> getAvailablePacks() throws Exception {
String accessToken = AuthManager.getAccessToken();
if (accessToken == null) {
throw new IOException("Не авторизован. Требуется проходка для просмотра сборок.");
}
if (!AuthManager.canViewPacks()) {
throw new IOException("Для просмотра сборок требуется активная проходка");
}
// Используем HttpURLConnection для GET с авторизацией
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("Для просмотра сборок требуется активная проходка");
}
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<ServerPack> parsePacksResponse(String responseBody) {
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray packsArray = root.getAsJsonArray("packs");
List<ServerPack> 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) {
System.err.println("Ошибка парсинга пака: " + 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 ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
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<String, String> 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;
}
/**
* Проверить наличие обновлений для серверной сборки
* @return версия на сервере, или 0 если нет обновлений
*/
public int checkForUpdates(String packName) throws Exception {
if (!instance.isServerPack()) return 0;
PackManifest manifest = getPackManifest(packName);
int serverVersion = manifest.getVersion();
int localVersion = instance.getServerVersion();
return serverVersion > localVersion ? serverVersion : 0;
}
/**
* Обновить существующую серверную сборку
*/
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<String, String> 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;
}
/**
* Сканирование локальных файлов и вычисление хешей
*/
public Map<String, String> scanLocalFiles() throws IOException {
Map<String, String> files = new HashMap<>();
Path instancePath = instance.getPath();
// Игнорируемые директории
Set<String> 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 запрос на сервер (получить список файлов для обновления)
*/
public DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
String json = gson.toJson(localFiles);
// Получаем токен авторизации
String accessToken = AuthManager.getAccessToken();
if (accessToken == null) {
throw new IOException("Не авторизован. Требуется проходка для скачивания сборок.");
}
if (!AuthManager.canDownloadPacks()) {
throw new IOException("Для скачивания сборок требуется активная проходка");
}
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
// Используем HttpURLConnection для полного контроля
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);
// Отправляем 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();
// Читаем ответ
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();
if (responseCode == 403) {
throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору.");
}
if (responseCode != 200) {
throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody));
}
return gson.fromJson(responseBody, DiffResponse.class);
} 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;
}
/**
* Применить 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() + " файлов");
if (progressCallback != null) {
progressCallback.onProgress(new ProgressInfo("starting", diff.getToDownload().size(), 0, "", 0, 0, 0, 0, ""));
}
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, progressCallback, downloaded.get(), total);
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, "файлов");
}
if (progressCallback != null) {
progressCallback.onProgress(new ProgressInfo("downloading", total, downloaded.get(), path,
file.getSize(), file.getSize(), 100, (downloaded.get() * 100) / total, ""));
}
} catch (Exception e) {
System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage()));
return false;
}
}
if (total > 0) {
ProgressBar.finish("Скачивание");
}
if (progressCallback != null) {
progressCallback.onProgress(new ProgressInfo("complete", total, total, "", 0, 0, 100, 100, ""));
}
return true;
}
/**
* Скачать один файл с сервера
*/
private void downloadFile(FileInfo file, Path destination) throws Exception {
downloadFile(file, destination, null, 0, 0);
}
private void downloadFile(FileInfo file, Path destination, ProgressCallback callback, int downloadedFiles, int totalFiles) throws Exception {
String url = ZHttpClient.getBaseUrl() + file.getUrl();
HttpRequest request = HttpRequest.newBuilder()
.uri(java.net.URI.create(url))
.GET()
.build();
HttpResponse<InputStream> 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();
long lastCallbackTime = 0;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
totalRead += bytesRead;
if (fileSize > 0) {
ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize);
long now = System.currentTimeMillis();
if (callback != null && now - lastCallbackTime > 200) {
int filePercent = (int) ((totalRead * 100) / fileSize);
int totalPercent = totalFiles > 0 ? ((downloadedFiles * 100 + filePercent) / totalFiles) : 0;
callback.onProgress(new ProgressInfo("downloading", totalFiles, downloadedFiles, file.getPath(),
fileSize, totalRead, filePercent, totalPercent, ""));
lastCallbackTime = now;
}
}
}
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();
}
// ====================== Вложенные классы ======================
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<String, Object> 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<String, Object> getFiles() { return files; }
public boolean isEmpty() { return files == null || files.isEmpty(); }
}
public static class DiffResponse {
private int version;
private List<FileInfo> to_download;
private List<String> to_delete;
private List<String> to_update;
public int getVersion() { return version; }
public List<FileInfo> getToDownload() { return to_download != null ? to_download : new ArrayList<>(); }
public List<String> getToDelete() { return to_delete != null ? to_delete : new ArrayList<>(); }
public List<String> 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; }
}
}
@@ -0,0 +1,213 @@
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("<latest>") + 8;
int end = xml.indexOf("</latest>", 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<String> 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<Path> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode() + " при скачивании " + url);
}
}
}
@@ -0,0 +1,270 @@
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<InputStream> 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<String, String> 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<String, String> 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);
}
}
}
}
}
}
@@ -0,0 +1,257 @@
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 NeoForgeInstaller {
private final Instance instance;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(30))
.build();
public NeoForgeInstaller(Instance instance) {
this.instance = instance;
}
public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Установка NeoForge " + neoForgeVersion + " для Minecraft " + mcVersion));
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);
String mavenGroup = getMavenGroup(mcVersion);
String mavenArtifact = getMavenArtifact(mcVersion);
// Формируем путь к версии
String versionName = mcVersion + "-" + neoForgeVersion;
Path versionDir = instance.getPath().resolve("versions").resolve(versionName);
Files.createDirectories(versionDir);
// Скачиваем universal.jar (это основной JAR NeoForge)
String baseMavenUrl = "https://maven.neoforged.net/releases/"
+ mavenGroup.replace('.', '/') + "/"
+ mavenArtifact + "/"
+ neoForgeVersion + "/";
String universalJarUrl = baseMavenUrl + mavenArtifact + "-" + neoForgeVersion + "-universal.jar";
Path neoForgeJar = versionDir.resolve(versionName + ".jar");
System.out.println(ZAnsi.cyan("Скачивание NeoForge universal.jar..."));
downloadFileDirect(universalJarUrl, neoForgeJar);
// Создаем version.json вручную
System.out.println(ZAnsi.cyan("Создание version.json..."));
createVersionJson(versionDir.resolve(versionName + ".json"), mcVersion, neoForgeVersion, mavenArtifact);
// Скачиваем необходимые библиотеки
System.out.println(ZAnsi.cyan("Скачивание библиотек NeoForge..."));
downloadNeoForgeLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " успешно установлен!"));
instance.setMinecraftVersion(mcVersion);
instance.setLoaderType("neoforge");
instance.setLoaderVersion(neoForgeVersion);
return true;
}
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);
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<InputStream> 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("NeoForge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
lastPercent = percent;
}
} else {
char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4;
System.out.print("\rСкачивание NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
}
}
}
ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
}
private void downloadFileDirect(String url, Path target) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode() + " for " + url);
}
System.out.println(ZAnsi.green(" " + target.getFileName() + " завершено ✓"));
}
private void createVersionJson(Path jsonFile, String mcVersion, String neoForgeVersion, String mavenArtifact) throws IOException {
// Создаем минимальный version.json для NeoForge
String versionName = mcVersion + "-" + neoForgeVersion;
String json = """
{
"id": "%s",
"type": "release",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"inheritsFrom": "%s",
"arguments": {
"--tweakClass": "cpw.mods.fml.relauncher.CoreModManager"
},
"libraries": [
{"name": "net.neoforged:neoforge:%s"},
{"name": "cpw.mods:bootstraplauncher:1.1.2"},
{"name": "net.minecraftforge:unsafe:0.2.0"},
{"name": "net.minecraftforge:srgutils:0.4.4"},
{"name": "net.minecraftforge:modlauncher:10.2.1"},
{"name": "net.minecraftforge:coremods:5.0.1"},
{"name": "net.minecraftforge:accesstransformers:8.8"},
{"name": "net.minecraftforge:eventbus:6.0.5"},
{"name": "net.minecraftforge:forgemin:0.1.1"},
{"name": "net.minecraftforge:scanner:1.2.2"},
{"name": "com.google.code.gson:gson:2.10.1"},
{"name": "com.google.guava:guava:32.1.3-jre"},
{"name": "org.apache.commons:commons-lang3:3.13.0"},
{"name": "org.jline:jline-reader:3.12.1"},
{"name": "org.jline:jline-terminal:3.12.1"}
]
}
""".formatted(versionName, mcVersion, neoForgeVersion);
Files.writeString(jsonFile, json);
System.out.println(ZAnsi.green(" version.json создан ✓"));
}
private void downloadNeoForgeLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
System.out.println(ZAnsi.cyan("Скачивание библиотек NeoForge..."));
String baseMavenUrl = "https://maven.neoforged.net/releases/"
+ mavenGroup.replace('.', '/') + "/";
Path librariesDir = instance.getPath().resolve("libraries");
// Список основных библиотек NeoForge
String[][] libs = {
{mavenGroup, mavenArtifact, neoForgeVersion},
{"cpw.mods", "bootstraplauncher", "1.1.2"},
{"net.minecraftforge", "unsafe", "0.2.0"},
{"net.minecraftforge", "srgutils", "0.4.4"},
{"net.minecraftforge", "modlauncher", "10.2.1"},
{"net.minecraftforge", "coremods", "5.0.1"},
{"net.minecraftforge", "accesstransformers", "8.8"},
{"net.minecraftforge", "eventbus", "6.0.5"},
{"net.minecraftforge", "forgemin", "0.1.1"},
{"net.minecraftforge", "scanner", "1.2.2"}
};
for (String[] lib : libs) {
String group = lib[0].replace('.', '/');
String artifact = lib[1];
String version = lib[2];
String jarName = artifact + "-" + version + ".jar";
String mavenPath = group + "/" + artifact + "/" + version + "/" + jarName;
Path target = librariesDir.resolve(mavenPath);
if (Files.exists(target)) {
System.out.println(ZAnsi.green(" " + jarName + " уже есть ✓"));
continue;
}
Files.createDirectories(target.getParent());
String url = baseMavenUrl + mavenPath;
try {
downloadFileDirect(url, target);
} catch (Exception e) {
// Пробуем Maven Central как fallback
try {
String centralUrl = "https://repo1.maven.org/maven2/" + mavenPath;
downloadFileDirect(centralUrl, target);
} catch (Exception e2) {
System.out.println(ZAnsi.yellow(" Предупреждение: не удалось скачать " + jarName));
}
}
}
System.out.println(ZAnsi.green("Библиотеки NeoForge обработаны ✓"));
}
}
@@ -0,0 +1,245 @@
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<MinecraftVersion> 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<MinecraftVersion> 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<CompletableFuture<Void>> 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<Void> 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<String> 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<Path> 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) + ")");
}
}
}
@@ -0,0 +1,426 @@
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 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 LaunchCommandBuilder {
private final Instance instance;
public LaunchCommandBuilder(Instance instance) {
this.instance = instance;
}
public List<String> build(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "..."));
List<String> command = new ArrayList<>();
String javaPath = "java";
command.add(javaPath);
command.addAll(getJvmArguments(options));
Path nativesDir = instance.getPath().resolve("natives");
if (!Files.exists(nativesDir)) {
Files.createDirectories(nativesDir);
}
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
VersionManifest manifest = resolveVersionManifest();
if (manifest != null) {
command.add("-cp");
command.add(buildClasspathFromManifest(manifest));
String mainClass = resolveMainClass(manifest);
command.add(mainClass);
command.addAll(resolveGameArguments(manifest, options));
} else {
command.add("-cp");
command.add(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);
System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName()));
return new VersionManifest(json);
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось загрузить 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 ("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";
}
return "net.minecraft.client.main.Main";
}
private List<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) {
List<String> args = new ArrayList<>();
Map<String, String> 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<String> getVanillaGameArguments(LaunchOptions options) {
List<String> 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());
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");
return args;
}
private Map<String, String> buildVariableMap(LaunchOptions options) {
Map<String, String> 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", instance.getName());
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<String, String> vars) {
if (!raw.contains("${")) return raw;
String result = raw;
for (Map.Entry<String, String> entry : vars.entrySet()) {
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
}
return result;
}
private String buildClasspathFromManifest(VersionManifest manifest) throws Exception {
List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries");
for (VersionManifest.Library lib : manifest.getLibraries()) {
Path libPath = librariesDir.resolve(lib.artifactPath);
if (Files.exists(libPath)) {
paths.add(libPath.toAbsolutePath().toString());
} else {
String mavenPath = mavenToPath(lib.name);
Path fallbackPath = librariesDir.resolve(mavenPath);
if (Files.exists(fallbackPath)) {
paths.add(fallbackPath.toAbsolutePath().toString());
} else {
System.out.println(ZAnsi.yellow(" Библиотека не найдена: " + lib.name));
}
}
}
Path versionJar = findVersionJar();
if (versionJar != null) {
paths.add(0, versionJar.toAbsolutePath().toString());
}
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
return String.join(separator, paths);
}
private String buildVanillaClasspath() throws Exception {
List<String> 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;
}
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<String> getJvmArguments(LaunchOptions options) {
List<String> 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;
}
}
@@ -1,7 +1,5 @@
package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -17,15 +15,13 @@ public class VersionManifest {
private final String id;
private final String mainClass;
private final String assetIndexId;
private final String inheritsFrom;
private final List<String> jvmArguments;
private final List<String> gameArguments;
private final List<Library> libraries;
private VersionManifest parent;
public VersionManifest(JSONObject json) {
this.id = json.getString("id");
this.mainClass = json.optString("mainClass", "net.minecraft.client.main.Main");
this.mainClass = json.getString("mainClass");
if (json.has("assetIndex")) {
JSONObject ai = json.getJSONObject("assetIndex");
@@ -34,7 +30,6 @@ public class VersionManifest {
this.assetIndexId = "unknown";
}
this.inheritsFrom = json.optString("inheritsFrom", null);
this.jvmArguments = parseArguments(json, "jvm");
this.gameArguments = parseArguments(json, "game");
this.libraries = parseLibraries(json);
@@ -43,65 +38,10 @@ public class VersionManifest {
public String getId() { return id; }
public String getMainClass() { return mainClass; }
public String getAssetIndexId() { return assetIndexId; }
public String getInheritsFrom() { return inheritsFrom; }
public List<String> getJvmArguments() { return jvmArguments; }
public List<String> getGameArguments() { return gameArguments; }
public List<Library> getLibraries() { return libraries; }
public List<String> getAllJvmArguments() {
List<String> all = new ArrayList<>();
if (parent != null) {
all.addAll(parent.getAllJvmArguments());
}
all.addAll(jvmArguments);
return all;
}
public List<String> getAllGameArguments() {
List<String> 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<Library> getAllLibraries() {
List<Library> 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<Library> list, String name) {
for (Library l : list) {
if (l.name.equals(name)) return true;
}
return false;
}
private List<String> parseArguments(JSONObject json, String type) {
List<String> args = new ArrayList<>();
if (!json.has("arguments")) return args;
@@ -159,17 +99,8 @@ public class VersionManifest {
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
if (key.startsWith("is_demo_user") || key.startsWith("has_custom_resolution")) continue;
matches = false;
break;
}
// Неизвестная фича считаем false
matches = false;
break;
}
}
@@ -189,11 +120,9 @@ public class VersionManifest {
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");
}
String name = libJson.getString("name");
String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path");
Library lib = new Library(name, artifactPath);
if (libJson.has("natives")) {
@@ -204,16 +133,17 @@ public class VersionManifest {
}
}
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) {
if (ruleMatches(dummyObj)) {
libs.add(lib);
}
} else {
libs.add(lib);
}
}
}
return libs;
}
@@ -37,7 +37,5 @@ public class LaunchOptions {
public void setExtraJvmArgs(List<String> 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; }
}
@@ -6,8 +6,6 @@ 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 {
@@ -16,22 +14,16 @@ public class ArrowMenu {
private final List<String> options;
private int selected = 0;
private final Terminal terminal;
private final InputStream rawInput;
private static final int VISIBLE_ITEMS = 7;
private static final int VISIBLE_ITEMS = 7; // сколько строк показывать в списке
public ArrowMenu(String title, List<String> 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)
.jna(true)
.build();
this.rawInput = terminal.input();
}
public int show() throws IOException {
@@ -42,43 +34,33 @@ public class ArrowMenu {
try {
while (true) {
printPagedMenu();
int b = rawInput.read();
if (b == -1) continue;
int key = terminal.reader().read();
// 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') {
if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц'
|| key == 'k' || key == 'K' || key == 'л' || key == 'Л') { // Up / Arrow Up
selected = (selected - 1 + options.size()) % options.size();
}
else if (b == 's' || b == 'S' || b == 'j' || b == 'J') {
else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы'
|| key == 'j' || key == 'J' || key == 'о' || key == 'О') { // Down / Arrow Down
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) {
else if (key == 13 || key == 10) { // Enter
return selected;
}
else if (key == 27) { // Esc or arrow escape seq
int next = terminal.reader().read(50);
if (next == 91) { // '[' start of arrow escape sequence
int arrow = terminal.reader().read(50);
if (arrow == 65) { // 'A' Up arrow
selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 66) { // 'B' Down arrow
selected = (selected + 1) % options.size();
}
// else unknown escape seq, ignore
} else {
return -1; // genuine Esc
}
}
}
} finally {
terminal.puts(InfoCmp.Capability.cursor_visible);
@@ -86,31 +68,19 @@ public class ArrowMenu {
}
}
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);
}
@@ -124,10 +94,10 @@ public class ArrowMenu {
}
}
// Подсказка внизу (фиксированная)
sb.append("\n")
.append(ZAnsi.white("W/S or \u2191/\u2193 - navigate | Enter - select | Esc - back"));
.append(ZAnsi.white("W/S (Ц/Ы) или ↑/↓ - перемещение | Enter - выбрать | Esc - назад"));
System.out.print(sb);
System.out.flush();
}
}
@@ -0,0 +1,137 @@
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 String BUILD_PROFILE = System.getProperty("build.profile", "global");
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 boolean isZernMCBuild() {
return "zernmc".equalsIgnoreCase(BUILD_PROFILE);
}
public static boolean isGlobalBuild() {
return !isZernMCBuild();
}
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";
}
}
@@ -10,9 +10,10 @@ public class ConsoleUtils {
}
public static void pause() {
System.out.print(ZAnsi.white("\nPress Enter to continue..."));
System.out.print(ZAnsi.white("\nНажмите Enter для продолжения..."));
try {
System.in.read();
// Очищаем буфер ввода
while (System.in.available() > 0) {
System.in.read();
}
@@ -3,20 +3,23 @@ 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 {
private static final Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8);
// Используем UTF-8 явно это помогает на Windows
private static final Scanner scanner = new Scanner(System.in, "UTF-8");
public static String readLine() {
return scanner.nextLine().trim();
}
public static String readLine(String prompt) {
flushInput();
flushInput(); // Очищаем буфер
System.out.print(prompt);
return scanner.nextLine().trim();
}
@@ -27,7 +30,7 @@ public class Input {
System.out.print(prompt);
return Integer.parseInt(scanner.nextLine().trim());
} catch (NumberFormatException e) {
System.out.println(ZAnsi.brightRed("Invalid number. Try again."));
System.out.println(ZAnsi.brightRed("Некорректное число. Попробуйте ещё раз."));
}
}
}
@@ -38,41 +41,57 @@ public class Input {
if (value >= min && value <= max) {
return value;
}
System.out.println(ZAnsi.brightRed("Value must be between " + min + " and " + max + "."));
System.out.println(ZAnsi.brightRed("Значение должно быть от " + min + " до " + max + "."));
}
}
/**
* Новый confirm через ArrowMenu
* @throws IOException
*/
public static boolean confirm(String question) throws IOException {
ConsoleUtils.clearScreen();
ConsoleUtils.clearScreen(); // опционально, можно убрать
List<String> options = List.of(
"Yes",
"No"
"Да",
"Нет"
);
ArrowMenu menu = new ArrowMenu(question, options);
int choice = menu.show();
return choice == 0;
return choice == 0; // 0 = "Да"
}
/**
* Альтернативный confirm без очистки экрана
* @throws IOException
*/
public static boolean confirmInline(String question) throws IOException {
List<String> options = List.of("Yes", "No");
List<String> options = List.of("Да", "Нет");
ArrowMenu menu = new ArrowMenu(question, options);
int choice = menu.show();
return choice == 0;
}
/**
* Закрытие сканнера (вызывать при выходе из программы, если нужно)
*/
public static void close() {
scanner.close();
}
/**
* Очищает буфер ввода от оставшихся символов
*/
public static void flushInput() {
try {
while (System.in.available() > 0) {
System.in.read();
}
} catch (IOException e) {
// Игнорируем
}
}
}
@@ -0,0 +1,81 @@
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";
}
}
@@ -34,9 +34,8 @@ public class Version {
public static boolean isNewer(String current, String server) {
if (current == null || server == null) return false;
// Нормализуем версии - убираем суффиксы типа -any, -alpha, -beta, -SNAPSHOT
current = normalizeVersion(current);
server = normalizeVersion(server);
current = current.replace("-SNAPSHOT", "").trim();
server = server.replace("-SNAPSHOT", "").trim();
if (current.equals(server)) return false;
@@ -46,29 +45,12 @@ public class Version {
int max = Math.max(cParts.length, sParts.length);
for (int i = 0; i < max; i++) {
int c = i < cParts.length ? parseVersionPart(cParts[i]) : 0;
int s = i < sParts.length ? parseVersionPart(sParts[i]) : 0;
int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0;
int s = i < sParts.length ? Integer.parseInt(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;
}
}
}
@@ -0,0 +1,550 @@
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.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 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);
/**
* Переопределить URL сервера (для тестов).
* Внимание: не потокобезопасно, использовать только в тестах.
*/
public static void setBaseUrl(String url) {
BASE_URL = url;
}
public static String getBaseUrl() {
return BASE_URL;
}
// Умное проксирование по сервисам
public enum ServiceType {
ZERN_SERVER("http://87.120.187.36:1582", 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<ServiceType, Boolean> serviceProxyMode = new ConcurrentHashMap<>();
private static final Map<ServiceType, Integer> serviceFailCount = new ConcurrentHashMap<>();
private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>();
private static final Map<ServiceType, Boolean> 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<ServiceType> 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()) {
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<String> 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("maven.neoforged.net")) return ServiceType.NEOFORGE_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<String> 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<String> 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<Path> 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<Path> 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.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<String> 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.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/proxy" + endpoint))
.timeout(Duration.ofSeconds(30))
.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<String> 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<String> 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<String> parseFabricVersionsFromJson(String json) {
JSONArray array = new JSONArray(json);
List<String> 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("Принудительно включен глобальный прокси режим"));
}
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);
}
}
}
@@ -0,0 +1,63 @@
package me.sashegdev.zernmc.launcher.web;
import java.awt.GraphicsEnvironment;
import javafx.application.Application;
import javafx.concurrent.Worker;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
public class UIWindow extends Application {
private static String url;
private static int port;
public static void start(int port) {
UIWindow.port = port;
UIWindow.url = "http://localhost:" + port;
Application.launch(UIWindow.class);
}
@Override
public void start(Stage stage) {
stage.setTitle("ZernMC Launcher");
stage.initStyle(StageStyle.UNDECORATED);
WebView webView = new WebView();
WebEngine webEngine = webView.getEngine();
webEngine.load(url);
webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
if (newState == Worker.State.FAILED) {
System.err.println("Failed to load: " + url);
}
});
Scene scene = new Scene(webView);
stage.setScene(scene);
Rectangle2D screenBounds = Screen.getPrimary().getVisualBounds();
double screenWidth = screenBounds.getWidth();
double screenHeight = screenBounds.getHeight();
double windowWidth = Math.min(1200, screenWidth * 0.8);
double windowHeight = Math.min(800, screenHeight * 0.85);
stage.setWidth(windowWidth);
stage.setHeight(windowHeight);
stage.setX((screenWidth - windowWidth) / 2);
stage.setY((screenHeight - windowHeight) / 2);
stage.show();
stage.setOnCloseRequest(event -> {
WebServer.stop();
System.exit(0);
});
}
}
@@ -0,0 +1,463 @@
package me.sashegdev.zernmc.launcher.web;
import io.javalin.Javalin;
import io.javalin.http.staticfiles.Location;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.install.InstallService;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.awt.Desktop;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.ServerSocket;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
public class WebServer {
private static final LauncherAPI api = new LauncherAPI();
private static Javalin app;
private static int currentPort;
private static volatile boolean running = false;
public static int findFreePort(int startPort) throws IOException {
for (int port = startPort; port < startPort + 100; port++) {
if (isPortAvailable(port)) {
return port;
}
}
throw new IOException("Не удалось найти свободный порт в диапазоне " + startPort + "-" + (startPort + 99));
}
private static boolean isPortAvailable(int port) {
try (ServerSocket socket = new ServerSocket(port)) {
return true;
} catch (IOException e) {
return false;
}
}
public static void start(int port) throws Exception {
currentPort = port;
running = true;
// Отключаем логирование Javalin в консоль
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "error");
app = Javalin.create(config -> {
config.staticFiles.add("/webapp", Location.CLASSPATH);
config.staticFiles.add("/assets", Location.CLASSPATH);
}).start(port);
// API эндпоинты
setupApiRoutes();
System.out.println(ZAnsi.brightGreen("✓ Web UI готов на http://localhost:" + port));
// Блокируем главный поток (сервер работает)
while (running) {
Thread.sleep(1000);
}
}
private static void setupApiRoutes() {
// Auth
app.get("/api/auth/status", ctx -> {
if (AuthManager.loadSavedSession()) {
ctx.json(Map.of(
"success", true,
"loggedIn", true,
"username", AuthManager.getUsername()
));
} else {
ctx.json(Map.of(
"success", true,
"loggedIn", false
));
}
});
app.post("/api/auth/login", ctx -> {
Map<String, String> body = ctx.bodyAsClass(Map.class);
String username = body.get("username");
String password = body.get("password");
if (username == null || password == null) {
ctx.status(400).json(Map.of("success", false, "error", "Missing username or password"));
return;
}
var result = api.login(username, password);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "username", username));
} else {
ctx.status(401).json(Map.of("success", false, "error", result.getError()));
}
});
app.post("/api/auth/logout", ctx -> {
AuthManager.logout();
ctx.json(Map.of("success", true));
});
// Instances - локальные
app.get("/api/instances", ctx -> {
var result = api.getAllInstances();
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "data", result.getData()));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Instance детали
app.get("/api/instances/{name}", ctx -> {
String name = ctx.pathParam("name");
var result = api.instances().getInstance(name);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "data", result.getData()));
} else {
ctx.status(404).json(Map.of("success", false, "error", result.getError()));
}
});
// Launch
app.post("/api/instances/{name}/launch", ctx -> {
String name = ctx.pathParam("name");
var result = api.launch(name);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "message", "Launch started"));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Delete
app.post("/api/instances/{name}/delete", ctx -> {
String name = ctx.pathParam("name");
var result = api.instances().deleteInstance(name);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "message", "Instance deleted"));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// ZernMC серверные сборки
app.get("/api/instances/zernmc", ctx -> {
// TODO: получить реальные сборки с сервера
List<Map<String, Object>> packs = List.of(
Map.of("name", "ZernMC SkyBlock", "version", 1, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 150),
Map.of("name", "ZernMC RPG", "version", 3, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 200)
);
ctx.json(Map.of("success", true, "data", packs));
});
// Установка ZernMC сборки
app.post("/api/instances/zernmc/install", ctx -> {
Map<String, String> body = ctx.bodyAsClass(Map.class);
String packName = body.get("packName");
String instanceName = body.get("instanceName");
if (packName == null || instanceName == null) {
ctx.status(400).json(Map.of("success", false, "error", "Missing packName or instanceName"));
return;
}
var result = api.install().installZernMCPack(packName, instanceName);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"name", result.getData().getName(),
"mcVersion", result.getData().getMcVersion(),
"loaderType", result.getData().getLoaderType(),
"serverVersion", result.getData().getServerVersion()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// SSE прогресс установки
app.get("/api/instances/{name}/install/stream", ctx -> {
ctx.header("Content-Type", "text/event-stream");
ctx.header("Cache-Control", "no-cache");
ctx.header("Connection", "keep-alive");
String instanceName = ctx.pathParam("name");
var instanceInfo = api.instances().getInstance(instanceName);
if (!instanceInfo.isSuccess() || instanceInfo.getData() == null) {
ctx.result("data: {\"phase\":\"error\",\"message\":\"Instance not found\"}\n\n");
return;
}
var os = ctx.outputStream();
InstallService service = new InstallService();
service.setProgressCallback(info -> {
try {
String json = String.format(
"{\"phase\":\"%s\",\"totalFiles\":%d,\"downloadedFiles\":%d,\"currentFile\":\"%s\",\"fileSize\":%d,\"downloadedBytes\":%d,\"filePercent\":%d,\"totalPercent\":%d,\"eta\":\"%s\"}",
info.getPhase(), info.getTotalFiles(), info.getDownloadedFiles(),
info.getCurrentFile() != null ? info.getCurrentFile().replace("\"", "\\\"") : "",
info.getFileSize(), info.getDownloadedBytes(),
info.getFilePercent(), info.getTotalPercent(),
info.getEta() != null ? info.getEta() : ""
);
os.write(("data: " + json + "\n\n").getBytes());
os.flush();
} catch (Exception e) {}
});
var result = service.installZernMCPack(instanceInfo.getData().getServerPackName(), instanceName);
try {
if (!result.isSuccess()) {
os.write(("data: {\"phase\":\"error\",\"message\":\"" + result.getError().replace("\"", "\\\"") + "\"}\n\n").getBytes());
} else {
os.write("data: {\"phase\":\"complete\"}\n\n".getBytes());
}
os.flush();
} catch (Exception e) {}
});
// Проверка обновлений
app.get("/api/instances/{name}/updates", ctx -> {
String name = ctx.pathParam("name");
var result = api.install().checkForUpdates(name);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"hasUpdate", result.getData().isHasUpdate(),
"isServerPack", result.getData().isServerPack(),
"currentVersion", result.getData().getCurrentVersion(),
"latestVersion", result.getData().getLatestVersion()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Проверка хешей
app.get("/api/instances/{name}/verify", ctx -> {
String name = ctx.pathParam("name");
var result = api.install().verifyHashes(name);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"hasMismatches", result.getData().hasMismatches(),
"mismatchedFiles", result.getData().getMismatchedFiles()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Получение времени игры
app.get("/api/instances/{name}/playtime", ctx -> {
String name = ctx.pathParam("name");
var result = api.install().getPlayTime(name);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"totalMinutes", result.getData().getTotalMinutes(),
"fromServer", result.getData().isFromServer(),
"formatted", result.getData().getFormattedTime()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Minecraft версии
app.get("/api/versions", ctx -> {
List<String> versions = List.of(
"1.21.4", "1.21.3", "1.21.2", "1.21.1", "1.21",
"1.20.4", "1.20.3", "1.20.2", "1.20.1", "1.20",
"1.19.4", "1.19.3", "1.19.2", "1.19.1", "1.19",
"1.18.2", "1.18.1", "1.18",
"1.17.1", "1.17"
);
ctx.json(Map.of("success", true, "data",
versions.stream().map(v -> Map.of("id", v)).toList()
));
});
// Версии лоадеров для конкретной версии Minecraft
app.get("/api/versions/{version}/loaders/{loader}", ctx -> {
String version = ctx.pathParam("version");
String loader = ctx.pathParam("loader");
List<Map<String, String>> loaderVersions = switch (loader.toLowerCase()) {
case "fabric" -> List.of(
Map.of("version", "0.16.9"),
Map.of("version", "0.16.8"),
Map.of("version", "0.16.7"),
Map.of("version", "0.16.6"),
Map.of("version", "0.16.5"),
Map.of("version", "0.15.11"),
Map.of("version", "0.15.10"),
Map.of("version", "0.15.9")
);
case "forge" -> List.of(
Map.of("version", "1.21-51.0.0"),
Map.of("version", "1.20.4-49.0.0"),
Map.of("version", "1.20.1-47.1.0"),
Map.of("version", "1.19.2-43.2.0"),
Map.of("version", "1.18.2-40.2.0")
);
case "neoforge" -> List.of(
Map.of("version", "21.0.0-beta"),
Map.of("version", "1.21-21.0.0"),
Map.of("version", "1.20.4-21.0.0"),
Map.of("version", "1.20.1-21.0.0")
);
default -> List.of();
};
ctx.json(Map.of("success", true, "data", loaderVersions));
});
// Установка ванильной сборки
app.post("/api/instances/vanilla/install", ctx -> {
Map<String, String> body = ctx.bodyAsClass(Map.class);
String mcVersion = body.get("mcVersion");
String loader = body.get("loader");
String loaderVersion = body.get("loaderVersion");
String instanceName = body.get("instanceName");
if (mcVersion == null || instanceName == null) {
ctx.status(400).json(Map.of("success", false, "error", "Missing required parameters"));
return;
}
// TODO: реализовать установку ванильной сборки
String desc = loader != null ? mcVersion + " + " + loader + " " + loaderVersion : mcVersion + " Vanilla";
ctx.json(Map.of("success", true, "message", "Vanilla installation started: " + desc));
});
// Health check
app.get("/api/health", ctx -> {
ctx.json(Map.of("success", true, "status", "ok"));
});
}
private static void openBrowser(String url) {
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(new URI(url));
System.out.println(ZAnsi.cyan("Браузер открыт: " + url));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически. Откройте вручную: " + url));
}
}
public static void stop() {
running = false;
if (app != null) {
app.stop();
}
}
// ==================== LAUNCHER AUTO-UPDATE ====================
public static void checkLauncherUpdate() {
try {
String json = ZHttpClient.getLauncherVersionInfo();
String serverVersion = extractVersion(json);
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
performLauncherUpdate(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 performLauncherUpdate(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<Path> 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)"));
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(me.sashegdev.zernmc.launcher.Main.class.getProtectionDomain()
.getCodeSource()
.getLocation()
.toURI());
} catch (Exception e) {
return Path.of("zernmc-launcher.jar");
}
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JAVA_HOME="$SCRIPT_DIR/jre21"
JAVA="$JAVA_HOME/bin/java"
JAVAFX_PATH="$SCRIPT_DIR/lib-javafx"
exec "$JAVA" \
--module-path="$JAVAFX_PATH" \
--add-modules=javafx.controls,javafx.web \
--add-reads=javafx.graphics=ALL-UNNAMED \
-jar "$SCRIPT_DIR/ZernMCLauncher.jar" "$@"
@@ -0,0 +1,825 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--bg-card-hover: #222230;
--bg-sidebar: #0d0d12;
--accent-primary: #e94560;
--accent-secondary: #ff6b6b;
--accent-glow: rgba(233, 69, 96, 0.3);
--text-primary: #ffffff;
--text-secondary: #a0a0b0;
--text-muted: #606070;
--border-color: #2a2a3a;
--success: #4ade80;
--error: #f87171;
--warning: #fbbf24;
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 30px var(--accent-glow);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--transition-fast: 150ms ease;
--transition-normal: 300ms ease;
--transition-slow: 500ms ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
#grid-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
opacity: 0.12;
pointer-events: none;
}
#app {
position: relative;
z-index: 1;
min-height: 100vh;
}
.screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: fadeIn var(--transition-slow) forwards;
}
.hidden {
display: none !important;
}
/* ==================== LOGIN SCREEN ==================== */
.login-container {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 48px;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-card);
border: 1px solid var(--border-color);
animation: slideUp var(--transition-slow) forwards;
}
.logo-section {
text-align: center;
margin-bottom: 40px;
}
.logo-placeholder {
display: inline-block;
margin-bottom: 16px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.app-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
background: linear-gradient(135deg, var(--text-primary), var(--accent-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.app-version {
color: var(--text-muted);
font-size: 14px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.input-group input {
width: 100%;
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 16px;
transition: var(--transition-fast);
}
.input-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.input-group input::placeholder {
color: var(--text-muted);
}
.btn-primary {
width: 100%;
padding: 14px 24px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
border-radius: var(--radius-sm);
color: white;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: var(--transition-fast);
position: relative;
overflow: hidden;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.btn-loader {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-message {
color: var(--error);
text-align: center;
font-size: 14px;
padding: 12px;
background: rgba(248, 113, 113, 0.1);
border-radius: var(--radius-sm);
animation: shake 0.5s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* ==================== MAIN LAYOUT ==================== */
.main-layout {
display: grid;
grid-template-columns: 280px 1fr 200px;
width: 100%;
max-width: 1600px;
height: calc(100vh - 40px);
gap: 0;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
animation: fadeIn var(--transition-slow) forwards;
}
/* Sidebar */
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
}
.logo-small svg {
display: block;
}
.header-info {
display: flex;
flex-direction: column;
}
.header-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.header-version {
font-size: 12px;
color: var(--text-muted);
}
.sidebar-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.section-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 12px;
}
.current-instance-section {
flex: 1;
}
.current-instance {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
transition: var(--transition-fast);
}
.current-instance:hover {
border-color: var(--accent-primary);
}
.instance-card-mini {
display: flex;
flex-direction: column;
gap: 8px;
}
.instance-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.instance-version {
font-size: 13px;
color: var(--accent-primary);
background: rgba(233, 69, 96, 0.15);
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
width: fit-content;
}
.btn-download {
width: 100%;
padding: 16px;
background: var(--bg-card);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: var(--transition-fast);
}
.btn-download:hover {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.sidebar-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid var(--border-color);
margin-top: 20px;
}
.username-display {
font-size: 13px;
color: var(--text-secondary);
}
.btn-logout {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition-fast);
}
.btn-logout:hover {
background: rgba(248, 113, 113, 0.1);
border-color: var(--error);
color: var(--error);
}
/* Main Content - Logs */
.main-content {
display: flex;
flex-direction: column;
padding: 20px;
background: var(--bg-primary);
}
.logs-section {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
overflow: hidden;
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.logs-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.btn-clear-logs {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: var(--transition-fast);
}
.btn-clear-logs:hover {
background: var(--bg-card-hover);
color: var(--text-secondary);
}
.logs-container {
flex: 1;
padding: 16px 20px;
overflow-y: auto;
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-entry {
padding: 4px 0;
color: var(--text-secondary);
animation: fadeIn var(--transition-fast) forwards;
}
.log-entry.info {
color: var(--text-secondary);
}
.log-entry.success {
color: var(--success);
}
.log-entry.warning {
color: var(--warning);
}
.log-entry.error {
color: var(--error);
}
/* Right Panel - Play Button */
.right-panel {
display: flex;
align-items: flex-end;
justify-content: center;
padding: 30px;
border-left: 1px solid var(--border-color);
background: var(--bg-sidebar);
}
.btn-play {
width: 100%;
padding: 20px 30px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
border-radius: var(--radius-md);
color: white;
font-size: 18px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
transition: var(--transition-normal);
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-play:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 8px 40px var(--accent-glow);
}
.btn-play:active {
transform: translateY(0);
}
.btn-play:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-update {
width: 100%;
padding: 20px 30px;
background: linear-gradient(135deg, var(--warning), #f59e0b);
border: none;
border-radius: var(--radius-md);
color: #1a1a24;
font-size: 18px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
transition: var(--transition-normal);
box-shadow: 0 4px 20px rgba(251, 191, 36, 0.4);
}
.btn-update:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 8px 40px rgba(251, 191, 36, 0.5);
}
.btn-update:active {
transform: translateY(0);
}
.btn-update:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* ==================== MODAL ==================== */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(10, 10, 15, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn var(--transition-fast) forwards;
}
.modal-content {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
animation: slideUp var(--transition-normal) forwards;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
}
.modal-close {
width: 32px;
height: 32px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 24px;
cursor: pointer;
transition: var(--transition-fast);
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-tabs {
display: flex;
padding: 16px 24px;
gap: 8px;
border-bottom: 1px solid var(--border-color);
}
.tab-btn {
flex: 1;
padding: 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: var(--transition-fast);
}
.tab-btn.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.tab-btn:hover:not(.active) {
background: var(--bg-card-hover);
}
.tab-content {
padding: 24px;
display: none;
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
}
.select-input, .text-input {
width: 100%;
padding: 12px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 14px;
transition: var(--transition-fast);
}
.select-input:focus, .text-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.select-input option {
background: var(--bg-secondary);
}
.btn-install {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
border-radius: var(--radius-sm);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: var(--transition-fast);
}
.btn-install:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
.download-progress {
padding: 24px;
border-top: 1px solid var(--border-color);
}
.progress-bar {
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 4px;
width: 0%;
transition: width var(--transition-normal);
}
.progress-text {
text-align: center;
color: var(--text-secondary);
font-size: 13px;
}
.progress-label {
margin-bottom: 8px;
font-weight: 500;
}
.progress-file {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-fill.animated {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary), var(--accent-primary));
background-size: 200% 100%;
animation: progressShimmer 1.5s ease-in-out infinite;
}
@keyframes progressShimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ==================== LOADING ==================== */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(10, 10, 15, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn var(--transition-fast) forwards;
}
.loader {
width: 48px;
height: 48px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
/* ==================== ANIMATIONS ==================== */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes cardFadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
/* ==================== RESPONSIVE ==================== */
@media (max-width: 1024px) {
.main-layout {
grid-template-columns: 240px 1fr 160px;
}
}
@media (max-width: 768px) {
.main-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.sidebar {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header {
padding-bottom: 0;
border-bottom: none;
margin-bottom: 0;
}
.sidebar-content {
display: none;
}
.sidebar-footer {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.right-panel {
padding: 16px;
border-left: none;
border-top: 1px solid var(--border-color);
}
}
/* ==================== SCROLLBAR ==================== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<canvas id="grid-canvas"></canvas>
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="screen hidden">
<div class="login-container">
<div class="logo-section">
<div class="logo-placeholder">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="20" fill="#e94560"/>
<path d="M25 40 L40 25 L55 40 L40 55 Z" fill="white"/>
</svg>
</div>
<h1 class="app-title">ZernMC Launcher</h1>
<p class="app-version">v<span id="version">1.0.8</span></p>
</div>
<form id="login-form" class="login-form">
<div class="input-group">
<input type="text" id="username" name="username" placeholder="Имя пользователя" required autocomplete="username">
</div>
<div class="input-group">
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password">
</div>
<button type="submit" class="btn-primary">
<span class="btn-text">Войти</span>
<div class="btn-loader hidden"></div>
</button>
<p id="login-error" class="error-message hidden"></p>
</form>
</div>
</div>
<!-- Main Screen -->
<div id="main-screen" class="screen hidden">
<div class="main-layout">
<!-- Left Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo-small">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="10" fill="#e94560"/>
<path d="M12 20 L20 12 L28 20 L20 28 Z" fill="white"/>
</svg>
</div>
<div class="header-info">
<h1 class="header-title">ZernMC</h1>
<span class="header-version">v<span id="header-version">1.0.8</span></span>
</div>
</div>
<div class="sidebar-content">
<!-- Current Instance -->
<div class="current-instance-section">
<h3 class="section-label">Текущая сборка</h3>
<div id="current-instance" class="current-instance">
<div class="instance-card-mini">
<span class="instance-name">Загрузка...</span>
<span class="instance-version">-</span>
</div>
</div>
</div>
<!-- Download Button -->
<button id="download-btn" class="btn-download">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Скачать сборку
</button>
</div>
<div class="sidebar-footer">
<span class="username-display" id="username-display"></span>
<button class="btn-logout" id="logout-btn" title="Выйти">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="logs-section">
<div class="logs-header">
<h2>Логи</h2>
<button class="btn-clear-logs" id="clear-logs">Очистить</button>
</div>
<div id="logs-container" class="logs-container">
<div class="log-entry info">Ожидание запуска...</div>
</div>
</div>
</main>
<!-- Right Panel - Play Button -->
<div class="right-panel">
<button id="play-btn" class="btn-play">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
ИГРАТЬ
</button>
</div>
</div>
</div>
<!-- Download Modal -->
<div id="download-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Скачать сборку</h2>
<button class="modal-close" id="close-download-modal">&times;</button>
</div>
<div class="modal-tabs">
<button class="tab-btn active" data-tab="zernmc">ZernMC сборки</button>
<button class="tab-btn" data-tab="vanilla">Чистый Minecraft</button>
</div>
<!-- ZernMC Tab -->
<div id="tab-zernmc" class="tab-content active">
<div class="form-group">
<label>Выберите сборку</label>
<select id="zernmc-pack-select" class="select-input">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label>Название сборки (системное)</label>
<input type="text" id="zernmc-instance-name" class="text-input" placeholder="my-zernmc-pack">
</div>
<button id="install-zernmc-btn" class="btn-install">
Скачать и установить
</button>
</div>
<!-- Vanilla Tab -->
<div id="tab-vanilla" class="tab-content">
<div class="form-group">
<label>Версия Minecraft</label>
<select id="mc-version-select" class="select-input">
<option value="">Выберите версию</option>
</select>
</div>
<div class="form-group">
<label>Лоадер</label>
<select id="loader-select" class="select-input">
<option value="vanilla">Vanilla (без лоадера)</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</div>
<div id="loader-version-group" class="form-group hidden">
<label>Версия лоадера</label>
<select id="loader-version-select" class="select-input">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label>Название сборки</label>
<input type="text" id="vanilla-instance-name" class="text-input" placeholder="my-minecraft">
</div>
<button id="install-vanilla-btn" class="btn-install">
Скачать и установить
</button>
</div>
<div id="download-progress" class="download-progress hidden">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<p class="progress-text" id="progress-text">Загрузка...</p>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="loader"></div>
<p>Загрузка...</p>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>
@@ -0,0 +1,638 @@
const API_BASE = '/api';
class App {
constructor() {
this.state = 'INIT';
this.username = null;
this.currentInstance = null;
this.instances = [];
this.zernmcPacks = [];
this.mcVersions = [];
this.hasUpdate = false;
this.hasMismatches = false;
this.isServerPack = false;
this.init();
}
async init() {
this.bindEvents();
this.initGridAnimation();
await this.checkAuth();
}
bindEvents() {
// Login form
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
this.handleLogin();
});
// Logout button
document.getElementById('logout-btn').addEventListener('click', () => {
this.handleLogout();
});
// Download button
document.getElementById('download-btn').addEventListener('click', () => {
this.showDownloadModal();
});
// Close modal
document.getElementById('close-download-modal').addEventListener('click', () => {
this.hideDownloadModal();
});
// Modal tabs
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// Play button
document.getElementById('play-btn').addEventListener('click', () => {
this.launchInstance();
});
// Clear logs
document.getElementById('clear-logs').addEventListener('click', () => {
this.clearLogs();
});
// Loader selection
document.getElementById('loader-select').addEventListener('change', (e) => {
this.onLoaderChange(e.target.value);
});
// Install buttons
document.getElementById('install-zernmc-btn').addEventListener('click', () => {
this.installZernMCPack();
});
document.getElementById('install-vanilla-btn').addEventListener('click', () => {
this.installVanilla();
});
}
// ==================== GRID ANIMATION ====================
initGridAnimation() {
const canvas = document.getElementById('grid-canvas');
const ctx = canvas.getContext('2d');
let mouseX = 0, mouseY = 0;
let offsetX = 0, offsetY = 0;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
};
window.addEventListener('resize', resize);
window.addEventListener('mousemove', (e) => {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
});
const animate = () => {
offsetX += (mouseX * 0.5 - offsetX) * 0.05;
offsetY += (mouseY * 0.5 - offsetY) * 0.05;
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
requestAnimationFrame(animate);
};
resize();
animate();
}
drawGrid(ctx, width, height, offsetX, offsetY) {
const gridSize = 50;
const dotSize = 1;
ctx.fillStyle = '#e94560';
for (let x = 0; x <= width; x += gridSize) {
for (let y = 0; y <= height; y += gridSize) {
const px = x + offsetX * 10;
const py = y + offsetY * 10;
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ==================== API ====================
async request(endpoint, options = {}) {
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
return await response.json();
} catch (error) {
console.error('API Error:', error);
return { success: false, error: error.message };
}
}
// ==================== AUTH ====================
async checkAuth() {
this.showLoading(true);
const result = await this.request('/auth/status');
if (result.loggedIn) {
this.username = result.username;
this.showMainScreen();
await this.loadCurrentInstance();
} else {
this.showLoginScreen();
}
this.showLoading(false);
}
async handleLogin() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('login-error');
const btn = document.querySelector('#login-form button[type="submit"]');
const btnText = btn.querySelector('.btn-text');
const btnLoader = btn.querySelector('.btn-loader');
if (!username || !password) {
this.showError('Введите имя пользователя и пароль');
return;
}
btn.disabled = true;
btnText.classList.add('hidden');
btnLoader.classList.remove('hidden');
errorEl.classList.add('hidden');
const result = await this.request('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
btn.disabled = false;
btnText.classList.remove('hidden');
btnLoader.classList.add('hidden');
if (result.success) {
this.username = result.username;
this.showMainScreen();
await this.loadCurrentInstance();
} else {
this.showError(result.error || 'Ошибка входа');
}
}
async handleLogout() {
await this.request('/auth/logout', { method: 'POST' });
this.username = null;
this.currentInstance = null;
this.showLoginScreen();
}
showError(message) {
const errorEl = document.getElementById('login-error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
// ==================== INSTANCES ====================
async loadCurrentInstance() {
const result = await this.request('/instances');
if (result.success && result.data && result.data.length > 0) {
this.currentInstance = result.data[0];
this.renderCurrentInstance(this.currentInstance);
this.isServerPack = this.currentInstance.isServerPack || false;
if (this.isServerPack) {
this.addLog('Проверка целостности файлов...', 'info');
const verifyResult = await this.request(`/instances/${this.currentInstance.name}/verify`);
if (verifyResult.success && verifyResult.data) {
this.hasMismatches = verifyResult.data.hasMismatches;
if (this.hasMismatches) {
this.addLog('Обнаружены изменённые файлы!', 'warning');
} else {
this.addLog('Файлы целы', 'success');
}
}
const updateResult = await this.request(`/instances/${this.currentInstance.name}/updates`);
if (updateResult.success && updateResult.data) {
this.hasUpdate = updateResult.data.hasUpdate;
if (this.hasUpdate) {
this.addLog('Доступно обновление: v' + updateResult.data.currentVersion + ' → v' + updateResult.data.latestVersion, 'warning');
}
}
}
this.updatePlayButton();
this.addLog('Сборка загружена: ' + this.currentInstance.name, 'success');
} else {
this.renderNoInstance();
this.enablePlayButton(false);
this.addLog('Установите сборку для игры', 'warning');
}
}
updatePlayButton() {
const btn = document.getElementById('play-btn');
if (!this.currentInstance) {
btn.disabled = true;
btn.className = 'btn-play';
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ИГРАТЬ';
return;
}
if (this.hasUpdate || this.hasMismatches) {
btn.disabled = false;
btn.className = 'btn-update';
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>ОБНОВИТЬ';
} else {
btn.disabled = false;
btn.className = 'btn-play';
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ИГРАТЬ';
}
}
renderCurrentInstance(instance) {
const container = document.getElementById('current-instance');
container.innerHTML = `
<div class="instance-card-mini">
<span class="instance-name">${this.escapeHtml(instance.name)}</span>
<span class="instance-version">${this.escapeHtml(instance.version || 'Vanilla')}</span>
</div>
`;
}
renderNoInstance() {
const container = document.getElementById('current-instance');
container.innerHTML = `
<div class="instance-card-mini">
<span class="instance-name" style="color: var(--text-muted)">Нет сборки</span>
<span class="instance-version" style="background: var(--bg-secondary)">Нажмите скачать</span>
</div>
`;
}
enablePlayButton(enabled) {
const btn = document.getElementById('play-btn');
btn.disabled = !enabled;
}
async launchInstance() {
if (!this.currentInstance) return;
if (this.hasUpdate || this.hasMismatches) {
await this.updateInstance();
return;
}
this.addLog('Проверка целостности файлов...', 'info');
this.enablePlayButton(false);
const result = await this.request(`/instances/${this.currentInstance.name}/launch`, {
method: 'POST'
});
if (result.success) {
this.addLog('Сборка запущена!', 'success');
} else {
this.addLog('Ошибка: ' + result.error, 'error');
this.enablePlayButton(true);
}
}
async updateInstance() {
if (!this.currentInstance || !this.isServerPack) return;
const packName = this.currentInstance.serverPackName;
if (!packName) {
this.addLog('Ошибка: неизвестная сборка', 'error');
return;
}
this.addLog('Обновление сборки...', 'info');
this.enablePlayButton(false);
const progressContainer = this.showAnimatedProgress('Обновление сборки...');
let eventSource = null;
let progressData = { totalFiles: 0, downloadedFiles: 0 };
try {
eventSource = new EventSource(`/api/instances/${this.currentInstance.name}/install/stream`);
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.phase === 'starting') {
progressData.totalFiles = data.totalFiles || 0;
this.updateAnimatedProgress(progressContainer, `Загрузка: 0/${progressData.totalFiles} файлов`, 5);
} else if (data.phase === 'downloading') {
progressData.downloadedFiles = data.downloadedFiles || 0;
const total = data.totalFiles || progressData.totalFiles || 1;
const percent = Math.round((progressData.downloadedFiles / total) * 100);
const fileName = data.currentFile ? data.currentFile.split('/').pop() : '';
const filePercent = data.filePercent || 0;
this.updateAnimatedProgress(progressContainer,
`Файл ${progressData.downloadedFiles}/${total} (${percent}%)`,
percent,
fileName,
filePercent
);
} else if (data.phase === 'complete') {
this.updateAnimatedProgress(progressContainer, 'Готово!', 100);
} else if (data.phase === 'error') {
this.addLog('Ошибка: ' + (data.message || 'неизвестная ошибка'), 'error');
}
} catch (err) {}
};
} catch (e) {
console.log('SSE not available, using fallback progress');
}
const result = await this.request('/instances/zernmc/install', {
method: 'POST',
body: JSON.stringify({
packName: packName,
instanceName: this.currentInstance.name
})
});
if (eventSource) {
eventSource.close();
}
this.hideProgress();
if (result.success) {
this.addLog('Сборка обновлена!', 'success');
this.addLog('Проверка после обновления...', 'info');
const verifyResult = await this.request(`/instances/${this.currentInstance.name}/verify`);
if (verifyResult.success && verifyResult.data) {
this.hasMismatches = verifyResult.data.hasMismatches;
}
const updateResult = await this.request(`/instances/${this.currentInstance.name}/updates`);
if (updateResult.success && updateResult.data) {
this.hasUpdate = updateResult.data.hasUpdate;
}
this.updatePlayButton();
if (!this.hasUpdate && !this.hasMismatches) {
this.addLog('Готово к игре!', 'success');
}
} else {
this.addLog('Ошибка обновления: ' + result.error, 'error');
this.updatePlayButton();
}
}
showAnimatedProgress(text) {
const progress = document.getElementById('download-progress');
const progressText = document.getElementById('progress-text');
const progressFill = document.getElementById('progress-fill');
progress.classList.remove('hidden');
progressText.innerHTML = `<div class="progress-label">${text}</div>
<div class="progress-file"></div>`;
progressFill.style.width = '5%';
progressFill.classList.add('animated');
return { container: progress, text: progressText, fill: progressFill };
}
updateAnimatedProgress(progressContainer, text, percent, fileName = '', filePercent = 0) {
const { text: progressText, fill: progressFill } = progressContainer;
if (fileName) {
progressText.innerHTML = `<div class="progress-label">${text}</div>
<div class="progress-file">${fileName} (${filePercent}%)</div>`;
} else {
progressText.innerHTML = `<div class="progress-label">${text}</div>`;
}
progressFill.style.width = percent + '%';
}
// ==================== DOWNLOAD MODAL ====================
async showDownloadModal() {
document.getElementById('download-modal').classList.remove('hidden');
await this.loadZernMCPacks();
await this.loadMCVersions();
}
hideDownloadModal() {
document.getElementById('download-modal').classList.add('hidden');
this.hideProgress();
}
switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === 'tab-' + tab);
});
}
async loadZernMCPacks() {
const select = document.getElementById('zernmc-pack-select');
select.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request('/instances/zernmc');
if (result.success && result.data && result.data.length > 0) {
this.zernmcPacks = result.data;
select.innerHTML = result.data.map(pack =>
`<option value="${this.escapeHtml(pack.name)}">${this.escapeHtml(pack.name)} (v${pack.version})</option>`
).join('');
} else {
select.innerHTML = '<option value="">Нет доступных сборок</option>';
}
}
async loadMCVersions() {
const select = document.getElementById('mc-version-select');
select.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request('/versions');
if (result.success && result.data) {
this.mcVersions = result.data;
select.innerHTML = '<option value="">Выберите версию</option>' +
result.data.map(v => `<option value="${v.id}">${v.id}</option>`).join('');
} else {
select.innerHTML = '<option value="">Не удалось загрузить</option>';
}
}
async onLoaderChange(loader) {
const loaderVersionGroup = document.getElementById('loader-version-group');
const loaderVersionSelect = document.getElementById('loader-version-select');
if (loader === 'vanilla') {
loaderVersionGroup.classList.add('hidden');
} else {
loaderVersionGroup.classList.remove('hidden');
loaderVersionSelect.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request(`/versions/${document.getElementById('mc-version-select').value}/loaders/${loader}`);
if (result.success && result.data) {
loaderVersionSelect.innerHTML = result.data.map(v =>
`<option value="${v.version}">${v.version}</option>`
).join('');
} else {
loaderVersionSelect.innerHTML = '<option value="">Нет версий</option>';
}
}
}
async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value;
const instanceName = document.getElementById('zernmc-instance-name').value;
if (!packName) {
alert('Выберите сборку');
return;
}
if (!instanceName) {
alert('Введите название сборки');
return;
}
this.showProgress('Установка ZernMC сборки...');
this.addLog('Начало установки: ' + packName, 'info');
const result = await this.request('/instances/zernmc/install', {
method: 'POST',
body: JSON.stringify({ packName, instanceName })
});
if (result.success) {
this.hideDownloadModal();
await this.loadCurrentInstance();
this.addLog('Сборка установлена!', 'success');
} else {
this.addLog('Ошибка установки: ' + result.error, 'error');
this.hideProgress();
}
}
async installVanilla() {
const mcVersion = document.getElementById('mc-version-select').value;
const loader = document.getElementById('loader-select').value;
const loaderVersion = document.getElementById('loader-version-select').value;
const instanceName = document.getElementById('vanilla-instance-name').value;
if (!mcVersion) {
alert('Выберите версию Minecraft');
return;
}
if (!instanceName) {
alert('Введите название сборки');
return;
}
if (loader !== 'vanilla' && !loaderVersion) {
alert('Выберите версию лоадера');
return;
}
this.showProgress('Установка сборки...');
this.addLog(`Начало установки: Minecraft ${mcVersion} ${loader !== 'vanilla' ? loader + ' ' + loaderVersion : ''}`, 'info');
const result = await this.request('/instances/vanilla/install', {
method: 'POST',
body: JSON.stringify({
mcVersion,
loader: loader === 'vanilla' ? null : loader,
loaderVersion: loader === 'vanilla' ? null : loaderVersion,
instanceName
})
});
if (result.success) {
this.hideDownloadModal();
await this.loadCurrentInstance();
this.addLog('Сборка установлена!', 'success');
} else {
this.addLog('Ошибка установки: ' + result.error, 'error');
this.hideProgress();
}
}
showProgress(text) {
const progress = document.getElementById('download-progress');
const progressText = document.getElementById('progress-text');
const progressFill = document.getElementById('progress-fill');
progress.classList.remove('hidden');
progressText.textContent = text;
progressFill.style.width = '50%';
}
hideProgress() {
document.getElementById('download-progress').classList.add('hidden');
}
// ==================== LOGS ====================
addLog(message, type = 'info') {
const container = document.getElementById('logs-container');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
container.appendChild(entry);
container.scrollTop = container.scrollHeight;
}
clearLogs() {
const container = document.getElementById('logs-container');
container.innerHTML = '<div class="log-entry info">Логи очищены</div>';
}
// ==================== UI HELPERS ====================
showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
showMainScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('hidden');
document.getElementById('username-display').textContent = this.username || '';
}
showLoading(show) {
const overlay = document.getElementById('loading-overlay');
if (show) {
overlay.classList.remove('hidden');
} else {
overlay.classList.add('hidden');
}
}
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
const app = new App();
@@ -0,0 +1,67 @@
package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class InstanceServiceTest {
@Test
void instanceService_instantiates() {
InstanceService service = new InstanceService();
assertNotNull(service, "InstanceService должен создаваться");
}
@Test
void getAllInstances_returnsResponse() {
InstanceService service = new InstanceService();
ApiResponse<?> response = service.getAllInstances();
assertNotNull(response, "Ответ не должен быть null");
assertTrue(response.isSuccess() || !response.isSuccess(), "Должен быть валидный ответ");
}
@Test
void getAllInstances_returnsList() {
InstanceService service = new InstanceService();
ApiResponse<?> response = service.getAllInstances();
assertNotNull(response.getData(), "Data не должен быть null");
}
@Test
void isInstanceExists_returnsBoolean() {
InstanceService service = new InstanceService();
ApiResponse<Boolean> response = service.isInstanceExists("nonexistent");
assertNotNull(response, "Ответ не должен быть null");
assertTrue(response.isSuccess(), "Проверка должна быть успешной");
assertNotNull(response.getData(), "Data должен быть boolean");
}
@Test
void isInstanceExists_nonexistentReturnsFalse() {
InstanceService service = new InstanceService();
ApiResponse<Boolean> response = service.isInstanceExists("definitely_nonexistent_12345");
assertTrue(response.isSuccess());
assertFalse(response.getData(), "Несуществующая сборка должна вернуть false");
}
@Test
void deleteInstance_invalidName_returnsError() {
InstanceService service = new InstanceService();
ApiResponse<Boolean> response = service.deleteInstance("nonexistent");
assertNotNull(response, "Ответ не должен быть null");
}
@Test
void getInstance_nonexistent_returnsError() {
InstanceService service = new InstanceService();
ApiResponse<?> response = service.getInstance("definitely_nonexistent_12345");
assertNotNull(response, "Ответ не должен быть null");
assertFalse(response.isSuccess(), "Несуществующая сборка должна вернуть ошибку");
}
}
@@ -0,0 +1,33 @@
package me.sashegdev.zernmc.launcher.web;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.GraphicsEnvironment;
class HeadlessDetectionTest {
@Test
void headlessDetection_works() {
boolean isHeadless = GraphicsEnvironment.isHeadless();
assertNotNull(isHeadless, "isHeadless() должен возвращать boolean");
}
@Test
void headlessDetection_consistentResult() {
boolean isHeadless1 = GraphicsEnvironment.isHeadless();
boolean isHeadless2 = GraphicsEnvironment.isHeadless();
assertEquals(isHeadless1, isHeadless2, "Результат должен быть консистентным");
}
@Test
void javaFxCheck_works() {
try {
boolean isHeadless = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()
.getDefaultScreenDevice() != null;
assertFalse(isHeadless, "На Linux без дисплея должно быть headless");
} catch (Exception e) {
assertTrue(true, "Ожидаемая ошибка на headless");
}
}
}
@@ -0,0 +1,37 @@
package me.sashegdev.zernmc.launcher.web;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.net.ServerSocket;
class WebServerTest {
@Test
void findFreePort_returnsValidPort() throws IOException {
int port = WebServer.findFreePort(8080);
assertTrue(port >= 8080, "Порт должен быть >= 8080");
assertTrue(port < 8180, "Порт должен быть < 8180");
}
@Test
void findFreePort_findsDifferentPorts() throws IOException {
int port1 = WebServer.findFreePort(9000);
int port2 = WebServer.findFreePort(9100);
assertNotEquals(port1, port2, "Должены быть разные порты");
}
@Test
void findFreePort_respectsStartPort() throws IOException {
int port = WebServer.findFreePort(9500);
assertTrue(port >= 9500, "Порт должен быть >= указанного startPort");
}
@Test
void portRangeTest() throws IOException {
int port = WebServer.findFreePort(8080);
assertTrue(port >= 8080 && port < 8180, "Порт в допустимом диапазоне 8080-8179");
}
}
-191
View File
@@ -1,191 +0,0 @@
#!/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)

Some files were not shown because too many files have changed in this diff Show More