Compare commits
122 Commits
ui-backup
...
bcd10cce06
| Author | SHA1 | Date | |
|---|---|---|---|
| bcd10cce06 | |||
| 592de204b6 | |||
| 7068558137 | |||
| 9cfd743e36 | |||
| 3755411954 | |||
| aaecf7e395 | |||
| 1aefca32f2 | |||
| 661411553c | |||
| 96bb8629d3 | |||
| 1561320c4e | |||
| 53c00a6414 | |||
| ee7f3812b6 | |||
| 20bb8233b9 | |||
| c86f24bc5b | |||
| a2073044e9 | |||
| ecb49e1eb7 | |||
| a815d685d3 | |||
| 21cd30c1c3 | |||
| 77fd97ad24 | |||
| 2f78acf526 | |||
| f857d91e4d | |||
| b6dccd67ac | |||
| 5e9ed7ec42 | |||
| be41928378 | |||
| 1685b74389 | |||
| e89b37190b | |||
| 8888e9d977 | |||
| 7881ee64b0 | |||
| fd47fa2b0b | |||
| f4edf5962b | |||
| f180663cef | |||
| 48fd1d2d82 | |||
| 5d82bfda4f | |||
| 3c6c12554e | |||
| cebe90d5f6 | |||
| a86189b67e | |||
| 07da727ee6 | |||
| 7b5eac2b02 | |||
| de0dbab4c8 | |||
| b00f38d226 | |||
| b29bc376a9 | |||
| bf503d8b62 | |||
| 52386312f0 | |||
| 0046c546df | |||
| 23b2236d7d | |||
| 576425ede9 | |||
| 109f379abc | |||
| 511c0a332d | |||
| b1812e270a | |||
| 2465484d18 | |||
| 95fa3143c5 | |||
| aa5fd1fa74 | |||
| e32f215092 | |||
| a391fda261 | |||
| 944bbd34b1 | |||
| bdfbf4c11e | |||
| b3a0280064 | |||
| f41af52619 | |||
| 7145b8beb6 | |||
| 9d7506b7f2 | |||
| 414576b673 | |||
| f84544b7a3 | |||
| 748483914f | |||
| 4c83489420 | |||
| 1821baa878 | |||
| 5fbc336beb | |||
| fc55882d59 | |||
| 8d8fe0e99c | |||
| 6063743cb4 | |||
| 1ac663d6c0 | |||
| d93fac3e7e | |||
| c041881776 | |||
| 79e0eadb64 | |||
| 633de49547 | |||
| 067ce0c89e | |||
| d5a06821e7 | |||
| 5f68956895 | |||
| 8acf4e5f1f | |||
| a97852cfd4 | |||
| ba141c195b | |||
| 48694181ad | |||
| c5ee482308 | |||
| fac4cfabf1 | |||
| 737b048b31 | |||
| 11c1f6bd6b | |||
| 505ecb4d3d | |||
| 942ab7f145 | |||
| 8923395f60 | |||
| f88f115456 | |||
| f65c5c4180 | |||
| 9a1cf886d7 | |||
| 7bc4cfba46 | |||
| 2eac52b561 | |||
| e105f73d4e | |||
| a754dea612 | |||
| 18029a5f1c | |||
| 81dd4c2892 | |||
| 8ab89075f2 | |||
| 914f753872 | |||
| 657304d7c7 | |||
| 7ec41919a3 | |||
| 12c3be84ea | |||
| 4189df9163 | |||
| 54e107f0b5 | |||
| 4b055172b5 | |||
| abf667ebdb | |||
| 07ca5c3668 | |||
| 566f9c13b3 | |||
| 3e85ff3a22 | |||
| 3063153a97 | |||
| 805b55b85d | |||
| 58603a3e6a | |||
| 1241fbffcd | |||
| 87a55f1dd1 | |||
| 91533d76cc | |||
| f2365f9bcc | |||
| c8a705249c | |||
| 094e40979e | |||
| a288dca5f9 | |||
| 36d7d0cefa | |||
| c15db6487b | |||
| 200e6b164b |
+11
-1
@@ -2,12 +2,22 @@ logs/
|
||||
__pycache__/
|
||||
./.venv/
|
||||
launcher/target
|
||||
bootstrap/target
|
||||
src/target
|
||||
server/builds
|
||||
server/packs
|
||||
server/data
|
||||
jre
|
||||
.vscode
|
||||
dependency-reduced-pom.xml
|
||||
.flattened-pom.xml
|
||||
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
|
||||
telegram-bot/
|
||||
.env
|
||||
builds/
|
||||
server/news/
|
||||
data/
|
||||
packs/
|
||||
.__pycache__
|
||||
.pytest_cache
|
||||
.venvtodo.txt
|
||||
*.bak
|
||||
|
||||
@@ -8,32 +8,50 @@
|
||||
- Написан полностью с нуля, без использования готовых решений
|
||||
- Удобная загрузка и обновление сборок прямо с сервера ZernMC
|
||||
- Чистый и относительно читаемый код как клиента, так и сервера
|
||||
- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.)
|
||||
- Возможность запуска клиента даже без графического интерфейса (TUI-режим)
|
||||
- Графический интерфейс на **JavaFX** (по умолчанию) + **TUI** (режим `--cli`)
|
||||
- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.), обход блокировок через выбор домена и SNI
|
||||
- Поддержка загрузчиков **Fabric**, **Forge**, **NeoForge** и ванильных версий (установка через встроенный JRE)
|
||||
- Ротационные логи в стиле Minecraft: `latest.log` + архивы `.log.gz`
|
||||
- Инкрементальное обновление JavaFX и автообновление лаунчера
|
||||
- Подпись exe самоподписным сертификатом Zern.cc (jsign/Authenticode)
|
||||
- Полное отсутствие рекламы и телеметрии
|
||||
- Не собирает никаких данных о пользователе
|
||||
- Работает «из коробки»: достаточно распаковать архив и запустить `ZernMCLauncher.exe`
|
||||
- Работает «из коробки»: достаточно распаковать архив и запустить `zernmc.exe`
|
||||
- Есть система аккаунтов и проходок
|
||||
|
||||
## Состав дистрибутива
|
||||
|
||||
**Онлайн-установщик `ZernMC-Online-Setup.exe` (Go, ~5.7М, без Java):**
|
||||
- Спрашивает папку установки (default `C:\ZernMC`) + чекбокс `Ярлык ZernMC Launcher.lnk на рабочем столе`
|
||||
- Качает `JRE 47М` → `lib/jre21`, затем `meta/file` для `lib/javafx`, `bin/`, `assets/`, `zernmc.exe`
|
||||
- Пишет `build.version`, создаёт `uninstall.exe` (`C:\ZernMC\uninstall.exe`), ярлык
|
||||
- Локальные файлы `~/.zernmc` не трогает
|
||||
|
||||
**Оффлайн `ZernMC-win-<версия>.zip` (или `ZernMC-Offline-Setup.exe` — тот же выбор папки + ярлык):**
|
||||
|
||||
- `zernmc.exe` — GUI (JavaFX)
|
||||
- `zernmc-cli.exe` — TUI
|
||||
- `zernmc-cli-jfx.exe` — консоль + JavaFX
|
||||
- `lib/jre21` — JRE 21 (только оффлайн)
|
||||
- `lib/javafx` — JavaFX 23.0.1
|
||||
|
||||
Bootstrap встроен в `.exe`.
|
||||
|
||||
## Чего пока нет в лаунчере
|
||||
|
||||
- Графического интерфейса (GUI) — только TUI
|
||||
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
|
||||
- Поддержки **Forge** (в разработке)
|
||||
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
|
||||
- Раздела новостей об обновлениях Minecraft и лаунчера
|
||||
- Выбора готовых пресетов оптимизации JVM
|
||||
- Готовых пресетов оптимизации JVM (настраиваются Java и выделенная память)
|
||||
- Поддержки Quilt, LabyMod и других лоадеров
|
||||
- Кастомных модов (UI, спавнеры, DPI, карточки)
|
||||
- Сайта для лаунчера и сервера
|
||||
- Трекинга наигранного времени
|
||||
- Сайта для лаунчера и сервера (текущий требует переделки)
|
||||
- Трекинга наигранного времени (обновление раз в час вместо минуты)
|
||||
|
||||
## Что планируется доработать в ближайшее время
|
||||
|
||||
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
|
||||
- **GUI мод** — привести в единый стиль с главным меню
|
||||
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
|
||||
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
|
||||
- **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
|
||||
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпиленной проверкой, уведомления админу в Telegram с технической информацией
|
||||
- **Сайт** — полноценный сайт для лаунчера и сервера
|
||||
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
|
||||
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
|
||||
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
|
||||
@@ -50,35 +68,43 @@
|
||||
|
||||
### Управление в лаунчере (TUI)
|
||||
|
||||
Лаунчер использует **текстовый интерфейс (TUI)**:
|
||||
Консольные версии (`zernmc-cli.exe`, а также режим `--cli`) используют **текстовый интерфейс (TUI)**:
|
||||
|
||||
- `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
|
||||
- `ENTER` — выбор пункта
|
||||
- `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
|
||||
- `ENTER` — выбор пункта
|
||||
- `ESC` или пункт «Назад» — возврат назад
|
||||
|
||||
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
|
||||
|
||||
Основная версия `zernmc.exe` запускает графический интерфейс JavaFX.
|
||||
|
||||
### Расположение сборок
|
||||
|
||||
Все установленные сборки хранятся в папке:
|
||||
- **Windows**: `%USERPROFILE%\.zernmc\instances`
|
||||
- **Linux**: `~/.zernmc/instances`
|
||||
- **Linux**: `~/.zernmc\instances`
|
||||
|
||||
Если сборка не удаляется корректно — удалите папку вручную.
|
||||
|
||||
### Логи
|
||||
|
||||
Логи лаунчера пишутся в файл `latest.log` рядом с исполняемым файлом; старые логи архивируются в `.log.gz` (в стиле Minecraft).
|
||||
|
||||
## Как скачать и запустить
|
||||
|
||||
Пока автообновления и GitHub Releases не настроены, скачать последнюю версию можно по ссылкам:
|
||||
**Скачать лаунчер (выбери один):**
|
||||
- **Онлайн-установщик (рекомендуется)** — `ZernMC-Online-Setup.exe` (~5.7М, Go, без Java): скачай → выбери папку установки (по умолчанию `C:\ZernMC`) → [x] Создать ярлык ZernMC Launcher на рабочем столе → Установить. Первый запуск докачает JRE 47М + файлы лаунчера 50М. Данные сборок `~/.zernmc` остаются в профиле пользователя. Деинсталляция — `C:\ZernMC\uninstall.exe` или удаление папки.
|
||||
- **Оффлайн ZIP** — `ZernMC-win-<версия>.zip` (98М, с JRE): скачай → выбери папку куда распаковать (диалог в `ZernMC-Offline-Setup.exe` или вручную) → [x] Ярлык → готово. Для air-gapped.
|
||||
|
||||
**Скачать лаунчер:**
|
||||
- https://api.zernmc.ru/launcher/download/latest
|
||||
- https://api.zernmc.online/launcher/download/latest
|
||||
**Зеркала (оба варианта):**
|
||||
- https://api.zern.cc/launcher/download/jre — JRE для онлайн
|
||||
- https://api.zernmc.ru/launcher/download/latest / https://api.zernmc.online/launcher/download/latest — ZIP
|
||||
|
||||
**Инструкция:**
|
||||
1. Скачайте zip-архив
|
||||
2. Распакуйте в удобную папку
|
||||
3. Запустите `ZernMCLauncher.exe`
|
||||
4. Нажмите **«Начать игру»**
|
||||
**Инструкция (оба):**
|
||||
1. Скачай online exe **или** offline zip
|
||||
2. Запусти установщик → выбери папку (по умолчанию `C:\ZernMC`, можно `D:\Games\ZernMC`) → ярлык
|
||||
3. Запусти `zernmc.exe` (или `zernmc-cli.exe`) из папки установки
|
||||
4. Войди в аккаунт и нажми **«Начать игру»**
|
||||
|
||||
### Установка первой сборки
|
||||
|
||||
@@ -86,28 +112,31 @@
|
||||
1. Выберите **«Установить новую сборку»**
|
||||
2. Выберите тип сборки:
|
||||
- **ZernMC** — готовые сборки с нашего сервера
|
||||
- **Модовый загрузчик** — Fabric / Forge / Quilt и т.д.
|
||||
- **Модовый загрузчик** — Fabric / Forge / NeoForge
|
||||
- **Ваниль** — чистая версия от Mojang
|
||||
|
||||
**Рекомендация:** Не используйте кириллицу в названии сборки! (science 1.0.0-1.0.5)
|
||||
|
||||
## Возможные проблемы и решения
|
||||
|
||||
### «Error: Invalid or corrupt jarfile» при запуске exe
|
||||
Перекачайте архив целиком из https://api.zernmc.ru/launcher/download/latest и распакуйте заново — старые версии (до 1.0.16.17) были повреждены подписью и не запускаются.
|
||||
|
||||
### Проблемы с загрузкой ассетов
|
||||
Иногда лаунчер неправильно определяет Asset Index.
|
||||
Иногда лаунчер неправильно определяет Asset Index.
|
||||
**Решение:**
|
||||
1. Запустите версию и посмотрите в логах, какой `.json` файл он пытается загрузить.
|
||||
2. Скопируйте правильный файл из `assets/indexes/` и переименуйте его в нужное имя.
|
||||
|
||||
### Forge версии
|
||||
Запуск Forge пока работает нестабильно. Рекомендуется дождаться исправления генерации команды запуска.
|
||||
### Forge / NeoForge
|
||||
Работает через установщики лоадеров. Если у вас нет внешнего доступа к Maven-репозиториям — используйте прокси-режим.
|
||||
|
||||
### Прокси-режим
|
||||
Если нет доступа к Fabric/Forge Maven или серверам Mojang — лаунчер пытается использовать наш сервер как прокси. Если и к нему нет доступа — загрузка может не работать.
|
||||
|
||||
## Как сообщить об ошибке (Issue)
|
||||
|
||||
1. Скопируйте весь текст ошибки из логов (лаунчер не очищает экран, поэтому просто скролльте вверх)
|
||||
1. Скопируйте весь текст ошибки из логов (`latest.log`)
|
||||
2. Укажите, что вы делали перед ошибкой
|
||||
3. Укажите версию лаунчера
|
||||
4. Создайте Issue на GitHub
|
||||
@@ -118,25 +147,21 @@
|
||||
git clone https://github.com/SashegDev/launcher
|
||||
```
|
||||
Откройте `launcher/launcher/pom.xml`
|
||||
отредактируете пути для билда, так как сейчас - он расчитан на разработку только на моём сервере
|
||||
отредактируйте пути для билда, так как сейчас - он рассчитан на разработку только на своём сервере
|
||||
```bash
|
||||
mvn clean package
|
||||
```
|
||||
можете начинать!
|
||||
|
||||
**Сборка exe на Linux требует:** `jsign` (`~/tools/jsign/jsign.jar`), файлы сертификата в `/root/cert/codesign/`, встроенный JRE в `~/launcher/jre/jre21`.
|
||||
|
||||
### Благодарность
|
||||
Glitch135 - За бета-тест лаунчера и фидбеки
|
||||
|
||||
### помощь
|
||||
Я был бы рад если нашлись люди кто мог бы помочь в разработке лаунчера и добавления к нему хотя бы базового UI чтоб было намногл удобнее работать с ним
|
||||
|
||||
|
||||
- Glitch135 — за бета-тест лаунчера и фидбеки
|
||||
|
||||
Буду рад помощи в разработке!
|
||||
Особенно приветствуется помощь с:
|
||||
|
||||
- Переходом на нормальный графический интерфейс (GUI)
|
||||
- Улучшением генерации команды запуска
|
||||
- Добавлением поддержки Forge / NeoForge / Quilt
|
||||
- Добавлением поддержки Quilt / LabyMod
|
||||
- Стабилизацией серверной части
|
||||
- вообще улучшением клиента
|
||||
- Вообще улучшением клиента
|
||||
@@ -0,0 +1,8 @@
|
||||
module zernmc-installer
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac h1:/QqP+ajFMma4hNWQyBDVaQQhz9Z1kDyXScNWMO3owx0=
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
|
||||
@@ -0,0 +1,552 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sqweek/dialog"
|
||||
)
|
||||
|
||||
var (
|
||||
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
|
||||
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
|
||||
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from adjacent zip)")
|
||||
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
|
||||
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultInstallDir = "C:\\ZernMC"
|
||||
serverBase = "https://api.zern.cc"
|
||||
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Offline flag overrides online
|
||||
isOnline := *onlineFlag && !*offlineFlag
|
||||
|
||||
dir := *installDirFlag
|
||||
if dir == "" {
|
||||
dir = askInstallDir()
|
||||
}
|
||||
if dir == "" {
|
||||
fmt.Println("Installation cancelled")
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline)
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fatal("Cannot create directory: %v", err)
|
||||
}
|
||||
|
||||
createShortcut := *shortcutFlag
|
||||
if !isFlagSet("shortcut") {
|
||||
// ask via dialog if not explicitly flagged
|
||||
createShortcut = askShortcut()
|
||||
}
|
||||
|
||||
if isOnline {
|
||||
if err := installOnline(dir, createShortcut); err != nil {
|
||||
fatal("Online install failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
zipPath := *offlineZipFlag
|
||||
if zipPath == "" {
|
||||
// try to find adjacent offline zip
|
||||
zipPath = findAdjacentOfflineZip()
|
||||
if zipPath == "" {
|
||||
// ask user
|
||||
zipPath, _ = dialog.File().Title("Select ZernMC-Offline.zip").Filter("ZIP (*.zip)", "zip").Load()
|
||||
}
|
||||
}
|
||||
if zipPath == "" {
|
||||
fatal("Offline zip not specified")
|
||||
}
|
||||
if err := installOffline(dir, zipPath, createShortcut); err != nil {
|
||||
fatal("Offline install failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nInstallation complete!")
|
||||
fmt.Printf("Launcher: %s\\zernmc.exe\n", dir)
|
||||
fmt.Printf("Data dir: %s\\.zernmc (local files, instances)\n", os.Getenv("USERPROFILE"))
|
||||
if createShortcut {
|
||||
fmt.Println("Shortcut on desktop: ZernMC Launcher.lnk")
|
||||
}
|
||||
fmt.Println("Uninstall: run", filepath.Join(dir, "uninstall.exe"), "or delete folder")
|
||||
}
|
||||
|
||||
func isFlagSet(name string) bool {
|
||||
found := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func askInstallDir() string {
|
||||
// Try GUI dialog first (Windows)
|
||||
if runtime.GOOS == "windows" {
|
||||
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
|
||||
if err == nil && dir != "" {
|
||||
// dialog.Directory returns chosen dir; append ZernMC if not already
|
||||
if !strings.Contains(strings.ToLower(dir), "zernmc") {
|
||||
dir = filepath.Join(dir, "ZernMC")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
// fallback to default if dialog fails/cancelled with empty
|
||||
fmt.Printf("Dialog cancelled or error (%v), using default %s\n", err, defaultInstallDir)
|
||||
}
|
||||
// CLI fallback
|
||||
fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
input = defaultInstallDir
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func askShortcut() bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
// simple yes/no dialog
|
||||
// sqweek/dialog Message does not return bool, so use question
|
||||
// fallback to true by default if dialog unavailable
|
||||
yes := dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
|
||||
return yes
|
||||
}
|
||||
|
||||
func fatal(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
// also show dialog on Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error()
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func installOnline(dir string, createShortcut bool) error {
|
||||
fmt.Println("Online installation — downloading JRE + launcher files...")
|
||||
// Step 1: download JRE zip if missing
|
||||
jreDir := filepath.Join(dir, "lib", "jre21")
|
||||
javaExe := filepath.Join(jreDir, "bin", "java.exe")
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
fmt.Println("Downloading JRE 21 (47 MB)...")
|
||||
jreURL := serverBase + "/launcher/download/jre"
|
||||
tmpZip := filepath.Join(os.TempDir(), jreZipName)
|
||||
if err := downloadFile(jreURL, tmpZip); err != nil {
|
||||
// fallback to direct zip from builds if server endpoint not ready
|
||||
// try alternative mirrors
|
||||
mirrors := []string{
|
||||
"https://api.zernmc.ru/launcher/download/jre",
|
||||
"https://api.zernmc.online/launcher/download/jre",
|
||||
}
|
||||
var lastErr error
|
||||
for _, m := range mirrors {
|
||||
fmt.Printf("Retry mirror %s\n", m)
|
||||
if err := downloadFile(m, tmpZip); err == nil {
|
||||
lastErr = nil
|
||||
break
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("JRE download failed: %w", lastErr)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
|
||||
if err := unzip(tmpZip, dir); err != nil {
|
||||
// try alt: unzip should contain jre21/ prefix or lib/jre21?
|
||||
// our zip contains jre21/ at root, need to move to lib/jre21
|
||||
return fmt.Errorf("unzip JRE: %w", err)
|
||||
}
|
||||
// handle both zip layouts: some zips contain jre21/, some lib/jre21/
|
||||
// if tmp unzip created dir/jre21, move to lib/jre21
|
||||
candidate := filepath.Join(dir, "jre21")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
os.MkdirAll(filepath.Join(dir, "lib"), 0755)
|
||||
os.RemoveAll(jreDir)
|
||||
if err := os.Rename(candidate, jreDir); err != nil {
|
||||
// fallback copy
|
||||
if err := copyDir(candidate, jreDir); err != nil {
|
||||
return err
|
||||
}
|
||||
os.RemoveAll(candidate)
|
||||
}
|
||||
}
|
||||
os.Remove(tmpZip)
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
return fmt.Errorf("JRE unpack failed, %s not found", javaExe)
|
||||
}
|
||||
fmt.Println("JRE ready")
|
||||
} else {
|
||||
fmt.Println("JRE already present, skipping")
|
||||
}
|
||||
|
||||
// Step 2: fetch meta and download launcher files
|
||||
fmt.Println("Fetching launcher meta...")
|
||||
meta, err := fetchMeta()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch meta: %w", err)
|
||||
}
|
||||
fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
|
||||
|
||||
for i, f := range meta.Files {
|
||||
rel := f.Path
|
||||
// skip JRE entries (should not be in meta, but just in case)
|
||||
if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") {
|
||||
continue
|
||||
}
|
||||
dest := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
need := true
|
||||
if fi, err := os.Stat(dest); err == nil && !fi.IsDir() {
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash == strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
need = false
|
||||
}
|
||||
}
|
||||
if !need {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
|
||||
// try mirrors on failure
|
||||
if err := downloadFile(url, dest); err != nil {
|
||||
// try versioned fallback (some servers use builds/ vs versions/)
|
||||
alt := fmt.Sprintf("%s/launcher/download/jar", serverBase) // not generic
|
||||
_ = alt
|
||||
return fmt.Errorf("download %s: %w", rel, err)
|
||||
}
|
||||
// verify hash
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash != strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
fmt.Printf(" WARNING hash mismatch for %s\n", rel)
|
||||
}
|
||||
}
|
||||
|
||||
// write build.version
|
||||
if err := os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err != nil {
|
||||
fmt.Printf("warning write build.version: %v\n", err)
|
||||
}
|
||||
// also ensure bin/zernmclauncher.jar etc are present
|
||||
fmt.Println("Launcher files ready")
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installOffline(dir, zipPath string, createShortcut bool) error {
|
||||
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
|
||||
if _, err := os.Stat(zipPath); err != nil {
|
||||
return fmt.Errorf("offline zip not found: %s", zipPath)
|
||||
}
|
||||
fmt.Println("Unpacking (98 MB, ~200 MB unpacked)...")
|
||||
if err := unzip(zipPath, dir); err != nil {
|
||||
return fmt.Errorf("unzip: %w", err)
|
||||
}
|
||||
// zip contains zernmc.exe, lib/, bin/, assets/ at root — already correct relative to dir
|
||||
// if zip contained top-level folder, handle it
|
||||
// we unzip directly to dir, so should be fine
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
fmt.Println("Offline unpack complete")
|
||||
fmt.Printf("Data will be in %s\\.zernmc\n", os.Getenv("USERPROFILE"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
// support resume? simple GET
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// ensure parent
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
total := resp.ContentLength
|
||||
var written int64
|
||||
buf := make([]byte, 64*1024)
|
||||
start := time.Now()
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
written += int64(n)
|
||||
if total > 0 {
|
||||
pct := float64(written) / float64(total) * 100
|
||||
elapsed := time.Since(start).Seconds()
|
||||
speed := float64(written) / 1024 / 1024 / (elapsed + 0.001)
|
||||
fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
|
||||
} else {
|
||||
fmt.Printf("\r %.1f MB ", float64(written)/1024/1024)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
out.Close()
|
||||
// verify tmp exists
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
// fallback copy
|
||||
if err2 := copyFile(tmp, dest); err2 != nil {
|
||||
return err2
|
||||
}
|
||||
os.Remove(tmp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzip(src, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
for _, f := range r.File {
|
||||
fpath := filepath.Join(dest, f.Name)
|
||||
// ZipSlip protection
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("illegal file path: %s", fpath)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, f.Mode())
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(path, src)
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
} `json:"files"`
|
||||
}
|
||||
|
||||
func fetchMeta() (*Meta, error) {
|
||||
// try /launcher/meta/{version} flow: first get version, then meta
|
||||
resp, err := http.Get(serverBase + "/launcher/version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var verResp struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver := verResp.Version
|
||||
if ver == "" {
|
||||
ver = "1.1.1"
|
||||
}
|
||||
metaResp, err := http.Get(serverBase + "/launcher/meta/" + ver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer metaResp.Body.Close()
|
||||
if metaResp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("meta %d", metaResp.StatusCode)
|
||||
}
|
||||
var meta Meta
|
||||
if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.Version == "" {
|
||||
meta.Version = ver
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func findAdjacentOfflineZip() string {
|
||||
exe, _ := os.Executable()
|
||||
dir := filepath.Dir(exe)
|
||||
// look for ZernMC-Offline*.zip or ZernMC-win*.zip nearby
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
matches, _ = filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
// also check current dir
|
||||
matches, _ = filepath.Glob("ZernMC-Offline*.zip")
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func createDesktopShortcut(installDir string) error {
|
||||
if runtime.GOOS != "windows" {
|
||||
return nil
|
||||
}
|
||||
desktop := filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
|
||||
if _, err := os.Stat(desktop); err != nil {
|
||||
desktop = filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop")
|
||||
}
|
||||
if _, err := os.Stat(desktop); err != nil {
|
||||
return fmt.Errorf("desktop not found")
|
||||
}
|
||||
lnk := filepath.Join(desktop, "ZernMC Launcher.lnk")
|
||||
target := filepath.Join(installDir, "zernmc.exe")
|
||||
ps := fmt.Sprintf(`$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WorkingDirectory = '%s'; $Shortcut.IconLocation = '%s'; $Shortcut.Save()`, lnk, target, installDir, target)
|
||||
tmp := filepath.Join(os.TempDir(), "zernmc_shortcut.ps1")
|
||||
if err := os.WriteFile(tmp, []byte(ps), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func createUninstaller(dir string) error {
|
||||
// create uninstall.bat and copy self as uninstall.exe
|
||||
exe, _ := os.Executable()
|
||||
uninstExe := filepath.Join(dir, "uninstall.exe")
|
||||
if exe != "" && exe != uninstExe {
|
||||
_ = copyFile(exe, uninstExe)
|
||||
}
|
||||
bat := filepath.Join(dir, "uninstall.bat")
|
||||
content := fmt.Sprintf(`@echo off
|
||||
echo Uninstalling ZernMC from %s
|
||||
timeout /t 2 >nul
|
||||
del "%%USERPROFILE%%\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
del "%%USERPROFILE%%\OneDrive\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
echo Data in %%USERPROFILE%%\.zernmc will be kept.
|
||||
echo To remove completely, delete %%USERPROFILE%%\.zernmc manually.
|
||||
`, dir)
|
||||
_ = os.WriteFile(bat, []byte(content), 0644)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCmd(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Maven
|
||||
target/
|
||||
pom.xml.tag
|
||||
pom.xml.releaseBackup
|
||||
pom.xml.versionsBackup
|
||||
dependency-reduced-pom.xml
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build outputs
|
||||
server/builds/
|
||||
server/logs/
|
||||
|
||||
# Colab
|
||||
colab/
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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
@@ -3,9 +3,13 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>me.sashegdev</groupId>
|
||||
<artifactId>ZernMCLauncher</artifactId>
|
||||
<version>1.0.7</version>
|
||||
<version>1.0.8</version>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.3</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
@@ -24,7 +28,7 @@
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<Implementation-Title>ZernMC Launcher</Implementation-Title>
|
||||
<Implementation-Vendor>SashegDev</Implementation-Vendor>
|
||||
<Implementation-Description>Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном)</Implementation-Description>
|
||||
<Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
|
||||
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
@@ -45,10 +49,11 @@
|
||||
<goal>launch4j</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outfile>../server/builds/ZernMCLauncher.exe</outfile>
|
||||
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
|
||||
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
||||
<headerType>console</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<jre>
|
||||
<path>jre21</path>
|
||||
<minVersion>21</minVersion>
|
||||
@@ -56,13 +61,13 @@
|
||||
<versionInfo>
|
||||
<fileVersion>${project.version}.0</fileVersion>
|
||||
<txtFileVersion>${project.version}</txtFileVersion>
|
||||
<fileDescription>ZernMC Launcher — A Little Minecraft Launcher</fileDescription>
|
||||
<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.exe</originalFilename>
|
||||
<originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
|
||||
</versionInfo>
|
||||
</configuration>
|
||||
</execution>
|
||||
@@ -80,9 +85,15 @@
|
||||
<configuration>
|
||||
<target>
|
||||
<echo>${project.version}</echo>
|
||||
<delete />
|
||||
<mkdir />
|
||||
<copy>
|
||||
<fileset />
|
||||
<fileset>
|
||||
<include />
|
||||
<include />
|
||||
</fileset>
|
||||
</copy>
|
||||
<move />
|
||||
<zip />
|
||||
</target>
|
||||
</configuration>
|
||||
@@ -109,10 +120,35 @@
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.1</version>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<artifactId>junit-jupiter-params</artifactId>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<artifactId>junit-jupiter-engine</artifactId>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<properties>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<project.organization.name>ZernMC</project.organization.name>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.inceptionYear>2026</project.inceptionYear>
|
||||
</properties>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>me.sashegdev</groupId>
|
||||
<artifactId>ZernMCLauncher</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>zernmc-diag</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>ZernMC Network Diagnostics</name>
|
||||
<description>Standalone network diagnostics utility (DNS, connectivity, report upload)</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dnsjava</groupId>
|
||||
<artifactId>dnsjava</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputFile>../../server/builds/bin/zernmcdiag.jar</outputFile>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>dnsjava:dnsjava</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/services/java.net.spi.InetAddressResolverProvider</exclude>
|
||||
<exclude>META-INF/services/sun.net.spi.nameservice.NameServiceDescriptor</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>me.sashegdev.zernmc.launcher.diag.DiagMain</mainClass>
|
||||
<manifestEntries>
|
||||
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
|
||||
<Implementation-Title>ZernMC Network Diagnostics</Implementation-Title>
|
||||
<Implementation-Vendor>SashegDev</Implementation-Vendor>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,29 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
public record DiagCheck(String category, String name, String status, String detail, long latencyMs) {
|
||||
|
||||
public boolean ok() {
|
||||
return "OK".equals(status);
|
||||
}
|
||||
|
||||
public String renderLine() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if ("OK".equals(status)) {
|
||||
sb.append(" [OK] ");
|
||||
} else if ("FAIL".equals(status)) {
|
||||
sb.append(" [FAIL]");
|
||||
} else {
|
||||
sb.append(" [SKIP]");
|
||||
}
|
||||
sb.append(' ').append(category).append(" / ").append(name);
|
||||
if (latencyMs >= 0) {
|
||||
sb.append(" (").append(latencyMs).append(" ms)");
|
||||
}
|
||||
if (detail != null && !detail.isEmpty()) {
|
||||
sb.append(" -> ").append(detail);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class DiagConstants {
|
||||
|
||||
public static final String HOST_API = "api.zern.cc";
|
||||
|
||||
public static final List<String> HOSTS = List.of(
|
||||
HOST_API,
|
||||
"api.zernmc.ru",
|
||||
"api.zernmc.online",
|
||||
"api.pl.zern.cc",
|
||||
"api.swe.zern.cc",
|
||||
"api.ru.zern.cc"
|
||||
);
|
||||
|
||||
public static final List<String> KNOWN_SERVER_IPS = List.of(
|
||||
"87.120.187.36", // main (Frankfurt)
|
||||
"212.22.82.243", // ru geo (Moscow)
|
||||
"2.26.5.202", // pl geo (Warsaw)
|
||||
"2.26.55.218" // swe geo (Stockholm)
|
||||
);
|
||||
|
||||
public static final String PRIMARY_API = "https://" + HOST_API;
|
||||
public static final String DIRECT_API = "http://87.120.187.36:1582";
|
||||
|
||||
public static final List<Integer> TLS_PORTS = List.of(443, 80);
|
||||
public static final int DIRECT_PORT = 1582;
|
||||
|
||||
public static final String VERSION_PATH = "/launcher/version";
|
||||
public static final String CLIENT_IP_PATH = "/launcher/ip";
|
||||
public static final String DIAG_UPLOAD_PATH = "/diag/upload";
|
||||
|
||||
public static final String DOH_GOOGLE_URL = "https://dns.google/resolve?name=%s&type=A";
|
||||
public static final String DOH_CLOUDFLARE_URL = "https://cloudflare-dns.com/dns-query?name=%s&type=A";
|
||||
|
||||
public static final Map<String, String> DNS_PROVIDERS = Map.ofEntries(
|
||||
Map.entry("Google 8.8.8.8", "8.8.8.8"),
|
||||
Map.entry("Google 8.8.4.4", "8.8.4.4"),
|
||||
Map.entry("Cloudflare 1.1.1.1", "1.1.1.1"),
|
||||
Map.entry("Cloudflare 1.0.0.1", "1.0.0.1"),
|
||||
Map.entry("Yandex 77.88.8.8", "77.88.8.8"),
|
||||
Map.entry("Yandex 77.88.8.1", "77.88.8.1"),
|
||||
Map.entry("OpenDNS 208.67.222.222", "208.67.222.222"),
|
||||
Map.entry("Quad9 9.9.9.9", "9.9.9.9")
|
||||
);
|
||||
|
||||
public static final int CONNECT_TIMEOUT_MS = 8000;
|
||||
public static final int READ_TIMEOUT_MS = 8000;
|
||||
|
||||
private DiagConstants() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.UIManager;
|
||||
|
||||
public final class DiagMain {
|
||||
|
||||
public static void main(String[] args) {
|
||||
List<String> argList = Arrays.asList(args);
|
||||
boolean cliMode = argList.contains("--cli");
|
||||
Path baseDir = baseDirFromArgs(argList);
|
||||
|
||||
if (cliMode) {
|
||||
runCli(baseDir);
|
||||
return;
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
DiagnosticsUI ui = new DiagnosticsUI(baseDir);
|
||||
ui.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(WindowEvent e) {
|
||||
System.exit(0);
|
||||
}
|
||||
});
|
||||
ui.setVisible(true);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
private static Path baseDirFromArgs(List<String> args) {
|
||||
int idx = args.indexOf("--dir");
|
||||
if (idx >= 0 && idx + 1 < args.size()) {
|
||||
return Path.of(args.get(idx + 1)).toAbsolutePath();
|
||||
}
|
||||
return Path.of("").toAbsolutePath();
|
||||
}
|
||||
|
||||
private static void runCli(Path baseDir) {
|
||||
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir, System.out::println, null);
|
||||
Path log = diag.run();
|
||||
System.out.println("Diagnostics log: " + (log != null ? log.toAbsolutePath() : "n/a"));
|
||||
System.out.println("Client IP: " + diag.getClientIp());
|
||||
if (log != null) {
|
||||
System.out.print("Send report to the Zern server? [y/N]: ");
|
||||
System.out.flush();
|
||||
try {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||
String line = reader.readLine();
|
||||
if (line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"))) {
|
||||
ReportSender.SendResult r = ReportSender.send(log, diag.getClientIp());
|
||||
System.out.println("Send result: " + (r.ok() ? "OK via " + r.endpoint() : "FAILED (" + r.detail() + ")"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("Send skipped: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
int exitCode = 0;
|
||||
for (DiagCheck c : diag.getChecks()) {
|
||||
if ("FAIL".equals(c.status()) && c.category().equals("HTTP")) {
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
System.exit(exitCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Desktop;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FlowLayout;
|
||||
import java.awt.Font;
|
||||
import java.awt.GridLayout;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.SwingWorker;
|
||||
import javax.swing.border.EmptyBorder;
|
||||
|
||||
public final class DiagnosticsUI extends JFrame {
|
||||
|
||||
private static final Color BG = new Color(0x15151a);
|
||||
private static final Color PANEL = new Color(0x1e1e24);
|
||||
private static final Color TEXT = new Color(0xe8e8ea);
|
||||
private static final Color ACCENT = new Color(0x5dade2);
|
||||
|
||||
private final Path baseDir;
|
||||
private final JTextArea output = new JTextArea();
|
||||
private final JButton sendButton = new JButton("Отправить на сервер");
|
||||
private final JLabel statusLabel = new JLabel("Запуск диагностики...");
|
||||
private volatile Path diagLog;
|
||||
private volatile String clientIp = "unknown";
|
||||
|
||||
public DiagnosticsUI(Path baseDir) {
|
||||
this.baseDir = baseDir;
|
||||
setTitle("ZernMC — Диагностика сети");
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
setSize(760, 560);
|
||||
setMinimumSize(new Dimension(600, 420));
|
||||
setLocationRelativeTo(null);
|
||||
|
||||
getContentPane().setBackground(BG);
|
||||
((JPanel) getContentPane()).setBorder(new EmptyBorder(16, 16, 16, 16));
|
||||
|
||||
JLabel header = new JLabel("Проверка связи с серверами Zern");
|
||||
header.setFont(new Font("Segoe UI", Font.BOLD, 17));
|
||||
header.setForeground(ACCENT);
|
||||
header.setBorder(new EmptyBorder(0, 0, 10, 0));
|
||||
|
||||
output.setEditable(false);
|
||||
output.setFont(new Font("Consolas", Font.PLAIN, 12));
|
||||
output.setBackground(PANEL);
|
||||
output.setForeground(TEXT);
|
||||
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||
JScrollPane scroll = new JScrollPane(output);
|
||||
scroll.setBorder(BorderFactory.createLineBorder(PANEL));
|
||||
|
||||
statusLabel.setForeground(TEXT);
|
||||
statusLabel.setBorder(new EmptyBorder(8, 0, 0, 0));
|
||||
|
||||
JButton openButton = new JButton("Открыть папку с логом");
|
||||
JButton closeButton = new JButton("Закрыть");
|
||||
sendButton.setEnabled(false);
|
||||
|
||||
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
|
||||
buttons.setOpaque(false);
|
||||
buttons.add(openButton);
|
||||
buttons.add(sendButton);
|
||||
buttons.add(closeButton);
|
||||
|
||||
JPanel south = new JPanel(new BorderLayout());
|
||||
south.setOpaque(false);
|
||||
south.add(statusLabel, BorderLayout.NORTH);
|
||||
south.add(buttons, BorderLayout.SOUTH);
|
||||
|
||||
setLayout(new BorderLayout());
|
||||
add(header, BorderLayout.NORTH);
|
||||
add(scroll, BorderLayout.CENTER);
|
||||
add(south, BorderLayout.SOUTH);
|
||||
|
||||
openButton.addActionListener(e -> openFolder());
|
||||
closeButton.addActionListener(e -> dispose());
|
||||
sendButton.addActionListener(e -> sendReport());
|
||||
}
|
||||
|
||||
public void start() {
|
||||
SwingWorker<Void, String> worker = new SwingWorker<>() {
|
||||
@Override
|
||||
protected Void doInBackground() {
|
||||
publish("\u2014 Сервер Zern недоступен, выполняем диагностику \u2014");
|
||||
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir,
|
||||
this::publish,
|
||||
c -> publish(renderCheck(c)));
|
||||
diagLog = diag.run();
|
||||
clientIp = diag.getClientIp();
|
||||
publish("Диагностика завершена. Лог: " + (diagLog != null ? diagLog.toAbsolutePath() : "n/a"));
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void process(java.util.List<String> chunks) {
|
||||
for (String line : chunks) {
|
||||
appendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void done() {
|
||||
sendButton.setEnabled(diagLog != null);
|
||||
statusLabel.setText("Готово. Можете отправить лог на сервер или сохранить его.");
|
||||
}
|
||||
};
|
||||
worker.execute();
|
||||
}
|
||||
|
||||
private static String renderCheck(DiagCheck c) {
|
||||
String prefix = switch (c.status()) {
|
||||
case "OK" -> "[OK] ";
|
||||
case "FAIL" -> "[FAIL]";
|
||||
default -> "[SKIP]";
|
||||
};
|
||||
StringBuilder sb = new StringBuilder(prefix).append(' ').append(c.category()).append(" / ").append(c.name());
|
||||
if (c.latencyMs() >= 0) {
|
||||
sb.append(" (").append(c.latencyMs()).append(" ms)");
|
||||
}
|
||||
if (c.detail() != null && !c.detail().isEmpty()) {
|
||||
sb.append(" -> ").append(c.detail());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void appendLine(String line) {
|
||||
output.append(line);
|
||||
if (!line.endsWith("\n")) {
|
||||
output.append("\n");
|
||||
}
|
||||
output.setCaretPosition(output.getDocument().getLength());
|
||||
}
|
||||
|
||||
private void openFolder() {
|
||||
try {
|
||||
Path dir = diagLog != null ? diagLog.getParent() : baseDir;
|
||||
if (dir != null && Files.exists(dir)) {
|
||||
Desktop.getDesktop().open(dir.toFile());
|
||||
} else {
|
||||
JOptionPane.showMessageDialog(this, "Папка ещё не создана.");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JOptionPane.showMessageDialog(this, "Не удалось открыть папку: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void sendReport() {
|
||||
if (diagLog == null) {
|
||||
return;
|
||||
}
|
||||
sendButton.setEnabled(false);
|
||||
statusLabel.setText("Отправка отчёта на сервер...");
|
||||
SwingWorker<ReportSender.SendResult, Void> worker = new SwingWorker<>() {
|
||||
@Override
|
||||
protected ReportSender.SendResult doInBackground() throws Exception {
|
||||
return ReportSender.send(diagLog, clientIp);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void done() {
|
||||
sendButton.setEnabled(true);
|
||||
try {
|
||||
ReportSender.SendResult r = get();
|
||||
if (r.ok()) {
|
||||
statusLabel.setText("Отчёт отправлен: " + r.endpoint());
|
||||
JOptionPane.showMessageDialog(DiagnosticsUI.this,
|
||||
"Отчёт успешно отправлен на сервер Zern.",
|
||||
"Отправлено", JOptionPane.INFORMATION_MESSAGE);
|
||||
} else {
|
||||
statusLabel.setText("Не удалось отправить отчёт.");
|
||||
JOptionPane.showMessageDialog(DiagnosticsUI.this,
|
||||
"Не удалось отправить отчёт на сервер.\n"
|
||||
+ "Сохраните лог и отправьте его вручную:\n" + diagLog.toAbsolutePath()
|
||||
+ "\n\nОшибка: " + r.detail(),
|
||||
"Ошибка отправки", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
statusLabel.setText("Ошибка отправки: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
};
|
||||
worker.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.xbill.DNS.ARecord;
|
||||
import org.xbill.DNS.Lookup;
|
||||
import org.xbill.DNS.Record;
|
||||
import org.xbill.DNS.SimpleResolver;
|
||||
import org.xbill.DNS.Type;
|
||||
|
||||
public final class DnsResolver {
|
||||
|
||||
private DnsResolver() {
|
||||
}
|
||||
|
||||
public static List<String> resolveSystem(String host) {
|
||||
try {
|
||||
InetAddress[] addrs = InetAddress.getAllByName(host);
|
||||
List<String> ips = new ArrayList<>();
|
||||
for (InetAddress a : addrs) {
|
||||
String ip = a.getHostAddress();
|
||||
if (ip != null && !ips.contains(ip)) {
|
||||
ips.add(ip);
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<String> resolveDoHGoogle(String host) {
|
||||
try {
|
||||
String url = String.format(DiagConstants.DOH_GOOGLE_URL, host);
|
||||
String body = doGet(url, "application/json");
|
||||
JSONObject json = new JSONObject(body);
|
||||
JSONArray answers = json.optJSONArray("Answer");
|
||||
if (answers == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> ips = new ArrayList<>();
|
||||
for (int i = 0; i < answers.length(); i++) {
|
||||
JSONObject a = answers.optJSONObject(i);
|
||||
if (a != null && a.optInt("type", 0) == 1) {
|
||||
String data = a.optString("data", null);
|
||||
if (data != null && !ips.contains(data)) {
|
||||
ips.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<String> resolveDoHCloudflare(String host) {
|
||||
try {
|
||||
String url = String.format(DiagConstants.DOH_CLOUDFLARE_URL, host);
|
||||
String body = doGet(url, "application/dns-json");
|
||||
JSONObject json = new JSONObject(body);
|
||||
JSONArray answers = json.optJSONArray("Answer");
|
||||
if (answers == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> ips = new ArrayList<>();
|
||||
for (int i = 0; i < answers.length(); i++) {
|
||||
JSONObject a = answers.optJSONObject(i);
|
||||
if (a != null && a.optInt("type", 0) == 1) {
|
||||
String data = a.optString("data", null);
|
||||
if (data != null && !ips.contains(data)) {
|
||||
ips.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<String> resolveUdp(String providerIp, String host) {
|
||||
try {
|
||||
SimpleResolver resolver = new SimpleResolver(providerIp);
|
||||
resolver.setTimeout(Duration.ofSeconds(5));
|
||||
Lookup lookup = new Lookup(org.xbill.DNS.Name.fromString(host + "."), Type.A);
|
||||
lookup.setResolver(resolver);
|
||||
lookup.setCache(null);
|
||||
Record[] records = lookup.run();
|
||||
if (records == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> ips = new ArrayList<>();
|
||||
for (Record r : records) {
|
||||
if (r instanceof ARecord a) {
|
||||
String ip = a.getAddress().getHostAddress();
|
||||
if (ip != null && !ips.contains(ip)) {
|
||||
ips.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String doGet(String url, String accept) throws Exception {
|
||||
URLConnection conn = new URL(url).openConnection();
|
||||
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
|
||||
conn.setRequestProperty("Accept", accept);
|
||||
conn.setRequestProperty("User-Agent", "ZernMC-Diag");
|
||||
conn.connect();
|
||||
byte[] buf;
|
||||
try (var in = conn.getInputStream()) {
|
||||
buf = in.readAllBytes();
|
||||
}
|
||||
return new String(buf, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class NetProbe {
|
||||
|
||||
public record TcpResult(boolean ok, long latencyMs, String detail) {
|
||||
}
|
||||
|
||||
public record HttpResult(boolean ok, int status, long latencyMs, String bodySnippet, String detail) {
|
||||
}
|
||||
|
||||
private NetProbe() {
|
||||
}
|
||||
|
||||
public static TcpResult tcpConnect(String host, int port, int timeoutMs) {
|
||||
long t0 = System.currentTimeMillis();
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.connect(new InetSocketAddress(host, port), timeoutMs);
|
||||
return new TcpResult(true, System.currentTimeMillis() - t0, host + ":" + port);
|
||||
} catch (Exception e) {
|
||||
return new TcpResult(false, System.currentTimeMillis() - t0, host + ":" + port + " -> " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static HttpResult httpGet(String url, int timeoutMs) {
|
||||
long t0 = System.currentTimeMillis();
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||
conn.setConnectTimeout(timeoutMs);
|
||||
conn.setReadTimeout(timeoutMs);
|
||||
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
int status = conn.getResponseCode();
|
||||
InputStream in = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
|
||||
String snippet = "";
|
||||
if (in != null) {
|
||||
byte[] bytes = in.readAllBytes();
|
||||
String body = new String(bytes, StandardCharsets.UTF_8).trim();
|
||||
snippet = body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||
}
|
||||
boolean ok = status >= 200 && status < 400;
|
||||
return new HttpResult(ok, status, System.currentTimeMillis() - t0, snippet, "HTTP " + status);
|
||||
} catch (Exception e) {
|
||||
return new HttpResult(false, 0, System.currentTimeMillis() - t0, "", e.getMessage());
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public final class NetworkDiagnostics {
|
||||
|
||||
private final Path baseDir;
|
||||
private final Consumer<String> onLine;
|
||||
private final Consumer<DiagCheck> onCheck;
|
||||
private final List<DiagCheck> checks = new CopyOnWriteArrayList<>();
|
||||
private final StringBuilder report = new StringBuilder();
|
||||
private String clientIp = "unknown";
|
||||
|
||||
public NetworkDiagnostics(Path baseDir, Consumer<String> onLine, Consumer<DiagCheck> onCheck) {
|
||||
this.baseDir = baseDir;
|
||||
this.onLine = onLine;
|
||||
this.onCheck = onCheck;
|
||||
}
|
||||
|
||||
public List<DiagCheck> getChecks() {
|
||||
return checks;
|
||||
}
|
||||
|
||||
public String getClientIp() {
|
||||
return clientIp;
|
||||
}
|
||||
|
||||
public Path run() {
|
||||
checks.clear();
|
||||
report.setLength(0);
|
||||
|
||||
report.append("=== ZernMC Network Diagnostics Report ===\n");
|
||||
report.append("Timestamp: ").append(timestamp()).append('\n');
|
||||
report.append("Base dir: ").append(baseDir.toAbsolutePath()).append('\n');
|
||||
report.append("Zern server known IP(s): ").append(String.join(", ", DiagConstants.KNOWN_SERVER_IPS)).append('\n');
|
||||
report.append("Primary API: ").append(DiagConstants.PRIMARY_API).append('\n');
|
||||
report.append("Direct API: ").append(DiagConstants.DIRECT_API).append('\n');
|
||||
|
||||
runDnsChecks();
|
||||
runConnectivityChecks();
|
||||
runHttpChecks();
|
||||
clientIp = determineClientIp();
|
||||
|
||||
int ok = 0, fail = 0, skip = 0;
|
||||
for (DiagCheck c : checks) {
|
||||
switch (c.status()) {
|
||||
case "OK" -> ok++;
|
||||
case "FAIL" -> fail++;
|
||||
default -> skip++;
|
||||
}
|
||||
}
|
||||
report.append("=== Summary ===\n");
|
||||
report.append("OK: ").append(ok).append(", FAIL: ").append(fail).append(", SKIP: ").append(skip).append('\n');
|
||||
|
||||
report.append("--- JSON ---\n");
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("client_ip", clientIp);
|
||||
json.put("timestamp", timestamp());
|
||||
JSONObject jsonChecks = new JSONObject();
|
||||
for (DiagCheck c : checks) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("status", c.status());
|
||||
j.put("detail", c.detail() == null ? "" : c.detail());
|
||||
j.put("latency_ms", c.latencyMs());
|
||||
jsonChecks.put(c.category() + "/" + c.name(), j);
|
||||
}
|
||||
json.put("checks", jsonChecks);
|
||||
report.append(json.toString(2)).append('\n');
|
||||
|
||||
return writeLog();
|
||||
}
|
||||
|
||||
private Path writeLog() {
|
||||
try {
|
||||
Files.createDirectories(baseDir);
|
||||
Path log = baseDir.resolve("diag.log");
|
||||
Files.writeString(log, report.toString(), StandardCharsets.UTF_8);
|
||||
if (onLine != null) {
|
||||
onLine.accept("\nDiagnostic log written to: " + log.toAbsolutePath());
|
||||
}
|
||||
return log;
|
||||
} catch (IOException e) {
|
||||
if (onLine != null) {
|
||||
onLine.accept("Failed to write diag.log: " + e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void runDnsChecks() {
|
||||
report.append("\n=== DNS resolution ===\n");
|
||||
for (String host : DiagConstants.HOSTS) {
|
||||
report.append("Host: ").append(host).append('\n');
|
||||
submit(resolveToCheck(host, "system", DnsResolver.resolveSystem(host)));
|
||||
}
|
||||
|
||||
List<Callable<DiagCheck>> tasks = new ArrayList<>();
|
||||
List<String> taskKeys = new ArrayList<>();
|
||||
|
||||
for (String host : DiagConstants.HOSTS) {
|
||||
tasks.add(() -> resolveToCheck(host, "DoH Google", DnsResolver.resolveDoHGoogle(host)));
|
||||
taskKeys.add("DoH Google/" + host);
|
||||
tasks.add(() -> resolveToCheck(host, "DoH Cloudflare", DnsResolver.resolveDoHCloudflare(host)));
|
||||
taskKeys.add("DoH Cloudflare/" + host);
|
||||
for (String provider : DiagConstants.DNS_PROVIDERS.keySet()) {
|
||||
String providerIp = DiagConstants.DNS_PROVIDERS.get(provider);
|
||||
tasks.add(() -> resolveToCheck(host, "UDP " + provider, DnsResolver.resolveUdp(providerIp, host)));
|
||||
taskKeys.add("UDP " + provider + "/" + host);
|
||||
}
|
||||
}
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(Math.min(8, tasks.size()), r -> {
|
||||
Thread t = new Thread(r, "diag-dns");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
try {
|
||||
List<Future<DiagCheck>> futures = new ArrayList<>();
|
||||
for (Callable<DiagCheck> task : tasks) {
|
||||
futures.add(pool.submit(task));
|
||||
}
|
||||
int idx = 0;
|
||||
for (Future<DiagCheck> f : futures) {
|
||||
String key = taskKeys.get(idx);
|
||||
try {
|
||||
DiagCheck c = f.get(20, TimeUnit.SECONDS);
|
||||
submit(c);
|
||||
} catch (Exception e) {
|
||||
submit(new DiagCheck("DNS", key, "FAIL", "query error: " + e.getMessage(), -1));
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
static DiagCheck resolveToCheck(String host, String source, List<String> ips) {
|
||||
if (ips.isEmpty()) {
|
||||
return new DiagCheck("DNS", host + " / " + source, "FAIL", "no A records", -1);
|
||||
}
|
||||
boolean matchesKnown = false;
|
||||
for (String ip : ips) {
|
||||
if (DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
|
||||
matchesKnown = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchesKnown) {
|
||||
return new DiagCheck("DNS", host + " / " + source, "OK", String.join(", ", ips), -1);
|
||||
}
|
||||
String detail = "resolved to " + String.join(", ", ips) + " (DIFFERENT from known " + String.join(", ", DiagConstants.KNOWN_SERVER_IPS) + ")";
|
||||
return new DiagCheck("DNS", host + " / " + source, "FAIL", detail, -1);
|
||||
}
|
||||
|
||||
private void runConnectivityChecks() {
|
||||
report.append("\n=== Connectivity ===\n");
|
||||
for (String ip : DiagConstants.KNOWN_SERVER_IPS) {
|
||||
for (int port : DiagConstants.TLS_PORTS) {
|
||||
submit(tcpToCheck(ip, port));
|
||||
}
|
||||
}
|
||||
submit(tcpToCheck("87.120.187.36", DiagConstants.DIRECT_PORT));
|
||||
|
||||
List<String> systemIps = DnsResolver.resolveSystem(DiagConstants.HOST_API);
|
||||
for (String ip : systemIps) {
|
||||
if (!DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
|
||||
submit(tcpToCheck(ip, 443));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DiagCheck tcpToCheck(String host, int port) {
|
||||
NetProbe.TcpResult r = NetProbe.tcpConnect(host, port, DiagConstants.CONNECT_TIMEOUT_MS);
|
||||
return new DiagCheck("TCP", host + ":" + port, r.ok() ? "OK" : "FAIL", r.detail(), r.latencyMs());
|
||||
}
|
||||
|
||||
private void runHttpChecks() {
|
||||
report.append("\n=== HTTP ===\n");
|
||||
for (String host : DiagConstants.HOSTS) {
|
||||
submit(httpToCheck("https://" + host + DiagConstants.VERSION_PATH));
|
||||
}
|
||||
submit(httpToCheck(DiagConstants.DIRECT_API + DiagConstants.VERSION_PATH));
|
||||
}
|
||||
|
||||
private DiagCheck httpToCheck(String url) {
|
||||
NetProbe.HttpResult r = NetProbe.httpGet(url, DiagConstants.READ_TIMEOUT_MS);
|
||||
String detail = r.ok() ? r.detail() + (r.bodySnippet().isEmpty() ? "" : " body=" + r.bodySnippet()) : r.detail();
|
||||
return new DiagCheck("HTTP", url, r.ok() ? "OK" : "FAIL", detail, r.latencyMs());
|
||||
}
|
||||
|
||||
private String determineClientIp() {
|
||||
String ip = null;
|
||||
NetProbe.HttpResult direct = NetProbe.httpGet(DiagConstants.DIRECT_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
|
||||
if (direct.ok()) {
|
||||
ip = parseIp(direct.bodySnippet());
|
||||
}
|
||||
if (ip == null) {
|
||||
NetProbe.HttpResult primary = NetProbe.httpGet(DiagConstants.PRIMARY_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
|
||||
if (primary.ok()) {
|
||||
ip = parseIp(primary.bodySnippet());
|
||||
}
|
||||
}
|
||||
String result = ip != null ? ip : "unknown";
|
||||
submit(new DiagCheck("INFO", "client public IP", ip != null ? "OK" : "FAIL", ip != null ? ip : "could not determine", -1));
|
||||
report.append("=== Client IP ===\nClient public IP: ").append(result).append('\n');
|
||||
return result;
|
||||
}
|
||||
|
||||
static String parseIp(String snippet) {
|
||||
if (snippet == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
int start = snippet.indexOf("\"ip\"");
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
int colon = snippet.indexOf(':', start);
|
||||
int q1 = snippet.indexOf('"', colon);
|
||||
int q2 = snippet.indexOf('"', q1 + 1);
|
||||
if (q1 < 0 || q2 < 0) {
|
||||
return null;
|
||||
}
|
||||
String ip = snippet.substring(q1 + 1, q2);
|
||||
return ip.matches("[\\d.]+") ? ip : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void submit(DiagCheck c) {
|
||||
checks.add(c);
|
||||
report.append(c.renderLine()).append('\n');
|
||||
if (onCheck != null) {
|
||||
onCheck.accept(c);
|
||||
}
|
||||
if (onLine != null) {
|
||||
onLine.accept(c.renderLine());
|
||||
}
|
||||
}
|
||||
|
||||
private static String timestamp() {
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
public final class ReportSender {
|
||||
|
||||
public record SendResult(boolean ok, String endpoint, String detail) {
|
||||
}
|
||||
|
||||
private ReportSender() {
|
||||
}
|
||||
|
||||
public static String buildFilename(String clientIp) {
|
||||
String ts = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
|
||||
String ip = (clientIp == null || clientIp.isBlank()) ? "unknown" : clientIp;
|
||||
return "diag_" + ts + "_" + ip + ".log";
|
||||
}
|
||||
|
||||
public static SendResult send(Path logFile, String clientIp) throws IOException {
|
||||
String content = Files.readString(logFile, StandardCharsets.UTF_8);
|
||||
String name = buildFilename(clientIp);
|
||||
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8);
|
||||
|
||||
List<String> endpoints = List.of(
|
||||
DiagConstants.PRIMARY_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded,
|
||||
DiagConstants.DIRECT_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded
|
||||
);
|
||||
|
||||
String lastDetail = null;
|
||||
for (String endpoint : endpoints) {
|
||||
try {
|
||||
HttpURLConnection conn = (HttpURLConnection) URI.create(endpoint).toURL().openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "text/plain; charset=utf-8");
|
||||
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
|
||||
byte[] body = content.getBytes(StandardCharsets.UTF_8);
|
||||
conn.setFixedLengthStreamingMode(body.length);
|
||||
try (OutputStream out = conn.getOutputStream()) {
|
||||
out.write(body);
|
||||
}
|
||||
int status = conn.getResponseCode();
|
||||
boolean ok = status >= 200 && status < 300;
|
||||
String detail = "HTTP " + status;
|
||||
conn.disconnect();
|
||||
return new SendResult(ok, endpoint, detail);
|
||||
} catch (Exception e) {
|
||||
lastDetail = e.getMessage();
|
||||
}
|
||||
}
|
||||
return new SendResult(false, lastEndpointUsed(endpoints), lastDetail != null ? lastDetail : "all endpoints failed");
|
||||
}
|
||||
|
||||
private static String lastEndpointUsed(List<String> endpoints) {
|
||||
return endpoints.get(endpoints.size() - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class DiagCheckTest {
|
||||
|
||||
@Test
|
||||
void renderLine_ok() {
|
||||
DiagCheck c = new DiagCheck("DNS", "api.zernmc.ru / system", "OK", "87.120.187.36", 12);
|
||||
String line = c.renderLine();
|
||||
assertTrue(line.contains("[OK]"));
|
||||
assertTrue(line.contains("api.zernmc.ru / system"));
|
||||
assertTrue(line.contains("12 ms"));
|
||||
assertTrue(line.contains("87.120.187.36"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderLine_fail() {
|
||||
DiagCheck c = new DiagCheck("TCP", "1.2.3.4:443", "FAIL", "connection refused", -1);
|
||||
String line = c.renderLine();
|
||||
assertTrue(line.contains("[FAIL]"));
|
||||
assertFalse(line.contains("ms)"));
|
||||
assertTrue(line.contains("connection refused"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ok_returnsStatus() {
|
||||
assertTrue(new DiagCheck("a", "b", "OK", null, -1).ok());
|
||||
assertFalse(new DiagCheck("a", "b", "FAIL", null, -1).ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class DnsResolverTest {
|
||||
|
||||
@Test
|
||||
void resolveToCheck_emptyIsFail() {
|
||||
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "Google 8.8.8.8", List.of());
|
||||
assertEquals("FAIL", c.status());
|
||||
assertEquals("no A records", c.detail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveToCheck_knownIpIsOk() {
|
||||
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("87.120.187.36"));
|
||||
assertEquals("OK", c.status());
|
||||
assertTrue(c.detail().contains("87.120.187.36"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveToCheck_unknownIpIsFail() {
|
||||
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("1.2.3.4"));
|
||||
assertEquals("FAIL", c.status());
|
||||
assertTrue(c.detail().contains("DIFFERENT from known"));
|
||||
assertTrue(c.detail().contains("1.2.3.4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveToCheck_multipleIpsOneKnown() {
|
||||
DiagCheck c = NetworkDiagnostics.resolveToCheck("zern.cc", "system", List.of("1.2.3.4", "87.120.187.36"));
|
||||
assertEquals("OK", c.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseIp_valid() {
|
||||
assertEquals("1.2.3.4", NetworkDiagnostics.parseIp("{\"ip\":\"1.2.3.4\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseIp_invalidReturnsNull() {
|
||||
assertEquals(null, NetworkDiagnostics.parseIp("not json"));
|
||||
assertEquals(null, NetworkDiagnostics.parseIp("{\"other\":\"x\"}"));
|
||||
assertEquals(null, NetworkDiagnostics.parseIp(null));
|
||||
assertEquals(null, NetworkDiagnostics.parseIp("{\"ip\":\"not-an-ip\"}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package me.sashegdev.zernmc.launcher.diag;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ReportSenderTest {
|
||||
|
||||
@Test
|
||||
void buildFilename_containsTimestampAndIp() {
|
||||
String name = ReportSender.buildFilename("1.2.3.4");
|
||||
assertTrue(name.matches("diag_\\d{8}_\\d{6}_[\\d.]+[.]log"), name);
|
||||
assertTrue(name.contains("1.2.3.4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildFilename_unknownIp() {
|
||||
String name = ReportSender.buildFilename(null);
|
||||
assertTrue(name.contains("unknown"), name);
|
||||
assertTrue(name.matches("diag_\\d{8}_\\d{6}_unknown[.]log"), name);
|
||||
String blank = ReportSender.buildFilename(" ");
|
||||
assertTrue(blank.contains("unknown"), blank);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
<?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"/>
|
||||
|
||||
<!-- Go online/offline инсталляторы (нативные, без JRE) -->
|
||||
<exec executable="bash" failonerror="false">
|
||||
<arg value="-c"/>
|
||||
<arg value="if command -v go >/dev/null 2>&1; then echo 'Building Go installers...'; mkdir -p ../../server/builds; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags='-s -w' -o ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../installer 2>&1 || echo 'Go build skipped (no go)'; cp ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe 2>/dev/null || true; fi"/>
|
||||
</exec>
|
||||
|
||||
<!-- Подпись Go инсталляторов -->
|
||||
<exec executable="java" failonerror="false">
|
||||
<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-Online-Setup-${revision}.${hotfix}.exe"/>
|
||||
</exec>
|
||||
<exec executable="java" failonerror="false">
|
||||
<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-Offline-Setup-${revision}.${hotfix}.exe"/>
|
||||
</exec>
|
||||
</target>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 423 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,337 @@
|
||||
package me.sashegdev.zernmc.launcher;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class Bootstrap {
|
||||
private static final String VERSION_FILE = "build.version";
|
||||
private static final String JAR_NAME = "ZernMCLauncher.jar";
|
||||
private static final String BASE_URL = "https://api.zernmc.ru";
|
||||
|
||||
private static Path baseDir;
|
||||
private static Path logDir;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
baseDir = Paths.get("").toAbsolutePath();
|
||||
logDir = baseDir.resolve("logs");
|
||||
Files.createDirectories(logDir);
|
||||
|
||||
log("=== ZernMC Launcher ===");
|
||||
|
||||
List<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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package me.sashegdev.zernmc.launcher;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.menu.*;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||
import me.sashegdev.zernmc.launcher.utils.*;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class Main {
|
||||
|
||||
private static final String CURRENT_VERSION = Version.getCurrentVersion();
|
||||
private static final LauncherAPI api = new LauncherAPI();
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
System.setProperty("file.encoding", "UTF-8");
|
||||
System.setProperty("sun.stderr.encoding", "UTF-8");
|
||||
System.setProperty("sun.stdout.encoding", "UTF-8");
|
||||
System.setProperty("java.stdout.encoding", "UTF-8");
|
||||
System.setProperty("java.stderr.encoding", "UTF-8");
|
||||
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
||||
|
||||
LauncherLogger.init();
|
||||
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
try {
|
||||
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
ZAnsi.install();
|
||||
LauncherLogger.info("Starting ZernMC Launcher " + CURRENT_VERSION);
|
||||
|
||||
List<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package me.sashegdev.zernmc.launcher.api;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
|
||||
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
||||
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class LauncherAPI {
|
||||
|
||||
private final AuthService authService;
|
||||
private final InstanceService instanceService;
|
||||
private final LaunchService launchService;
|
||||
|
||||
public LauncherAPI() {
|
||||
this.authService = new AuthService();
|
||||
this.instanceService = new InstanceService();
|
||||
this.launchService = new LaunchService();
|
||||
}
|
||||
|
||||
public AuthService auth() {
|
||||
return authService;
|
||||
}
|
||||
|
||||
public InstanceService instances() {
|
||||
return instanceService;
|
||||
}
|
||||
|
||||
public LaunchService launch() {
|
||||
return launchService;
|
||||
}
|
||||
|
||||
public boolean isLoggedIn() {
|
||||
return authService.isLoggedIn();
|
||||
}
|
||||
|
||||
public String getCurrentUsername() {
|
||||
return authService.getCurrentUsername();
|
||||
}
|
||||
|
||||
public ApiResponse<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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-9
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
@@ -8,6 +10,34 @@ 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);
|
||||
@@ -17,7 +47,7 @@ public class AuthService {
|
||||
}
|
||||
return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка авторизации: " + e.getMessage());
|
||||
return ApiResponse.error(AuthManager.friendlyConnectionError(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +56,7 @@ public class AuthService {
|
||||
AuthManager.logout();
|
||||
return ApiResponse.success(true);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка при выходе: " + e.getMessage());
|
||||
return ApiResponse.error("Logout error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,23 +67,27 @@ public class AuthService {
|
||||
SessionInfo info = new SessionInfo(
|
||||
AuthManager.getUsername(),
|
||||
AuthManager.getAccessToken(),
|
||||
AuthManager.hasActivePass()
|
||||
AuthManager.hasActivePass(),
|
||||
AuthManager.getRole(),
|
||||
AuthManager.getRoleName()
|
||||
);
|
||||
return ApiResponse.success(info);
|
||||
}
|
||||
return ApiResponse.error("Сессия не найдена");
|
||||
return ApiResponse.error("Session not found");
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка проверки сессии: " + e.getMessage());
|
||||
return ApiResponse.error("Session check error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> activatePass(String passCode) {
|
||||
try {
|
||||
String response = post("/auth/pass/activate",
|
||||
"{\"code\":\"" + passCode + "\"}");
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("pass_code", passCode);
|
||||
String response = post("/auth/pass/activate", json.toString());
|
||||
AuthManager.refreshUserInfo();
|
||||
return ApiResponse.success(true);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка активации проходки: " + e.getMessage());
|
||||
return ApiResponse.error("Pass activation error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +137,10 @@ public class AuthService {
|
||||
return AuthManager.getUsername();
|
||||
}
|
||||
|
||||
public String getCurrentToken() {
|
||||
return AuthManager.getAccessToken();
|
||||
}
|
||||
|
||||
public static class LoginResult {
|
||||
private String username;
|
||||
private String token;
|
||||
@@ -120,15 +158,21 @@ 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) {
|
||||
public SessionInfo(String username, String token, boolean passActive, int role, String roleName) {
|
||||
this.username = username;
|
||||
this.token = token;
|
||||
this.passActive = passActive;
|
||||
this.role = role;
|
||||
this.roleName = roleName;
|
||||
}
|
||||
|
||||
public String getUsername() { return username; }
|
||||
public String getToken() { return token; }
|
||||
public boolean isPassActive() { return passActive; }
|
||||
public int getRole() { return role; }
|
||||
public String getRoleName() { return roleName; }
|
||||
}
|
||||
}
|
||||
+34
-10
@@ -18,7 +18,7 @@ public class InstanceService {
|
||||
.collect(Collectors.toList());
|
||||
return ApiResponse.success(infoList);
|
||||
} catch (IOException e) {
|
||||
return ApiResponse.error("Ошибка получения списка сборок: " + e.getMessage());
|
||||
return ApiResponse.error("Error getting instances list: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ public class InstanceService {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Сборка не найдена: " + name);
|
||||
return ApiResponse.error("Pack not found: " + name);
|
||||
}
|
||||
return ApiResponse.success(toInstanceInfo(instance));
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка получения сборки: " + e.getMessage());
|
||||
return ApiResponse.error("Error getting pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ public class InstanceService {
|
||||
try {
|
||||
boolean created = InstanceManager.createInstanceFolder(name);
|
||||
if (!created) {
|
||||
return ApiResponse.error("Сборка с таким именем уже существует: " + name);
|
||||
return ApiResponse.error("A pack with this name already exists: " + name);
|
||||
}
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
return ApiResponse.success(toInstanceInfo(instance));
|
||||
} catch (IOException e) {
|
||||
return ApiResponse.error("Ошибка создания сборки: " + e.getMessage());
|
||||
return ApiResponse.error("Error creating pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,11 @@ public class InstanceService {
|
||||
try {
|
||||
boolean deleted = InstanceManager.deleteInstance(name);
|
||||
if (!deleted) {
|
||||
return ApiResponse.error("Не удалось удалить сборку: " + name);
|
||||
return ApiResponse.error("Failed to delete pack: " + name);
|
||||
}
|
||||
return ApiResponse.success(true);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка удаления сборки: " + e.getMessage());
|
||||
return ApiResponse.error("Error deleting pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,16 +64,24 @@ public class InstanceService {
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
return ApiResponse.success(instance != null);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка проверки сборки: " + e.getMessage());
|
||||
return ApiResponse.error("Error checking pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private InstanceInfo toInstanceInfo(Instance instance) {
|
||||
String name = instance.getName().toLowerCase();
|
||||
String category = instance.isServerPack() ? "zernmc" : "local";
|
||||
|
||||
return new InstanceInfo(
|
||||
instance.getName(),
|
||||
instance.getPath().toString(),
|
||||
instance.getMinecraftVersion(),
|
||||
instance.getLoaderType()
|
||||
instance.getLoaderType(),
|
||||
category,
|
||||
instance.isServerPack(),
|
||||
instance.getServerVersion(),
|
||||
instance.getLoaderVersion(),
|
||||
instance.getServerPackName()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,17 +90,33 @@ 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) {
|
||||
public InstanceInfo(String name, String path, String version, String loaderType, String category,
|
||||
boolean isServerPack, int serverVersion, String loaderVersion, String serverPackName) {
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
this.version = version;
|
||||
this.loaderType = loaderType;
|
||||
this.category = category;
|
||||
this.isServerPack = isServerPack;
|
||||
this.serverVersion = serverVersion;
|
||||
this.loaderVersion = loaderVersion;
|
||||
this.serverPackName = serverPackName;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public String getPath() { return path; }
|
||||
public String getVersion() { return version; }
|
||||
public String getLoaderType() { return loaderType; }
|
||||
public String getCategory() { return category; }
|
||||
public boolean isServerPack() { return isServerPack; }
|
||||
public int getServerVersion() { return serverVersion; }
|
||||
public String getLoaderVersion() { return loaderVersion; }
|
||||
public String getServerPackName() { return serverPackName; }
|
||||
}
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
package me.sashegdev.zernmc.launcher.api.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class LaunchService {
|
||||
|
||||
private static final ConcurrentHashMap<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; }
|
||||
}
|
||||
}
|
||||
+230
-53
@@ -6,10 +6,12 @@ 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;
|
||||
@@ -25,37 +27,71 @@ 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)) return false;
|
||||
if (!Files.exists(AUTH_FILE)) {
|
||||
LauncherLogger.warn("loadSavedSession: auth.json not found at " + AUTH_FILE);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
String json = Files.readString(AUTH_FILE);
|
||||
AuthSession loaded = GSON.fromJson(json, AuthSession.class);
|
||||
if (loaded == null || loaded.accessToken == null) return false;
|
||||
if (loaded == null || loaded.accessToken == null) {
|
||||
LauncherLogger.warn("loadSavedSession: invalid auth.json content, deleting");
|
||||
Files.deleteIfExists(AUTH_FILE);
|
||||
return false;
|
||||
}
|
||||
|
||||
session = loaded;
|
||||
userInfo = fetchUserInfo();
|
||||
LauncherLogger.info("loadSavedSession: loaded session for " + loaded.username
|
||||
+ " expiresAt=" + loaded.expiresAt + " hasRefresh=" + (loaded.refreshToken != null));
|
||||
|
||||
refreshUserInfo();
|
||||
|
||||
if (isAccessTokenExpired()) {
|
||||
return tryRefresh();
|
||||
LauncherLogger.info("loadSavedSession: token expired, attempting refresh");
|
||||
boolean refreshed = tryRefresh();
|
||||
if (!refreshed) {
|
||||
if (session == null) {
|
||||
LauncherLogger.warn("loadSavedSession: token rejected by server (401)");
|
||||
return false;
|
||||
}
|
||||
LauncherLogger.warn("loadSavedSession: refresh failed (network/no refreshToken),"
|
||||
+ " keeping session for retry on next launch");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (session == null) {
|
||||
LauncherLogger.warn("loadSavedSession: session invalidated during token refresh");
|
||||
return false;
|
||||
}
|
||||
LauncherLogger.info("loadSavedSession: session valid for " + session.username);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("loadSavedSession error: " + e.getMessage());
|
||||
invalidateSession();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== АВТОРИЗАЦИЯ ======================
|
||||
public static boolean tryAutoLogin() {
|
||||
if (isLoggedIn()) return true;
|
||||
if (!Files.exists(AUTH_FILE)) return false;
|
||||
boolean loaded = loadSavedSession();
|
||||
if (loaded) {
|
||||
refreshUserInfo();
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
public static AuthResult login(String username, String password) {
|
||||
return authRequest("/auth/login", username, password);
|
||||
}
|
||||
@@ -72,49 +108,115 @@ 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(extractError(resp.body()));
|
||||
return AuthResult.fail(friendlyHttpError(resp.statusCode(), resp.body()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
|
||||
LauncherLogger.warn("authRequest failed (" + endpoint + "): " + e.getMessage());
|
||||
return AuthResult.fail(friendlyConnectionError(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an HTTP status code + raw body to a short, human-readable error message
|
||||
* (in Russian) suitable for showing directly to the user.
|
||||
*/
|
||||
public static String friendlyHttpError(int statusCode, String body) {
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
case 403:
|
||||
return "Неверный логин или пароль";
|
||||
case 404:
|
||||
return "Сервис временно недоступен, попробуйте позже";
|
||||
case 409:
|
||||
return "Логин уже занят";
|
||||
case 422:
|
||||
return "Некорректные данные: " + extractError(body);
|
||||
case 429:
|
||||
return "Слишком много попыток. Подождите немного.";
|
||||
default:
|
||||
if (statusCode >= 500) {
|
||||
return "Сервер временно недоступен, попробуйте позже";
|
||||
}
|
||||
}
|
||||
String detail = extractError(body);
|
||||
if (detail.isEmpty() || detail.startsWith("<")) {
|
||||
return "Ошибка сервера (код " + statusCode + ")";
|
||||
}
|
||||
if (detail.length() > 120) {
|
||||
detail = detail.substring(0, 117) + "...";
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a network/IO exception to a short, human-readable message.
|
||||
*/
|
||||
public static String friendlyConnectionError(Exception e) {
|
||||
String m = e == null ? "" : String.valueOf(e.getMessage()).toLowerCase();
|
||||
if (e instanceof java.net.SocketTimeoutException
|
||||
|| e instanceof java.net.ConnectException
|
||||
|| e instanceof java.net.UnknownHostException
|
||||
|| m.contains("timeout") || m.contains("connect") || m.contains("unknownhost")
|
||||
|| m.contains("refused")) {
|
||||
return "Нет соединения с сервером. Проверьте интернет.";
|
||||
}
|
||||
return "Ошибка соединения с сервером. Попробуйте позже.";
|
||||
}
|
||||
|
||||
public static void logout() {
|
||||
if (session != null && session.refreshToken != null) {
|
||||
try {
|
||||
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
|
||||
} catch (Exception ignored) {}
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("refresh_token", session.refreshToken);
|
||||
post("/auth/logout", json.toString());
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Logout error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
session = null;
|
||||
userInfo = null;
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception e) {
|
||||
LauncherLogger.warn("Failed to delete auth.json: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isLoggedIn() {
|
||||
return session != null && session.accessToken != null;
|
||||
}
|
||||
|
||||
public static boolean authFileExists() {
|
||||
return Files.exists(AUTH_FILE);
|
||||
}
|
||||
|
||||
public static String getUsername() {
|
||||
return session != null ? session.username : "Player";
|
||||
AuthSession localSession = session;
|
||||
return localSession != null ? localSession.username : "Player";
|
||||
}
|
||||
|
||||
public static String getUuid() {
|
||||
return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000";
|
||||
AuthSession localSession = session;
|
||||
return localSession != null ? localSession.uuid : "00000000-0000-0000-0000-000000000000";
|
||||
}
|
||||
|
||||
public static String getAccessToken() {
|
||||
if (session == null) return "0";
|
||||
AuthSession localSession = session;
|
||||
if (localSession == null) return "0";
|
||||
if (isAccessTokenExpired()) {
|
||||
tryRefresh();
|
||||
boolean refreshed = tryRefresh();
|
||||
if (!refreshed) {
|
||||
localSession = session;
|
||||
if (localSession == null) return "0";
|
||||
return localSession.accessToken != null ? localSession.accessToken : "0";
|
||||
}
|
||||
}
|
||||
return session != null && session.accessToken != null ? session.accessToken : "0";
|
||||
localSession = session;
|
||||
return localSession != null && localSession.accessToken != null ? localSession.accessToken : "0";
|
||||
}
|
||||
|
||||
private static boolean isAccessTokenExpired() {
|
||||
@@ -123,41 +225,70 @@ public class AuthManager {
|
||||
}
|
||||
|
||||
private static boolean tryRefresh() {
|
||||
if (session == null || session.refreshToken == null) return false;
|
||||
if (session == null) {
|
||||
LauncherLogger.warn("tryRefresh: session is null");
|
||||
return false;
|
||||
}
|
||||
if (session.refreshToken == null) {
|
||||
LauncherLogger.warn("tryRefresh: no refreshToken in session");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
|
||||
SimpleHttpResponse resp = post("/auth/refresh", body);
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("refresh_token", session.refreshToken);
|
||||
SimpleHttpResponse resp = post("/auth/refresh", json.toString());
|
||||
|
||||
if (resp.statusCode() == 200) {
|
||||
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
|
||||
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
|
||||
session = newSession;
|
||||
userInfo = fetchUserInfo();
|
||||
if (userInfo != null) {
|
||||
session.role = userInfo.role;
|
||||
}
|
||||
saveSession();
|
||||
LauncherLogger.info("tryRefresh: token refreshed successfully");
|
||||
return true;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (resp.statusCode() == 401) {
|
||||
LauncherLogger.warn("tryRefresh: server rejected refresh token (401)");
|
||||
invalidateSession();
|
||||
} else {
|
||||
LauncherLogger.warn("tryRefresh: server returned " + resp.statusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("tryRefresh: network error: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void invalidateSession() {
|
||||
session = null;
|
||||
userInfo = null;
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||
return false;
|
||||
try {
|
||||
Files.deleteIfExists(AUTH_FILE);
|
||||
LauncherLogger.info("Session invalidated, auth.json deleted");
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("Failed to delete auth.json", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveSession() {
|
||||
try {
|
||||
Files.createDirectories(AUTH_FILE.getParent());
|
||||
Files.writeString(AUTH_FILE, GSON.toJson(session));
|
||||
LauncherLogger.info("Session saved to " + AUTH_FILE);
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage()));
|
||||
LauncherLogger.error("Failed to save session", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ПОЛУЧЕНИЕ ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ====================
|
||||
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");
|
||||
@@ -184,36 +315,52 @@ public class AuthManager {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("Не удалось получить UserInfo: " + e.getMessage());
|
||||
LauncherLogger.warn("Failed to get UserInfo: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ПРОВЕРКИ ПРАВ ====================
|
||||
public static boolean hasPass() {
|
||||
if (!isLoggedIn()) return false;
|
||||
if (userInfo != null) return userInfo.has_pass;
|
||||
return getRole() >= ROLE_PASS_HOLDER;
|
||||
if (getRole() >= ROLE_PASS_HOLDER) return true;
|
||||
try {
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
if (json.has("has_active")) {
|
||||
return json.get("has_active").getAsBoolean();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Failed to check pass: " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean canViewPacks() {
|
||||
if (userInfo != null && userInfo.permissions != null) {
|
||||
return userInfo.permissions.contains(PERM_VIEW_PACKS);
|
||||
}
|
||||
return hasPass(); // fallback для старых аккаунтов
|
||||
return hasPass();
|
||||
}
|
||||
|
||||
public static boolean canDownloadPacks() {
|
||||
if (userInfo != null && userInfo.permissions != null) {
|
||||
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
|
||||
}
|
||||
return hasPass(); // fallback
|
||||
return hasPass();
|
||||
}
|
||||
|
||||
public static int getRole() {
|
||||
return session != null ? session.role : ROLE_USER;
|
||||
}
|
||||
|
||||
// ====================== POST ======================
|
||||
public static String getRoleName() {
|
||||
if (userInfo != null && userInfo.role_name != null) {
|
||||
return userInfo.role_name;
|
||||
}
|
||||
return "USER";
|
||||
}
|
||||
|
||||
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
|
||||
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
||||
HttpURLConnection conn = null;
|
||||
@@ -245,11 +392,15 @@ public class AuthManager {
|
||||
}
|
||||
|
||||
int statusCode = conn.getResponseCode();
|
||||
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||
InputStream is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||
|
||||
String responseBody;
|
||||
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
||||
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||
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);
|
||||
@@ -272,36 +423,49 @@ 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;
|
||||
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;
|
||||
}
|
||||
return hasPass();
|
||||
}
|
||||
|
||||
public static String getPassStatus() {
|
||||
if (!isLoggedIn()) return "Не авторизован";
|
||||
if (!isLoggedIn()) return "Not logged in";
|
||||
try {
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean();
|
||||
return hasActive ? "Есть активная проходка" : "Проходка отсутствует";
|
||||
return hasActive ? "Active pass" : "No pass";
|
||||
} catch (Exception e) {
|
||||
return "Ошибка проверки";
|
||||
return "Check error";
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
|
||||
public static class AuthSession {
|
||||
@SerializedName("access_token") public String accessToken;
|
||||
@SerializedName("refresh_token") public String refreshToken;
|
||||
@SerializedName("expires_in") public int expiresIn;
|
||||
public transient long expiresAt;
|
||||
public long expiresAt;
|
||||
public String username;
|
||||
public String uuid;
|
||||
public int role;
|
||||
@@ -337,9 +501,22 @@ 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;
|
||||
@@ -351,4 +528,4 @@ class SimpleHttpResponse {
|
||||
|
||||
int statusCode() { return statusCode; }
|
||||
String body() { return body; }
|
||||
}
|
||||
}
|
||||
+96
-180
@@ -26,75 +26,30 @@ import java.util.stream.Collectors;
|
||||
public class LaunchMenu {
|
||||
|
||||
public void show() throws Exception {
|
||||
if (Config.isZernMCBuild()) {
|
||||
showZernMCOnly();
|
||||
} else {
|
||||
showGlobal();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== 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);
|
||||
}
|
||||
showGlobal();
|
||||
}
|
||||
|
||||
private boolean awaitActivePass() throws Exception {
|
||||
if (AuthManager.hasActivePass()) {
|
||||
System.out.println(ZAnsi.brightGreen("✓ Активная проходка подтверждена"));
|
||||
System.out.println(ZAnsi.brightGreen("✓ Active pass confirmed"));
|
||||
return true;
|
||||
}
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
|
||||
System.out.println(ZAnsi.white("Для доступа к сборкам ZernMC требуется активная проходка."));
|
||||
System.out.println(ZAnsi.brightRed("You don't have an active pass!"));
|
||||
System.out.println(ZAnsi.white("Access to ZernMC packs requires an active pass."));
|
||||
System.out.println();
|
||||
|
||||
openActivationWebsite();
|
||||
|
||||
System.out.println(ZAnsi.cyan("Ожидаем активацию проходки... (проверка каждые 10 секунд)"));
|
||||
System.out.println(ZAnsi.white("Нажмите Enter для отмены"));
|
||||
System.out.println(ZAnsi.cyan("Waiting for pass activation... (checking every 10 seconds)"));
|
||||
System.out.println(ZAnsi.white("Press Enter to cancel"));
|
||||
|
||||
for (int i = 0; i < 60; i++) {
|
||||
try {
|
||||
if (System.in.available() > 0) {
|
||||
Input.readLine();
|
||||
System.out.println(ZAnsi.yellow("\nОжидание отменено."));
|
||||
System.out.println(ZAnsi.yellow("\nWaiting cancelled."));
|
||||
return false;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
@@ -102,7 +57,7 @@ public class LaunchMenu {
|
||||
Thread.sleep(10000);
|
||||
|
||||
if (AuthManager.hasActivePass()) {
|
||||
System.out.println(ZAnsi.brightGreen("\n✓ Проходка успешно активирована!"));
|
||||
System.out.println(ZAnsi.brightGreen("\n✓ Pass activated successfully!"));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -110,66 +65,27 @@ public class LaunchMenu {
|
||||
if ((i + 1) % 6 == 0) System.out.println();
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightRed("\n\nВремя ожидания истекло."));
|
||||
System.out.println(ZAnsi.brightRed("\n\nWaiting time expired."));
|
||||
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("Браузер открыт: " + url));
|
||||
System.out.println(ZAnsi.cyan("Browser opened: " + url));
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически."));
|
||||
System.out.println(ZAnsi.white("Откройте вручную: " + url));
|
||||
System.out.println(ZAnsi.yellow("Could not open browser automatically."));
|
||||
System.out.println(ZAnsi.white("Open manually: " + url));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка открытия браузера: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.white("Ссылка: " + url));
|
||||
System.out.println(ZAnsi.brightRed("Error opening browser: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.white("Link: " + 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();
|
||||
@@ -179,10 +95,10 @@ public class LaunchMenu {
|
||||
.map(Instance::toString)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
options.add("Установить новую сборку");
|
||||
options.add("Назад в главное меню");
|
||||
options.add("Install new pack");
|
||||
options.add("Back to main menu");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Управление сборками", options);
|
||||
ArrowMenu menu = new ArrowMenu("Manage packs", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) break;
|
||||
@@ -201,13 +117,13 @@ public class LaunchMenu {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Установить сборку с сервера ZernMC",
|
||||
"Установить Vanilla Minecraft",
|
||||
"Создать сборку вручную (Fabric/Forge)",
|
||||
"Назад"
|
||||
"Install pack from ZernMC server",
|
||||
"Install Vanilla Minecraft",
|
||||
"Create custom pack (Fabric/Forge)",
|
||||
"Back"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Установка новой сборки", options);
|
||||
ArrowMenu menu = new ArrowMenu("Install new pack", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 3) return;
|
||||
@@ -223,28 +139,28 @@ public class LaunchMenu {
|
||||
if (!awaitActivePass()) return;
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Получение списка доступных сборок..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching available packs..."));
|
||||
|
||||
PackDownloader tempDownloader = new PackDownloader(null);
|
||||
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
|
||||
|
||||
if (availablePacks.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Нет доступных сборок на сервере."));
|
||||
System.out.println(ZAnsi.yellow("No packs available on the server."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> options = availablePacks.stream()
|
||||
.map(p -> String.format("%s [%s + %s v%d] — %d файлов",
|
||||
.map(p -> String.format("%s [%s + %s v%d] - %d files",
|
||||
p.getName(),
|
||||
p.getMinecraftVersion(),
|
||||
p.getLoaderType(),
|
||||
p.getVersion(),
|
||||
p.getFilesCount()))
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options);
|
||||
ArrowMenu menu = new ArrowMenu("Select a pack to install", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
@@ -252,14 +168,14 @@ public class LaunchMenu {
|
||||
ServerPack selected = availablePacks.get(choice);
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
|
||||
System.out.println(ZAnsi.header("Installing pack: " + selected.getName()));
|
||||
|
||||
System.out.print(ZAnsi.white("\nВведите название локальной сборки (Enter = имя пака): "));
|
||||
System.out.print(ZAnsi.white("\nEnter local pack name (Enter = pack name): "));
|
||||
String localName = Input.readLine().trim();
|
||||
if (localName.isEmpty()) localName = selected.getName();
|
||||
|
||||
if (InstanceManager.getInstance(localName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
@@ -271,37 +187,36 @@ public class LaunchMenu {
|
||||
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
// ====================== manageInstance — полностью восстановлен ======================
|
||||
private void manageInstance(Instance instance) throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName()));
|
||||
System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion()));
|
||||
System.out.println(ZAnsi.white("Лоадер: " + instance.getLoaderType() +
|
||||
System.out.println(ZAnsi.header("Managing pack: " + instance.getName()));
|
||||
System.out.println(ZAnsi.white("Version: " + instance.getMinecraftVersion()));
|
||||
System.out.println(ZAnsi.white("Loader: " + instance.getLoaderType() +
|
||||
(instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : "")));
|
||||
|
||||
if (instance.isServerPack()) {
|
||||
System.out.println(ZAnsi.green("Серверная сборка: v" + instance.getServerVersion()));
|
||||
System.out.println(ZAnsi.green("Server pack: v" + instance.getServerVersion()));
|
||||
}
|
||||
|
||||
List<String> options = new ArrayList<>();
|
||||
options.add("Запустить сборку");
|
||||
options.add("Launch pack");
|
||||
if (instance.isServerPack()) {
|
||||
options.add("Проверить обновления");
|
||||
options.add("Check for updates");
|
||||
}
|
||||
options.add("Изменить версию лоадера");
|
||||
options.add("Удалить сборку");
|
||||
options.add("Назад");
|
||||
options.add("Change loader version");
|
||||
options.add("Delete pack");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Действия", options);
|
||||
ArrowMenu menu = new ArrowMenu("Actions", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
@@ -329,40 +244,40 @@ public class LaunchMenu {
|
||||
|
||||
private void checkAndUpdateServerPack(Instance instance) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName()));
|
||||
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName()));
|
||||
|
||||
PackDownloader downloader = new PackDownloader(instance);
|
||||
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||
|
||||
if (!hasUpdate) {
|
||||
System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")"));
|
||||
System.out.println(ZAnsi.green("Pack is up to date (v" + instance.getServerVersion() + ")"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightYellow("Доступно обновление!"));
|
||||
if (Input.confirm("Обновить сборку")) {
|
||||
System.out.println(ZAnsi.brightYellow("Update available!"));
|
||||
if (Input.confirm("Update pack")) {
|
||||
boolean success = downloader.updatePack(instance.getServerPackName());
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("Сборка успешно обновлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Pack updated successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось обновить сборку."));
|
||||
System.out.println(ZAnsi.brightRed("Failed to update pack."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Обновление отменено."));
|
||||
System.out.println(ZAnsi.yellow("Update cancelled."));
|
||||
}
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void changeLoaderVersion(Instance instance) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Изменение версии лоадера для " + instance.getName()));
|
||||
System.out.println(ZAnsi.cyan("Changing loader version for " + instance.getName()));
|
||||
|
||||
String currentLoader = instance.getLoaderType();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
|
||||
if ("vanilla".equalsIgnoreCase(currentLoader)) {
|
||||
System.out.println(ZAnsi.yellow("Это vanilla сборка. Нельзя изменить лоадер."));
|
||||
System.out.println(ZAnsi.yellow("This is a vanilla instance. Cannot change loader."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
@@ -378,7 +293,7 @@ public class LaunchMenu {
|
||||
|
||||
if (newLoaderVersion == null) return;
|
||||
|
||||
System.out.println(ZAnsi.cyan("Переустановка лоадера " + currentLoader + " -> " + newLoaderVersion + "..."));
|
||||
System.out.println(ZAnsi.cyan("Reinstalling loader " + currentLoader + " -> " + newLoaderVersion + "..."));
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
boolean success;
|
||||
@@ -393,12 +308,12 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Loader version changed successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось изменить версию лоадера."));
|
||||
System.out.println(ZAnsi.brightRed("Failed to change loader version."));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.brightRed("Error changing loader: " + e.getMessage()));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -408,12 +323,12 @@ public class LaunchMenu {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> confirmOptions = List.of(
|
||||
"Да, удалить сборку",
|
||||
"Нет, отменить"
|
||||
"Yes, delete pack",
|
||||
"No, cancel"
|
||||
);
|
||||
|
||||
ArrowMenu confirmMenu = new ArrowMenu(
|
||||
"Вы действительно хотите удалить сборку '" + instance.getName() + "'?",
|
||||
"Are you sure you want to delete '" + instance.getName() + "'?",
|
||||
confirmOptions
|
||||
);
|
||||
|
||||
@@ -422,12 +337,12 @@ public class LaunchMenu {
|
||||
if (choice == 0) {
|
||||
boolean deleted = InstanceManager.deleteInstance(instance.getName());
|
||||
if (deleted) {
|
||||
System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена."));
|
||||
System.out.println(ZAnsi.brightGreen("Pack '" + instance.getName() + "' deleted successfully."));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось удалить сборку."));
|
||||
System.out.println(ZAnsi.brightRed("Failed to delete pack."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Удаление отменено."));
|
||||
System.out.println(ZAnsi.yellow("Deletion cancelled."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -436,16 +351,20 @@ public class LaunchMenu {
|
||||
private void launchExistingInstance(Instance instance) {
|
||||
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
|
||||
System.out.println(ZAnsi.brightRed("Launching a server pack requires an active pass!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
||||
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
options.setMaxMemory(Config.getMaxMemory());
|
||||
options.setWidth(Config.getWindowWidth());
|
||||
options.setHeight(Config.getWindowHeight());
|
||||
options.setJavaPath(Config.getJavaPath());
|
||||
|
||||
options.setUsername(AuthManager.getUsername());
|
||||
options.setUuid(AuthManager.getUuid());
|
||||
@@ -454,20 +373,18 @@ public class LaunchMenu {
|
||||
try {
|
||||
lib.launch(options);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.brightRed("Error launching: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
// ====================== Остальные вспомогательные методы ======================
|
||||
|
||||
private String askPackName() {
|
||||
System.out.print(ZAnsi.white("\nВведите название новой сборки: "));
|
||||
System.out.print(ZAnsi.white("\nEnter new pack name: "));
|
||||
String name = Input.readLine().trim();
|
||||
if (name.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Отменено."));
|
||||
System.out.println(ZAnsi.yellow("Cancelled."));
|
||||
return null;
|
||||
}
|
||||
return name;
|
||||
@@ -475,7 +392,7 @@ public class LaunchMenu {
|
||||
|
||||
private void createVanillaInstance() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||
@@ -483,9 +400,9 @@ public class LaunchMenu {
|
||||
List<String> versionOptions = allVersions.stream()
|
||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||
.collect(Collectors.toList());
|
||||
versionOptions.add("Назад");
|
||||
versionOptions.add("Back");
|
||||
|
||||
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
||||
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
|
||||
int versionChoice = versionMenu.show();
|
||||
|
||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||
@@ -497,7 +414,7 @@ public class LaunchMenu {
|
||||
if (packName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(packName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
@@ -509,9 +426,9 @@ public class LaunchMenu {
|
||||
boolean success = lib.installMinecraft(mcVersion);
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!"));
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla pack '" + packName + "' created successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку."));
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to create pack."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -519,7 +436,7 @@ public class LaunchMenu {
|
||||
|
||||
private void createCustomInstance() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||
@@ -527,9 +444,9 @@ public class LaunchMenu {
|
||||
List<String> versionOptions = allVersions.stream()
|
||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||
.collect(Collectors.toList());
|
||||
versionOptions.add("Назад");
|
||||
versionOptions.add("Back");
|
||||
|
||||
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
||||
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
|
||||
int versionChoice = versionMenu.show();
|
||||
|
||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||
@@ -538,7 +455,7 @@ public class LaunchMenu {
|
||||
String mcVersion = selectedMc.getId();
|
||||
|
||||
List<String> loaderOptions = buildLoaderOptions(mcVersion);
|
||||
ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions);
|
||||
ArrowMenu loaderMenu = new ArrowMenu("Select mod loader for " + mcVersion, loaderOptions);
|
||||
int loaderChoice = loaderMenu.show();
|
||||
|
||||
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
|
||||
@@ -574,7 +491,7 @@ public class LaunchMenu {
|
||||
if (packName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(packName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
@@ -594,9 +511,9 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + packName + "' installed successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to install pack."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -609,7 +526,7 @@ public class LaunchMenu {
|
||||
if (isNeoForgeSupported(mcVersion)) options.add("NeoForge");
|
||||
if (isForgeSupported(mcVersion)) options.add("Forge");
|
||||
options.add("Vanilla");
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -631,16 +548,16 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
private String askFabricLoaderVersion() throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching Fabric Loader versions..."));
|
||||
List<String> versions = ZHttpClient.getFabricLoaderVersions();
|
||||
|
||||
List<String> options = versions.stream()
|
||||
.limit(30)
|
||||
.map(v -> "Fabric Loader " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выбор версии Fabric Loader", options);
|
||||
ArrowMenu menu = new ArrowMenu("Select Fabric Loader version", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
@@ -648,7 +565,7 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
private String askForgeVersion(String mcVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Forge для " + mcVersion + "..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching Forge versions for " + mcVersion + "..."));
|
||||
|
||||
List<String> allForgeVersions = getAllForgeVersions();
|
||||
|
||||
@@ -658,7 +575,7 @@ public class LaunchMenu {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (compatibleVersions.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion));
|
||||
System.out.println(ZAnsi.yellow("No compatible Forge versions found for " + mcVersion));
|
||||
ConsoleUtils.pause();
|
||||
return null;
|
||||
}
|
||||
@@ -667,9 +584,9 @@ public class LaunchMenu {
|
||||
.limit(30)
|
||||
.map(v -> "Forge " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выбор версии Forge для " + mcVersion, options);
|
||||
ArrowMenu menu = new ArrowMenu("Select Forge version for " + mcVersion, options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
@@ -698,7 +615,7 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
private String askNeoForgeVersion(String mcVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий NeoForge для " + mcVersion + "..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching NeoForge versions for " + mcVersion + "..."));
|
||||
|
||||
List<String> allNeoForgeVersions = getAllNeoForgeVersions();
|
||||
|
||||
@@ -707,7 +624,7 @@ public class LaunchMenu {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (compatibleVersions.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Не найдено совместимых версий NeoForge для " + mcVersion));
|
||||
System.out.println(ZAnsi.yellow("No compatible NeoForge versions found for " + mcVersion));
|
||||
ConsoleUtils.pause();
|
||||
return null;
|
||||
}
|
||||
@@ -716,9 +633,9 @@ public class LaunchMenu {
|
||||
.limit(30)
|
||||
.map(v -> "NeoForge " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выбор версии NeoForge для " + mcVersion, options);
|
||||
ArrowMenu menu = new ArrowMenu("Select NeoForge version for " + mcVersion, options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
@@ -760,11 +677,10 @@ public class LaunchMenu {
|
||||
index = end;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Skip if one maven doesn't have the artifact
|
||||
}
|
||||
}
|
||||
|
||||
versions.sort((a, b) -> b.compareTo(a));
|
||||
return versions;
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-54
@@ -10,30 +10,20 @@ 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("Добро пожаловать в ZernMC!", options);
|
||||
ArrowMenu menu = new ArrowMenu("Welcome to ZernMC!", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 2) return false;
|
||||
@@ -45,62 +35,56 @@ public class LoginMenu {
|
||||
};
|
||||
|
||||
if (success) return true;
|
||||
// Если не успех — покажем меню снова (ошибка уже напечатана внутри методов)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывается когда пользователь уже вошёл — предлагает выйти из аккаунта.
|
||||
*/
|
||||
public void showAccountMenu() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
System.out.println(ZAnsi.header("=== Аккаунт ==="));
|
||||
System.out.println(ZAnsi.header("=== Account ==="));
|
||||
System.out.println();
|
||||
System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
|
||||
System.out.println(ZAnsi.white(" Player: ") + 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("Управление аккаунтом", options);
|
||||
ArrowMenu menu = new ArrowMenu("Account Management", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == 0) {
|
||||
AuthManager.logout();
|
||||
System.out.println(ZAnsi.yellow("Вы вышли из аккаунта."));
|
||||
System.out.println(ZAnsi.yellow("Logged out."));
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ПРИВАТНЫЕ МЕТОДЫ ======================
|
||||
|
||||
private boolean doLogin() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]"));
|
||||
System.out.println(ZAnsi.cyan(" [ Sign In ]"));
|
||||
System.out.println();
|
||||
|
||||
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
|
||||
String username = Input.readLine(ZAnsi.white(" Username: "));
|
||||
if (username.isEmpty()) return false;
|
||||
|
||||
String password = readPassword(" Пароль: ");
|
||||
String password = readPassword(" Password: ");
|
||||
if (password.isEmpty()) return false;
|
||||
|
||||
System.out.println();
|
||||
System.out.print(ZAnsi.cyan(" Выполняем вход..."));
|
||||
System.out.print(ZAnsi.cyan(" Signing in..."));
|
||||
|
||||
AuthResult result = AuthManager.login(username, password);
|
||||
|
||||
if (result.success) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! "));
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Welcome, " + AuthManager.getUsername() + "! "));
|
||||
ConsoleUtils.pause();
|
||||
return true;
|
||||
} else {
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
@@ -109,45 +93,41 @@ public class LoginMenu {
|
||||
private boolean doRegister() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]"));
|
||||
System.out.println(ZAnsi.cyan(" [ Create Account ]"));
|
||||
System.out.println();
|
||||
System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _"));
|
||||
System.out.println(ZAnsi.yellow(" Длина имени: 3-16 символов | Длина пароля: от 6 символов"));
|
||||
System.out.println(ZAnsi.yellow(" Allowed characters: a-z, A-Z, 0-9, _"));
|
||||
System.out.println(ZAnsi.yellow(" Name length: 3-16 chars | Password length: 6+ chars"));
|
||||
System.out.println();
|
||||
|
||||
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
|
||||
String username = Input.readLine(ZAnsi.white(" Username: "));
|
||||
if (username.isEmpty()) return false;
|
||||
|
||||
String password = readPassword(" Пароль: ");
|
||||
String password = readPassword(" Password: ");
|
||||
if (password.isEmpty()) return false;
|
||||
|
||||
String confirm = readPassword(" Повторите пароль: ");
|
||||
String confirm = readPassword(" Confirm password: ");
|
||||
if (!password.equals(confirm)) {
|
||||
System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!"));
|
||||
System.out.println(ZAnsi.brightRed("\n Passwords do not match!"));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
System.out.print(ZAnsi.cyan(" Создаём аккаунт..."));
|
||||
System.out.print(ZAnsi.cyan(" Creating account..."));
|
||||
|
||||
AuthResult result = AuthManager.register(username, password);
|
||||
|
||||
if (result.success) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! "));
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Account created! Welcome, " + AuthManager.getUsername() + "! "));
|
||||
ConsoleUtils.pause();
|
||||
return true;
|
||||
} else {
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Error: " + 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)
|
||||
@@ -165,27 +145,26 @@ public class LoginMenu {
|
||||
int key = passTerminal.reader().read();
|
||||
|
||||
if (key == 27) {
|
||||
// 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'
|
||||
int next = passTerminal.reader().read();
|
||||
if (next == 91) {
|
||||
passTerminal.reader().read();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key == 13 || key == 10) { // Enter
|
||||
if (key == 13 || key == 10) {
|
||||
passTerminal.writer().println();
|
||||
break;
|
||||
} else if (key == 127 || key == 8) { // Backspace
|
||||
} else if (key == 127 || key == 8) {
|
||||
if (password.length() > 0) {
|
||||
password.setLength(password.length() - 1);
|
||||
passTerminal.writer().print("\b \b");
|
||||
passTerminal.writer().flush();
|
||||
}
|
||||
} else if (key == 3) { // Ctrl+C
|
||||
} else if (key == 3) {
|
||||
passTerminal.writer().println();
|
||||
System.exit(0);
|
||||
} else if (key >= 32 && key < 127) { // Printable characters
|
||||
} else if (key >= 32 && key < 127) {
|
||||
password.append((char) key);
|
||||
passTerminal.writer().print('*');
|
||||
passTerminal.writer().flush();
|
||||
+28
-28
@@ -18,17 +18,17 @@ public class ServerCheckMenu {
|
||||
public void show() throws IOException {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Диагностика подключения"));
|
||||
System.out.println(ZAnsi.header("Connection Diagnostics"));
|
||||
|
||||
List<String> options = List.of(
|
||||
"Проверить подключение к ZernMC серверу",
|
||||
"Проверить доступ к Mojang (Minecraft)",
|
||||
"Проверить доступ к Fabric Meta",
|
||||
"Проверить доступ к Forge Maven",
|
||||
"Назад в главное меню"
|
||||
"Check ZernMC server connection",
|
||||
"Check Mojang (Minecraft) access",
|
||||
"Check Fabric Meta access",
|
||||
"Check Forge Maven access",
|
||||
"Back to main menu"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выберите проверку", options);
|
||||
ArrowMenu menu = new ArrowMenu("Select check", 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("Проверка подключения к ZernMC серверу..."));
|
||||
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
|
||||
|
||||
try {
|
||||
String response = ZHttpClient.get("/health");
|
||||
System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!"));
|
||||
System.out.println(ZAnsi.white("Ответ сервера: ") + response);
|
||||
System.out.println(ZAnsi.brightGreen("[OK] ZernMC server connected successfully!"));
|
||||
System.out.println(ZAnsi.white("Server response: ") + response);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Could not connect to ZernMC server"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkMojang() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
|
||||
System.out.println(ZAnsi.cyan("Checking Mojang access..."));
|
||||
|
||||
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 доступен"));
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Mojang is accessible"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode()));
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Mojang returned code " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Mojang"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkFabric() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
|
||||
System.out.println(ZAnsi.cyan("Checking Fabric Meta access..."));
|
||||
|
||||
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 доступен"));
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta is accessible"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta вернул код " + response.statusCode()));
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta returned code " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Fabric Meta"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkForge() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Forge Maven..."));
|
||||
System.out.println(ZAnsi.cyan("Checking Forge Maven access..."));
|
||||
|
||||
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 доступен"));
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven is accessible"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven вернул код " + response.statusCode()));
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven returned code " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Forge Maven"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Forge Maven"));
|
||||
System.out.println(ZAnsi.white("Error: ") + 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(
|
||||
"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.");
|
||||
}
|
||||
}
|
||||
+28
-30
@@ -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("Проверка обновлений", options);
|
||||
ArrowMenu menu = new ArrowMenu("Update Check", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 2) return;
|
||||
@@ -34,7 +34,7 @@ public class UpdateMenu {
|
||||
try {
|
||||
checkPackUpdates();
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.brightRed("Error: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public class UpdateMenu {
|
||||
}
|
||||
|
||||
private void checkPackUpdates() throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений сборок..."));
|
||||
System.out.println(ZAnsi.cyan("Checking pack updates..."));
|
||||
|
||||
List<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("Нет сборок, установленных с сервера."));
|
||||
System.out.println(ZAnsi.yellow("No server-installed packs found."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.cyan("\nПроверка обновлений для серверных сборок:\n"));
|
||||
System.out.println(ZAnsi.cyan("\nChecking updates for server packs:\n"));
|
||||
|
||||
boolean hasUpdates = false;
|
||||
List<Instance> updatableInstances = new ArrayList<>();
|
||||
@@ -68,42 +68,41 @@ public class UpdateMenu {
|
||||
try {
|
||||
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||
if (hasUpdate) {
|
||||
System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!"));
|
||||
System.out.println(ZAnsi.yellow(instance.getName() + " - Update available!"));
|
||||
updatableInstances.add(instance);
|
||||
hasUpdates = true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.green(instance.getName() + " - Актуальна"));
|
||||
System.out.println(ZAnsi.green(instance.getName() + " - Up to date"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.red(instance.getName() + " - Ошибка проверки: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.red(instance.getName() + " - Check error: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUpdates) {
|
||||
System.out.println(ZAnsi.green("\nВсе сборки актуальны!"));
|
||||
System.out.println(ZAnsi.green("\nAll packs are up to date!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Предлагаем обновить каждую сборку отдельно
|
||||
for (Instance instance : updatableInstances) {
|
||||
System.out.println(ZAnsi.brightYellow("\nОбновить сборку '" + instance.getName() + "'?"));
|
||||
if (Input.confirm("Обновить")) {
|
||||
System.out.println(ZAnsi.cyan("Обновление " + instance.getName() + "..."));
|
||||
System.out.println(ZAnsi.brightYellow("\nUpdate pack '" + instance.getName() + "'?"));
|
||||
if (Input.confirm("Update")) {
|
||||
System.out.println(ZAnsi.cyan("Updating " + instance.getName() + "..."));
|
||||
PackDownloader downloader = new PackDownloader(instance);
|
||||
|
||||
try {
|
||||
boolean success = downloader.updatePack(instance.getServerPackName());
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen(instance.getName() + " обновлен"));
|
||||
System.out.println(ZAnsi.brightGreen(instance.getName() + " updated"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed(instance.getName() + " не удалось обновить"));
|
||||
System.out.println(ZAnsi.brightRed(instance.getName() + " update failed"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage()));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Пропущено: " + instance.getName()));
|
||||
System.out.println(ZAnsi.yellow(" Skipped: " + instance.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,28 +110,27 @@ public class UpdateMenu {
|
||||
}
|
||||
|
||||
private void checkLauncherUpdates() {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
|
||||
System.out.println(ZAnsi.cyan("Checking launcher updates..."));
|
||||
|
||||
try {
|
||||
String json = ZHttpClient.getLauncherVersionInfo();
|
||||
String serverVersion = extractVersion(json);
|
||||
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
|
||||
|
||||
System.out.println(ZAnsi.white("Текущая версия: ") + currentVersion);
|
||||
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion);
|
||||
System.out.println(ZAnsi.white("Current version: ") + currentVersion);
|
||||
System.out.println(ZAnsi.white("Server version: ") + serverVersion);
|
||||
|
||||
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
|
||||
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия!"));
|
||||
if (Input.confirm("Обновить лаунчер?")) {
|
||||
// Обновление будет при следующем запуске
|
||||
System.out.println(ZAnsi.green("Лаунчер будет обновлен при следующем запуске."));
|
||||
System.out.println(ZAnsi.brightYellow("\nNew version available!"));
|
||||
if (Input.confirm("Update launcher?")) {
|
||||
System.out.println(ZAnsi.green("Launcher will be updated on next restart."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
|
||||
System.out.println(ZAnsi.brightGreen("Launcher is up to date."));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.yellow("Could not check launcher updates."));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -149,4 +147,4 @@ public class UpdateMenu {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -1,5 +1,7 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import java.io.IOException;
|
||||
@@ -21,6 +23,7 @@ public class Instance {
|
||||
private int serverVersion; // версия сборки на сервере
|
||||
private String serverPackName; // имя пака на сервере
|
||||
private String fabricVersionId;
|
||||
private String presetId;
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
@@ -88,6 +91,15 @@ public class Instance {
|
||||
saveMetadata();
|
||||
}
|
||||
|
||||
public String getPresetId() {
|
||||
return presetId;
|
||||
}
|
||||
|
||||
public void setPresetId(String presetId) {
|
||||
this.presetId = presetId;
|
||||
saveMetadata();
|
||||
}
|
||||
|
||||
public String getServerPackName() {
|
||||
return serverPackName;
|
||||
}
|
||||
@@ -130,14 +142,17 @@ public class Instance {
|
||||
this.isServerPack = meta.isServerPack;
|
||||
this.serverVersion = meta.serverVersion;
|
||||
this.serverPackName = meta.serverPackName;
|
||||
} catch (Exception ignored) {}
|
||||
this.presetId = meta.presetId;
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void saveMetadata() {
|
||||
Path metaFile = path.resolve("instance.json");
|
||||
InstanceMeta meta = new InstanceMeta(
|
||||
minecraftVersion, loaderType, loaderVersion, assetIndex,
|
||||
isServerPack, serverVersion, serverPackName
|
||||
isServerPack, serverVersion, serverPackName, presetId
|
||||
);
|
||||
try {
|
||||
Files.writeString(metaFile, GSON.toJson(meta));
|
||||
@@ -154,12 +169,13 @@ public class Instance {
|
||||
boolean isServerPack = false;
|
||||
int serverVersion = 0;
|
||||
String serverPackName;
|
||||
String presetId;
|
||||
|
||||
|
||||
public InstanceMeta(String minecraftVersion, String loaderType,
|
||||
String loaderVersion, String assetIndex,
|
||||
boolean isServerPack, int serverVersion,
|
||||
String serverPackName) {
|
||||
String serverPackName, String presetId) {
|
||||
this.minecraftVersion = minecraftVersion;
|
||||
this.loaderType = loaderType;
|
||||
this.loaderVersion = loaderVersion;
|
||||
@@ -167,6 +183,7 @@ public class Instance {
|
||||
this.isServerPack = isServerPack;
|
||||
this.serverVersion = serverVersion;
|
||||
this.serverPackName = serverPackName;
|
||||
this.presetId = presetId;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -51,7 +53,7 @@ public class InstanceManager {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
System.err.println("Не удалось удалить: " + path);
|
||||
LauncherLogger.warn("Не удалось удалить: " + path);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
+116
-38
@@ -1,18 +1,24 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.ModLoaderInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipException;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
public class MinecraftLib {
|
||||
|
||||
@@ -38,13 +44,13 @@ public class MinecraftLib {
|
||||
}
|
||||
|
||||
public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception {
|
||||
ForgeInstaller installer = new ForgeInstaller(instance);
|
||||
return installer.install(minecraftVersion, forgeVersion);
|
||||
return new ModLoaderInstaller(instance)
|
||||
.install(minecraftVersion, forgeVersion, ModLoaderInstaller.LoaderType.FORGE);
|
||||
}
|
||||
|
||||
public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception {
|
||||
NeoForgeInstaller installer = new NeoForgeInstaller(instance);
|
||||
return installer.install(minecraftVersion, neoforgeVersion);
|
||||
return new ModLoaderInstaller(instance)
|
||||
.install(minecraftVersion, neoforgeVersion, ModLoaderInstaller.LoaderType.NEOFORGE);
|
||||
}
|
||||
|
||||
public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception {
|
||||
@@ -52,7 +58,7 @@ public class MinecraftLib {
|
||||
boolean success = installer.install(minecraftVersion, loaderVersion);
|
||||
|
||||
if (success) {
|
||||
// Сохраняем информацию в Instance
|
||||
// Save info to Instance
|
||||
instance.setMinecraftVersion(minecraftVersion);
|
||||
instance.setLoaderType("fabric");
|
||||
instance.setLoaderVersion(loaderVersion);
|
||||
@@ -61,81 +67,119 @@ public class MinecraftLib {
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная установка сборки (vanilla + loader + моды)
|
||||
* Пока заглушка — будем расширять
|
||||
* Full pack install (vanilla + loader + mods)
|
||||
* Stub - will be expanded
|
||||
*/
|
||||
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Начинается полная установка сборки: " + packName));
|
||||
LauncherLogger.info("Starting full pack install: " + packName);
|
||||
|
||||
// 1. Устанавливаем Minecraft
|
||||
// 1. Install Minecraft
|
||||
boolean mcInstalled = installMinecraft(minecraftVersion);
|
||||
if (!mcInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion));
|
||||
LauncherLogger.error("Failed to install Minecraft " + minecraftVersion);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Устанавливаем лоадер
|
||||
// 2. Install loader
|
||||
if ("fabric".equalsIgnoreCase(loaderType)) {
|
||||
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
|
||||
if (!fabricInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить Fabric"));
|
||||
LauncherLogger.error("Failed to install Fabric");
|
||||
return false;
|
||||
}
|
||||
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
||||
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
|
||||
if (!forgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить Forge"));
|
||||
LauncherLogger.error("Failed to install Forge");
|
||||
return false;
|
||||
}
|
||||
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
|
||||
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
|
||||
if (!neoforgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
|
||||
LauncherLogger.error("Failed to install NeoForge");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. В будущем здесь будет diff и скачивание модов
|
||||
// 3. In the future: diff and mod download
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Базовая установка сборки завершена!"));
|
||||
LauncherLogger.info("Basic pack install complete!");
|
||||
return true;
|
||||
}
|
||||
|
||||
//Запуск
|
||||
//Launch
|
||||
public void launch(LaunchOptions options) throws Exception {
|
||||
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
||||
LauncherLogger.info("Launching pack: " + instance.getName());
|
||||
cleanupOldLoaders();
|
||||
validateJarFiles();
|
||||
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
List<String> command = builder.build(options);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Команда запуска (" + command.size() + " аргументов):"));
|
||||
command.forEach(arg -> System.out.println(" " + arg));
|
||||
LauncherLogger.info("Launch command (" + command.size() + " args) written below");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(instance.getPath().toFile());
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
||||
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
|
||||
pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
|
||||
// Write launch command to file for debugging
|
||||
Path cmdLogFile = instance.getPath().resolve("launch-command.log");
|
||||
try {
|
||||
Files.writeString(cmdLogFile, String.join(" \\\n ", command));
|
||||
LauncherLogger.info("Launch command written to " + cmdLogFile.toAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Failed to write launch command log: " + e.getMessage());
|
||||
}
|
||||
|
||||
LauncherLogger.info("Starting Minecraft...");
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
|
||||
// Capture output
|
||||
Thread outThread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
JFXBridge.appendGameLog(line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXBridge.appendGameLog("[Error reading output: " + e.getMessage() + "]");
|
||||
}
|
||||
});
|
||||
outThread.setDaemon(true);
|
||||
outThread.start();
|
||||
|
||||
// Capture errors
|
||||
Thread errThread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
JFXBridge.appendGameLog("[ERR] " + line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXBridge.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
|
||||
}
|
||||
});
|
||||
errThread.setDaemon(true);
|
||||
errThread.start();
|
||||
|
||||
int exitCode = process.waitFor();
|
||||
outThread.join(1000);
|
||||
errThread.join(1000);
|
||||
|
||||
LauncherLogger.info("Minecraft exited with code: " + exitCode);
|
||||
}
|
||||
|
||||
private void safeDeleteDirectory(Path dir) {
|
||||
try {
|
||||
Files.walk(dir)
|
||||
.sorted((a, b) -> b.compareTo(a))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException ignored) {}
|
||||
});
|
||||
} catch (IOException ignored) {}
|
||||
try (var stream = Files.walk(dir)) {
|
||||
stream.sorted((a, b) -> b.compareTo(a))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException e) { /* ignore */ }
|
||||
});
|
||||
} catch (IOException e) {
|
||||
LauncherLogger.warn("safeDeleteDirectory: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException {
|
||||
@@ -170,9 +214,9 @@ public class MinecraftLib {
|
||||
|
||||
if (currentLoaderVer == null) return;
|
||||
|
||||
System.out.println(ZAnsi.yellow("Выполняем очистку старых версий лоадера..."));
|
||||
LauncherLogger.info("Cleaning old loader versions...");
|
||||
|
||||
// Удаляем все старые fabric-loader / forge
|
||||
// Delete all old fabric-loader / forge
|
||||
Path libraries = instance.getPath().resolve("libraries");
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
@@ -190,6 +234,40 @@ 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;
|
||||
}
|
||||
+836
@@ -0,0 +1,836 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
public class PackDownloader {
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ProgressCallback {
|
||||
void onProgress(String label, int percent, String stageName, int stageIndex, int stageCount);
|
||||
}
|
||||
|
||||
private static final String USER_FILES_FILE = ".user-files.json";
|
||||
|
||||
private final Instance instance;
|
||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
private ProgressCallback progressCallback;
|
||||
//private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
|
||||
|
||||
public PackDownloader(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public void setProgressCallback(ProgressCallback callback) {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
private void reportProgress(String label, int percent, String stageName, int stageIndex, int stageCount) {
|
||||
if (progressCallback != null) {
|
||||
try {
|
||||
progressCallback.onProgress(label, percent, stageName, stageIndex, stageCount);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available packs from server
|
||||
*/
|
||||
public List<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/<mc>/<mc>.json -> assetIndex.id). We only trust it when the
|
||||
* corresponding index file actually exists on disk; otherwise fall back to
|
||||
* the manifest value.
|
||||
*/
|
||||
private String resolveAssetIndex(PackManifest manifest) {
|
||||
String manifestIndex = manifest.getAssetIndex();
|
||||
if (manifest == null || manifest.getMinecraftVersion() == null) {
|
||||
return manifestIndex;
|
||||
}
|
||||
try {
|
||||
Path versionJson = instance.getPath().resolve("versions")
|
||||
.resolve(manifest.getMinecraftVersion())
|
||||
.resolve(manifest.getMinecraftVersion() + ".json");
|
||||
if (Files.exists(versionJson)) {
|
||||
JsonObject versionData = JsonParser.parseString(Files.readString(versionJson)).getAsJsonObject();
|
||||
String realIndex = null;
|
||||
if (versionData.has("assetIndex") && versionData.get("assetIndex").isJsonObject()
|
||||
&& versionData.getAsJsonObject("assetIndex").has("id")) {
|
||||
realIndex = versionData.getAsJsonObject("assetIndex").get("id").getAsString();
|
||||
} else if (versionData.has("assets") && !versionData.get("assets").isJsonNull()) {
|
||||
realIndex = versionData.get("assets").getAsString();
|
||||
}
|
||||
if (realIndex != null && !realIndex.isEmpty()) {
|
||||
Path indexPath = instance.getPath().resolve("assets").resolve("indexes")
|
||||
.resolve(realIndex + ".json");
|
||||
if (Files.exists(indexPath)) {
|
||||
if (!realIndex.equals(manifestIndex)) {
|
||||
LauncherLogger.warn("resolveAssetIndex: manifest asset_index=" + manifestIndex
|
||||
+ " but real index=" + realIndex + " exists on disk, using real index");
|
||||
}
|
||||
return realIndex;
|
||||
} else {
|
||||
LauncherLogger.warn("resolveAssetIndex: real index=" + realIndex
|
||||
+ " from version JSON, but " + indexPath + " does not exist; falling back to manifest=" + manifestIndex);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LauncherLogger.warn("resolveAssetIndex: local version JSON not found: " + versionJson);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("resolveAssetIndex: failed to resolve: " + e.getClass().getSimpleName() + " - " + e.getMessage());
|
||||
}
|
||||
return manifestIndex;
|
||||
}
|
||||
|
||||
public static class DiffResponse {
|
||||
private int version;
|
||||
private List<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; }
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class FabricInstaller {
|
||||
|
||||
private final Instance instance;
|
||||
|
||||
public FabricInstaller(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public boolean install(String minecraftVersion, String loaderVersion) throws Exception {
|
||||
LauncherLogger.info("Installing Fabric " + loaderVersion + " for Minecraft " + minecraftVersion);
|
||||
|
||||
Path instancePath = instance.getPath();
|
||||
cleanOldFabricLoaders();
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(instancePath);
|
||||
String assetIndex = versionInstaller.install(minecraftVersion);
|
||||
|
||||
LauncherLogger.info("Asset index obtained: " + assetIndex);
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
instance.setMinecraftVersion(minecraftVersion);
|
||||
|
||||
ProgressBar.showIndeterminate("Preparing Fabric installer");
|
||||
|
||||
String installerVersion = getLatestInstallerVersion();
|
||||
String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/"
|
||||
+ installerVersion + "/fabric-installer-" + installerVersion + ".jar";
|
||||
|
||||
Path installerJar = instancePath.resolve("fabric-installer.jar");
|
||||
|
||||
if (!Files.exists(installerJar)) {
|
||||
ProgressBar.showIndeterminate("Downloading Fabric Installer");
|
||||
downloadFileWithFallback(installerUrl, installerJar);
|
||||
ProgressBar.finish("Fabric Installer downloaded");
|
||||
}
|
||||
|
||||
preDownloadFabricLibraries(minecraftVersion, loaderVersion);
|
||||
|
||||
LauncherLogger.info("Running Fabric Installer...");
|
||||
ProgressBar.showIndeterminate("Running Fabric Installer (may take a few minutes)");
|
||||
|
||||
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
JavaResolver.getJavaExecutable(), "-jar", installerJar.toAbsolutePath().toString(),
|
||||
"client",
|
||||
"-dir", instancePath.toAbsolutePath().toString(),
|
||||
"-mcversion", minecraftVersion,
|
||||
"-loader", loaderVersion,
|
||||
"-noprofile"
|
||||
);
|
||||
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
boolean hasErrors = false;
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.contains("Downloading") || line.contains("Extracting")
|
||||
|| line.contains("Installing") || line.contains("Creating")
|
||||
|| line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
ProgressBar.showIndeterminate("Fabric Installer: " + ProgressBar.cleanLine(line));
|
||||
}
|
||||
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
LauncherLogger.info(" -> " + line);
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
LauncherLogger.info(" + " + line);
|
||||
} else if (line.contains("WARNING") || line.contains("warning")) {
|
||||
LauncherLogger.warn(" ! " + line);
|
||||
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
|
||||
LauncherLogger.error(" X " + line);
|
||||
if (line.contains("timed out") || line.contains("failed to download")) {
|
||||
hasErrors = true;
|
||||
}
|
||||
} else if (!line.isBlank()) {
|
||||
LauncherLogger.info(" " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int exitCode;
|
||||
if (hasErrors) {
|
||||
process.destroyForcibly();
|
||||
exitCode = 1;
|
||||
} else {
|
||||
if (!process.waitFor(10, TimeUnit.MINUTES)) {
|
||||
process.destroyForcibly();
|
||||
LauncherLogger.error("Fabric Installer timed out after 10 minutes");
|
||||
return false;
|
||||
}
|
||||
exitCode = process.exitValue();
|
||||
}
|
||||
|
||||
if (exitCode != 0) {
|
||||
LauncherLogger.error("Fabric Installer failed (code " + exitCode + ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId);
|
||||
|
||||
if (Files.exists(fabricVersionDir)) {
|
||||
LauncherLogger.info("Fabric installed successfully!");
|
||||
|
||||
instance.setLoaderType("fabric");
|
||||
instance.setLoaderVersion(loaderVersion);
|
||||
instance.setFabricVersionId(fabricVersionId);
|
||||
|
||||
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
|
||||
|
||||
ZHttpClient.repairMissingLibraries(instancePath);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
LauncherLogger.error("Fabric Installer ran, but version not found.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void downloadFileWithFallback(String url, Path target) throws Exception {
|
||||
try {
|
||||
ZHttpClient.downloadFile(url, target);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Failed to download Fabric Installer: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-downloads the Fabric loader jar and its dependency manifest through the smart proxy
|
||||
* so the installer subprocess finds them already on disk and skips direct downloads.
|
||||
* Non-fatal: if this fails the subprocess will still try to download itself.
|
||||
*/
|
||||
private void preDownloadFabricLibraries(String minecraftVersion, String loaderVersion) {
|
||||
try {
|
||||
Path libRoot = instance.getPath().resolve("libraries");
|
||||
String loaderVersionPath = "net/fabricmc/fabric-loader/" + loaderVersion;
|
||||
|
||||
Path loaderJson = libRoot.resolve(loaderVersionPath).resolve("fabric-loader-" + loaderVersion + ".json");
|
||||
if (!Files.exists(loaderJson)) {
|
||||
Files.createDirectories(loaderJson.getParent());
|
||||
ZHttpClient.downloadFileWithSmartProxy(
|
||||
"https://maven.fabricmc.net/" + loaderVersionPath + "/fabric-loader-" + loaderVersion + ".json",
|
||||
loaderJson);
|
||||
}
|
||||
|
||||
Path loaderJar = libRoot.resolve(loaderVersionPath).resolve("fabric-loader-" + loaderVersion + ".jar");
|
||||
if (!Files.exists(loaderJar)) {
|
||||
ZHttpClient.downloadFileWithSmartProxy(
|
||||
"https://maven.fabricmc.net/" + loaderVersionPath + "/fabric-loader-" + loaderVersion + ".jar",
|
||||
loaderJar);
|
||||
}
|
||||
|
||||
ZHttpClient.repairLibrariesFromJson(loaderJson, instance.getPath());
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Pre-download of Fabric libraries incomplete: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException {
|
||||
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
|
||||
|
||||
if (!Files.exists(versionJson)) {
|
||||
LauncherLogger.warn("Version JSON file not found: " + versionJson);
|
||||
return;
|
||||
}
|
||||
|
||||
String content = Files.readString(versionJson);
|
||||
|
||||
// Check and fix asset index
|
||||
if (!content.contains("\"assets\":\"" + assetIndex + "\"")) {
|
||||
LauncherLogger.warn("Fixing asset index in version JSON file...");
|
||||
|
||||
// Replace assets with correct value
|
||||
content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\"");
|
||||
|
||||
// Also check assetIndex
|
||||
if (content.contains("\"assetIndex\"")) {
|
||||
content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\"");
|
||||
}
|
||||
|
||||
Files.writeString(versionJson, content);
|
||||
LauncherLogger.info("Asset index fixed to: " + assetIndex);
|
||||
} else {
|
||||
LauncherLogger.info("Asset index in version JSON is correct: " + assetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanOldFabricLoaders() throws IOException {
|
||||
Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader");
|
||||
if (!Files.exists(librariesDir)) return;
|
||||
|
||||
LauncherLogger.warn("Cleaning old Fabric Loader versions...");
|
||||
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(Files::isDirectory)
|
||||
.filter(dir -> dir.getFileName().toString().matches("\\d+\\.\\d+\\.\\d+.*"))
|
||||
.forEach(dir -> {
|
||||
try {
|
||||
Files.walk(dir)
|
||||
.sorted((a,b) -> b.compareTo(a))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException ignored) {}
|
||||
});
|
||||
} catch (IOException ignored) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private String getLatestInstallerVersion() throws Exception {
|
||||
try {
|
||||
// Use ZHttpClient with smart proxy
|
||||
String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml");
|
||||
int start = xml.indexOf("<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);
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ForgeInstaller {
|
||||
|
||||
private final Instance instance;
|
||||
|
||||
public ForgeInstaller(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public boolean install(String mcVersion, String forgeVersion) throws Exception {
|
||||
LauncherLogger.info("Installing Forge " + forgeVersion + " for Minecraft " + mcVersion);
|
||||
|
||||
// Step 1: Install vanilla and get real assetIndex
|
||||
LauncherLogger.info("Installing base Minecraft version " + mcVersion + "...");
|
||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||
|
||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
LauncherLogger.error("Failed to install base Minecraft version");
|
||||
return false;
|
||||
}
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
|
||||
// Step 2: Create launcher_profiles.json
|
||||
createLauncherProfile();
|
||||
|
||||
// Step 3: Download Forge Installer with progress bar
|
||||
String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/"
|
||||
+ mcVersion + "-" + forgeVersion
|
||||
+ "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar";
|
||||
|
||||
Path installerJar = instance.getPath().resolve("forge-installer.jar");
|
||||
|
||||
LauncherLogger.info("Downloading Forge Installer...");
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
// Step 4: Pre-download the libraries the installer subprocess would fetch from
|
||||
// maven.minecraftforge.net directly. On blocked networks that subprocess cannot
|
||||
// reach the maven host, so we seed every library (version.json + install_profile.json)
|
||||
// through the launcher smart proxy first. The installer reuses files whose checksums
|
||||
// validate ("File exists: Checksum validated"), so no direct downloads are needed.
|
||||
LauncherLogger.info("Prefetching Forge libraries via smart proxy...");
|
||||
int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath());
|
||||
LauncherLogger.info("Prefetched " + prefetched + " Forge libraries");
|
||||
|
||||
// Step 5: Run Forge Installer and show its output
|
||||
LauncherLogger.info("Running Forge Installer...");
|
||||
LauncherLogger.info("This may take a few minutes. Please wait...");
|
||||
|
||||
boolean success = runForgeInstaller(installerJar);
|
||||
|
||||
// After successful Forge install, before saving metadata
|
||||
if (success) {
|
||||
// Download missing libraries
|
||||
try {
|
||||
downloadMissingLibraries(mcVersion, forgeVersion);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Warning: could not download some libraries: " + e.getMessage());
|
||||
}
|
||||
|
||||
LauncherLogger.info("\nForge " + forgeVersion + " installed successfully!");
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType("forge");
|
||||
instance.setLoaderVersion(forgeVersion);
|
||||
|
||||
// Clean up temporary installer file
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
LauncherLogger.error("\nError installing Forge!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void createLauncherProfile() throws IOException {
|
||||
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
|
||||
if (Files.exists(profilePath)) return;
|
||||
|
||||
String minimalProfile = """
|
||||
{
|
||||
"profiles": {},
|
||||
"selectedProfile": "Default"
|
||||
}
|
||||
""";
|
||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
LauncherLogger.info("Created launcher_profiles.json");
|
||||
}
|
||||
|
||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||
ProgressBar.show("Downloading Forge Installer", 0, 100, "%");
|
||||
ZHttpClient.downloadFileWithSmartProxy(url, target);
|
||||
ProgressBar.finish("Forge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
|
||||
}
|
||||
|
||||
private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException {
|
||||
// Try up to 3 times with different options
|
||||
int maxRetries = 3;
|
||||
int attempt = 1;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
LauncherLogger.info("Attempt " + attempt + " of " + maxRetries);
|
||||
|
||||
// Re-seed libraries each attempt: the installer may have left partial files,
|
||||
// and prefetch deletes any with a bad checksum before re-downloading via proxy.
|
||||
int seeded = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath());
|
||||
if (seeded > 0) {
|
||||
LauncherLogger.info(" Prefetched " + seeded + " libraries for this attempt");
|
||||
}
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
JavaResolver.getJavaExecutable(),
|
||||
"-jar",
|
||||
installerJar.toAbsolutePath().toString(),
|
||||
"--installClient",
|
||||
instance.getPath().toAbsolutePath().toString()
|
||||
);
|
||||
|
||||
// Add JVM args for increased timeouts
|
||||
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
|
||||
|
||||
pb.directory(instance.getPath().toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
// Read output in real time
|
||||
StringBuilder output = new StringBuilder();
|
||||
boolean hasErrors = false;
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
|
||||
// Format Forge Installer output
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
LauncherLogger.info(" -> " + line);
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
LauncherLogger.info(" + " + line);
|
||||
} else if (line.contains("WARNING") || line.contains("warning")) {
|
||||
LauncherLogger.warn(" ! " + line);
|
||||
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
|
||||
LauncherLogger.error(" X " + line);
|
||||
if (line.contains("timed out") || line.contains("failed to download")) {
|
||||
hasErrors = true;
|
||||
}
|
||||
} else if (!line.isBlank()) {
|
||||
LauncherLogger.info(" " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int exitCode;
|
||||
if (hasErrors) {
|
||||
process.destroyForcibly();
|
||||
exitCode = 1;
|
||||
} else {
|
||||
if (!process.waitFor(10, TimeUnit.MINUTES)) {
|
||||
process.destroyForcibly();
|
||||
LauncherLogger.error("Forge Installer timed out after 10 minutes");
|
||||
if (attempt < maxRetries) {
|
||||
attempt++;
|
||||
Thread.sleep(5000);
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
exitCode = process.exitValue();
|
||||
}
|
||||
|
||||
// If successful or no download errors
|
||||
if (exitCode == 0 && !hasErrors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If error and not last attempt
|
||||
if (attempt < maxRetries) {
|
||||
LauncherLogger.warn("Install error. Retrying in 5 seconds...");
|
||||
Thread.sleep(5000);
|
||||
// Prefetch at the top of the next attempt already re-seeds any library that
|
||||
// was left partial or corrupted (SHA-1 mismatch -> delete + re-download via proxy).
|
||||
} else {
|
||||
LauncherLogger.error("Forge Installer exited with error code: " + exitCode);
|
||||
|
||||
// Show possible solution
|
||||
if (output.toString().contains("timed out")) {
|
||||
LauncherLogger.warn("\nPossible solutions:");
|
||||
LauncherLogger.warn("1. Check your internet connection");
|
||||
LauncherLogger.warn("2. Run the launcher as administrator");
|
||||
LauncherLogger.warn("3. Temporarily disable antivirus/firewall");
|
||||
LauncherLogger.warn("4. Try installing a different Forge version");
|
||||
}
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
|
||||
LauncherLogger.info("Checking and downloading missing libraries...");
|
||||
ZHttpClient.repairMissingLibraries(instance.getPath());
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ModLoaderInstaller {
|
||||
|
||||
public enum LoaderType {
|
||||
FORGE("forge", "net.minecraftforge", "forge", "https://maven.minecraftforge.net"),
|
||||
NEOFORGE("neoforge", "net.neoforged", null, "https://maven.neoforged.net/releases");
|
||||
|
||||
final String loaderName;
|
||||
final String mavenGroup;
|
||||
final String fixedArtifact;
|
||||
final String mavenBase;
|
||||
|
||||
LoaderType(String loaderName, String mavenGroup, String fixedArtifact, String mavenBase) {
|
||||
this.loaderName = loaderName;
|
||||
this.mavenGroup = mavenGroup;
|
||||
this.fixedArtifact = fixedArtifact;
|
||||
this.mavenBase = mavenBase;
|
||||
}
|
||||
|
||||
public String artifact(String mcVersion) {
|
||||
if (this == NEOFORGE && !"1.20.1".equals(mcVersion)) return "neoforge";
|
||||
return "forge";
|
||||
}
|
||||
}
|
||||
|
||||
private final Instance instance;
|
||||
|
||||
public ModLoaderInstaller(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public boolean install(String mcVersion, String loaderVersion, LoaderType type) throws Exception {
|
||||
LauncherLogger.info("Installing " + type.loaderName + " " + loaderVersion + " for Minecraft " + mcVersion);
|
||||
|
||||
LauncherLogger.info("Installing base Minecraft version " + mcVersion + "...");
|
||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
LauncherLogger.error("Failed to install base Minecraft version");
|
||||
return false;
|
||||
}
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
createLauncherProfile();
|
||||
|
||||
ProgressBar.showIndeterminate("Preparing " + type.loaderName + " installer");
|
||||
|
||||
String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
|
||||
String jarName = type.loaderName + "-installer.jar";
|
||||
Path installerJar = instance.getPath().resolve(jarName);
|
||||
|
||||
LauncherLogger.info("Downloading " + type.loaderName + " Installer...");
|
||||
ProgressBar.showIndeterminate("Downloading " + type.loaderName + " Installer");
|
||||
ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar);
|
||||
|
||||
LauncherLogger.info("Running " + type.loaderName + " Installer...");
|
||||
LauncherLogger.info("This may take a few minutes. Please wait...");
|
||||
ProgressBar.showIndeterminate("Running " + type.loaderName + " Installer (may take a few minutes)");
|
||||
|
||||
boolean success = runInstaller(installerJar, type);
|
||||
|
||||
if (success) {
|
||||
try {
|
||||
downloadMissingLibraries(type);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Warning: could not download some libraries: " + e.getMessage());
|
||||
}
|
||||
|
||||
LauncherLogger.info("\n" + type.loaderName + " " + loaderVersion + " installed successfully!");
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType(type.loaderName);
|
||||
instance.setLoaderVersion(loaderVersion);
|
||||
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
LauncherLogger.error("\nError installing " + type.loaderName + "!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildInstallerUrl(String mcVersion, String loaderVersion, LoaderType type) {
|
||||
if (type == LoaderType.FORGE) {
|
||||
return type.mavenBase + "/" + type.mavenGroup.replace('.', '/')
|
||||
+ "/" + type.fixedArtifact + "/" + mcVersion + "-" + loaderVersion
|
||||
+ "/" + type.fixedArtifact + "-" + mcVersion + "-" + loaderVersion + "-installer.jar";
|
||||
}
|
||||
|
||||
String artifact = type.artifact(mcVersion);
|
||||
return type.mavenBase + "/" + type.mavenGroup.replace('.', '/')
|
||||
+ "/" + artifact + "/" + loaderVersion
|
||||
+ "/" + artifact + "-" + loaderVersion + "-installer.jar";
|
||||
}
|
||||
|
||||
private void createLauncherProfile() throws IOException {
|
||||
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
|
||||
if (Files.exists(profilePath)) return;
|
||||
|
||||
String minimalProfile = """
|
||||
{
|
||||
"profiles": {},
|
||||
"selectedProfile": "Default"
|
||||
}
|
||||
""";
|
||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
LauncherLogger.info("Created launcher_profiles.json");
|
||||
}
|
||||
|
||||
private boolean runInstaller(Path installerJar, LoaderType type) throws IOException, InterruptedException {
|
||||
int maxRetries = 3;
|
||||
int attempt = 1;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
LauncherLogger.info("Attempt " + attempt + " of " + maxRetries);
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
JavaResolver.getJavaExecutable(),
|
||||
"-jar",
|
||||
installerJar.toAbsolutePath().toString(),
|
||||
"--installClient",
|
||||
instance.getPath().toAbsolutePath().toString()
|
||||
);
|
||||
|
||||
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
|
||||
pb.directory(instance.getPath().toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
StringBuilder output = new StringBuilder();
|
||||
boolean hasErrors = false;
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
|
||||
if (line.contains("Downloading") || line.contains("Extracting")
|
||||
|| line.contains("Installing") || line.contains("Creating")
|
||||
|| line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
ProgressBar.showIndeterminate(type.loaderName + " Installer: " + ProgressBar.cleanLine(line));
|
||||
}
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
LauncherLogger.info(" -> " + line);
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
LauncherLogger.info(" + " + line);
|
||||
} else if (line.contains("WARNING") || line.contains("warning")) {
|
||||
LauncherLogger.warn(" ! " + line);
|
||||
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
|
||||
LauncherLogger.error(" X " + line);
|
||||
if (line.contains("timed out") || line.contains("failed to download")) {
|
||||
hasErrors = true;
|
||||
}
|
||||
} else if (!line.isBlank()) {
|
||||
LauncherLogger.info(" " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int exitCode;
|
||||
if (hasErrors) {
|
||||
process.destroyForcibly();
|
||||
exitCode = 1;
|
||||
} else {
|
||||
if (!process.waitFor(10, TimeUnit.MINUTES)) {
|
||||
process.destroyForcibly();
|
||||
LauncherLogger.error(type.loaderName + " Installer timed out after 10 minutes");
|
||||
if (attempt < maxRetries) {
|
||||
attempt++;
|
||||
Thread.sleep(5000);
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
exitCode = process.exitValue();
|
||||
}
|
||||
|
||||
if (exitCode == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
LauncherLogger.warn("Install error. Retrying in 5 seconds...");
|
||||
Thread.sleep(5000);
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar"))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LauncherLogger.error(type.loaderName + " Installer exited with error code: " + exitCode);
|
||||
|
||||
if (output.toString().contains("timed out")) {
|
||||
LauncherLogger.warn("\nPossible solutions:");
|
||||
LauncherLogger.warn("1. Check your internet connection");
|
||||
LauncherLogger.warn("2. Run the launcher as administrator");
|
||||
LauncherLogger.warn("3. Temporarily disable antivirus/firewall");
|
||||
LauncherLogger.warn("4. Try installing a different version");
|
||||
}
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void downloadMissingLibraries(LoaderType type) throws Exception {
|
||||
LauncherLogger.info("Checking and downloading missing libraries...");
|
||||
ProgressBar.showIndeterminate("Downloading missing libraries");
|
||||
ZHttpClient.repairMissingLibraries(instance.getPath());
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class NeoForgeInstaller {
|
||||
|
||||
private final Instance instance;
|
||||
|
||||
public NeoForgeInstaller(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
|
||||
LauncherLogger.info("Installing NeoForge " + neoForgeVersion + " for Minecraft " + mcVersion);
|
||||
|
||||
LauncherLogger.info("Installing base Minecraft version " + mcVersion + "...");
|
||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
LauncherLogger.error("Failed to install base Minecraft version");
|
||||
return false;
|
||||
}
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
createLauncherProfile();
|
||||
|
||||
String mavenGroup = getMavenGroup(mcVersion);
|
||||
String mavenArtifact = getMavenArtifact(mcVersion);
|
||||
|
||||
String installerUrl = "https://maven.neoforged.net/releases/"
|
||||
+ mavenGroup.replace('.', '/') + "/"
|
||||
+ mavenArtifact + "/"
|
||||
+ neoForgeVersion
|
||||
+ "/" + mavenArtifact + "-" + neoForgeVersion + "-installer.jar";
|
||||
|
||||
Path installerJar = instance.getPath().resolve("neoforge-installer.jar");
|
||||
|
||||
LauncherLogger.info("Downloading NeoForge Installer...");
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
LauncherLogger.info("Prefetching NeoForge libraries via smart proxy...");
|
||||
int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath());
|
||||
LauncherLogger.info("Prefetched " + prefetched + " NeoForge libraries");
|
||||
|
||||
LauncherLogger.info("Running NeoForge Installer...");
|
||||
LauncherLogger.info("This may take a few minutes. Please wait...");
|
||||
|
||||
boolean success = runNeoForgeInstaller(installerJar);
|
||||
|
||||
if (success) {
|
||||
try {
|
||||
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Warning: could not download some libraries: " + e.getMessage());
|
||||
}
|
||||
|
||||
LauncherLogger.info("\nNeoForge " + neoForgeVersion + " installed successfully!");
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType("neoforge");
|
||||
instance.setLoaderVersion(neoForgeVersion);
|
||||
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
LauncherLogger.error("\nError installing NeoForge!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String getMavenGroup(String mcVersion) {
|
||||
if (mcVersion.equals("1.20.1")) {
|
||||
return "net.neoforged";
|
||||
}
|
||||
return "net.neoforged";
|
||||
}
|
||||
|
||||
private String getMavenArtifact(String mcVersion) {
|
||||
if (mcVersion.equals("1.20.1")) {
|
||||
return "forge";
|
||||
}
|
||||
return "neoforge";
|
||||
}
|
||||
|
||||
private void createLauncherProfile() throws IOException {
|
||||
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
|
||||
if (Files.exists(profilePath)) return;
|
||||
|
||||
String minimalProfile = """
|
||||
{
|
||||
"profiles": {},
|
||||
"selectedProfile": "Default"
|
||||
}
|
||||
""";
|
||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
LauncherLogger.info("Created launcher_profiles.json");
|
||||
}
|
||||
|
||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||
ProgressBar.show("Downloading NeoForge Installer", 0, 100, "%");
|
||||
ZHttpClient.downloadFileWithSmartProxy(url, target);
|
||||
ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
|
||||
}
|
||||
|
||||
private boolean runNeoForgeInstaller(Path installerJar) throws IOException, InterruptedException {
|
||||
int maxRetries = 3;
|
||||
int attempt = 1;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
LauncherLogger.info("Attempt " + attempt + " of " + maxRetries);
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
JavaResolver.getJavaExecutable(),
|
||||
"-jar",
|
||||
installerJar.toAbsolutePath().toString(),
|
||||
"--installClient",
|
||||
instance.getPath().toAbsolutePath().toString()
|
||||
);
|
||||
|
||||
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
|
||||
pb.directory(instance.getPath().toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
StringBuilder output = new StringBuilder();
|
||||
boolean hasErrors = false;
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
LauncherLogger.info(" -> " + line);
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
LauncherLogger.info(" + " + line);
|
||||
} else if (line.contains("WARNING") || line.contains("warning")) {
|
||||
LauncherLogger.warn(" ! " + line);
|
||||
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
|
||||
LauncherLogger.error(" X " + line);
|
||||
if (line.contains("timed out") || line.contains("failed to download")) {
|
||||
hasErrors = true;
|
||||
}
|
||||
} else if (!line.isBlank()) {
|
||||
LauncherLogger.info(" " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.waitFor(10, TimeUnit.MINUTES)) {
|
||||
process.destroyForcibly();
|
||||
LauncherLogger.error("NeoForge Installer timed out after 10 minutes");
|
||||
return false;
|
||||
}
|
||||
int exitCode = process.exitValue();
|
||||
|
||||
if (exitCode == 0 && !hasErrors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
LauncherLogger.warn("Install error. Retrying in 5 seconds...");
|
||||
Thread.sleep(5000);
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar"))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LauncherLogger.error("NeoForge Installer exited with error code: " + exitCode);
|
||||
|
||||
if (output.toString().contains("timed out")) {
|
||||
LauncherLogger.warn("\nPossible solutions:");
|
||||
LauncherLogger.warn("1. Check your internet connection");
|
||||
LauncherLogger.warn("2. Run the launcher as administrator");
|
||||
LauncherLogger.warn("3. Temporarily disable antivirus/firewall");
|
||||
LauncherLogger.warn("4. Try installing a different NeoForge version");
|
||||
}
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
|
||||
LauncherLogger.info("Checking and downloading missing libraries...");
|
||||
ZHttpClient.repairMissingLibraries(instance.getPath());
|
||||
}
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
public class VersionInstaller {
|
||||
|
||||
private final Path minecraftDir;
|
||||
private final ExecutorService executor = Executors.newFixedThreadPool(32);
|
||||
|
||||
public VersionInstaller(Path minecraftDir) {
|
||||
this.minecraftDir = minecraftDir;
|
||||
}
|
||||
|
||||
public List<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");
|
||||
}
|
||||
}
|
||||
+1054
File diff suppressed because it is too large
Load Diff
+91
-21
@@ -1,5 +1,7 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -15,13 +17,15 @@ 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.getString("mainClass");
|
||||
this.mainClass = json.optString("mainClass", "net.minecraft.client.main.Main");
|
||||
|
||||
if (json.has("assetIndex")) {
|
||||
JSONObject ai = json.getJSONObject("assetIndex");
|
||||
@@ -30,6 +34,7 @@ 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);
|
||||
@@ -38,10 +43,65 @@ 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;
|
||||
@@ -99,8 +159,17 @@ public class VersionManifest {
|
||||
if (rule.has("features")) {
|
||||
JSONObject features = rule.getJSONObject("features");
|
||||
for (String key : features.keySet()) {
|
||||
if (key.startsWith("is_demo_user") || key.startsWith("has_custom_resolution")) continue;
|
||||
if (key.startsWith("has_custom_resolution")) {
|
||||
continue; // Лаунчер сам обрабатывает разрешение
|
||||
}
|
||||
if (key.startsWith("is_demo_user")) {
|
||||
// Лаунчер не использует demo режим, считаем фичу false
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
// Неизвестная фича — считаем false
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,29 +189,30 @@ 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")) {
|
||||
String name = libJson.getString("name");
|
||||
String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path");
|
||||
Library lib = new Library(name, artifactPath);
|
||||
artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path");
|
||||
}
|
||||
Library lib = new Library(name, artifactPath);
|
||||
|
||||
if (libJson.has("natives")) {
|
||||
JSONObject natives = libJson.getJSONObject("natives");
|
||||
for (String key : natives.keySet()) {
|
||||
String osKey = key.toLowerCase();
|
||||
lib.natives.put(osKey, natives.getString(key));
|
||||
}
|
||||
if (libJson.has("natives")) {
|
||||
JSONObject natives = libJson.getJSONObject("natives");
|
||||
for (String key : natives.keySet()) {
|
||||
String osKey = key.toLowerCase();
|
||||
lib.natives.put(osKey, natives.getString(key));
|
||||
}
|
||||
}
|
||||
|
||||
if (libJson.has("rules")) {
|
||||
JSONObject dummyObj = new JSONObject();
|
||||
dummyObj.put("rules", libJson.getJSONArray("rules"));
|
||||
dummyObj.put("value", "");
|
||||
if (ruleMatches(dummyObj)) {
|
||||
libs.add(lib);
|
||||
}
|
||||
} else {
|
||||
libs.add(lib);
|
||||
}
|
||||
boolean allowed = true;
|
||||
if (libJson.has("rules")) {
|
||||
JSONObject dummyObj = new JSONObject();
|
||||
dummyObj.put("rules", libJson.getJSONArray("rules"));
|
||||
dummyObj.put("value", "");
|
||||
allowed = ruleMatches(dummyObj);
|
||||
}
|
||||
if (allowed) {
|
||||
libs.add(lib);
|
||||
}
|
||||
}
|
||||
return libs;
|
||||
+2
@@ -37,5 +37,7 @@ 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; }
|
||||
}
|
||||
+55
-25
@@ -6,6 +6,8 @@ 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 {
|
||||
@@ -14,16 +16,22 @@ 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(true)
|
||||
.jna(isWindows)
|
||||
.jansi(true)
|
||||
.encoding(StandardCharsets.UTF_8)
|
||||
.build();
|
||||
this.rawInput = terminal.input();
|
||||
}
|
||||
|
||||
public int show() throws IOException {
|
||||
@@ -34,33 +42,43 @@ public class ArrowMenu {
|
||||
try {
|
||||
while (true) {
|
||||
printPagedMenu();
|
||||
int key = terminal.reader().read();
|
||||
int b = rawInput.read();
|
||||
if (b == -1) continue;
|
||||
|
||||
if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц'
|
||||
|| key == 'k' || key == 'K' || key == 'л' || key == 'Л') { // Up / Arrow Up
|
||||
// w/W/k/K or ц (0xD1 0x86) = up
|
||||
// s/S/j/J or ы (0xD1 0x8B) = down
|
||||
if (b == 'w' || b == 'W' || b == 'k' || b == 'K') {
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
}
|
||||
else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы'
|
||||
|| key == 'j' || key == 'J' || key == 'о' || key == 'О') { // Down / Arrow Down
|
||||
else if (b == 's' || b == 'S' || b == 'j' || b == 'J') {
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
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
|
||||
// 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 == 66) { // 'B' — Down arrow
|
||||
} else if (arrow == 0x42) { // Down
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
// else — unknown escape seq, ignore
|
||||
} else {
|
||||
return -1; // genuine Esc
|
||||
}
|
||||
}
|
||||
else if (b == 0xD1) {
|
||||
int second = nonBlockingRead();
|
||||
if (second == 0x86) { // ц
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
} else if (second == 0x8B) { // ы
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
}
|
||||
else if (b == 13 || b == 10) {
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
terminal.puts(InfoCmp.Capability.cursor_visible);
|
||||
@@ -68,19 +86,31 @@ 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);
|
||||
}
|
||||
@@ -94,10 +124,10 @@ public class ArrowMenu {
|
||||
}
|
||||
}
|
||||
|
||||
// Подсказка внизу (фиксированная)
|
||||
sb.append("\n")
|
||||
.append(ZAnsi.white("W/S (Ц/Ы) или ↑/↓ - перемещение | Enter - выбрать | Esc - назад"));
|
||||
.append(ZAnsi.white("W/S or \u2191/\u2193 - navigate | Enter - select | Esc - back"));
|
||||
|
||||
System.out.print(sb);
|
||||
System.out.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package me.sashegdev.zernmc.launcher.ui.jfx;
|
||||
|
||||
/**
|
||||
* Safe reflection bridge to JFXLauncher for use from non-JFX code (CLI, menus,
|
||||
* installers).
|
||||
*
|
||||
* IMPORTANT: JFXLauncher extends javafx.application.Application. In CLI mode
|
||||
* JavaFX is NOT on the module path, so loading JFXLauncher (or any class that
|
||||
* references JavaFX) throws NoClassDefFoundError (an Error, not an Exception).
|
||||
* Therefore the bridge must catch Throwable and never let JFX-launcher errors
|
||||
* leak into CLI/game-launch code paths.
|
||||
*
|
||||
* Rule: any call into JFX UI state from non-JFX context MUST go through this
|
||||
* bridge. Direct JFXLauncher.x() calls are only allowed inside JFXLauncher.java.
|
||||
*/
|
||||
public final class JFXBridge {
|
||||
|
||||
private JFXBridge() {}
|
||||
|
||||
private static final String JFX_CLASS = "me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher";
|
||||
|
||||
private static volatile java.lang.reflect.Method appendGameLogMethod;
|
||||
private static volatile java.lang.reflect.Method appendLauncherLogFromLoggerMethod;
|
||||
private static volatile java.lang.reflect.Method setInstallProgressMethod;
|
||||
private static volatile java.lang.reflect.Method setInstallStageMethod;
|
||||
|
||||
private static java.lang.reflect.Method method(String name, Class<?>... params) {
|
||||
java.lang.reflect.Method m = null;
|
||||
try {
|
||||
Class<?> clazz = Class.forName(JFX_CLASS);
|
||||
m = clazz.getMethod(name, params);
|
||||
} catch (Throwable t) {
|
||||
// JavaFX not available (CLI mode) or JFXLauncher not initialized.
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Forward a game log line to the JFX UI. Safe no-op in CLI mode. */
|
||||
public static void appendGameLog(String msg) {
|
||||
if (appendGameLogMethod == null) {
|
||||
appendGameLogMethod = method("appendGameLog", String.class);
|
||||
}
|
||||
if (appendGameLogMethod == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
appendGameLogMethod.invoke(null, msg);
|
||||
} catch (Throwable t) {
|
||||
appendGameLogMethod = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a launcher log line to the JFX UI without re-logging it through
|
||||
* LauncherLogger. Called from LauncherLogger.write so all logged lines
|
||||
* (including installer output) become visible in the UI. Safe no-op in CLI mode.
|
||||
*/
|
||||
public static void appendLauncherLogFromLogger(String msg) {
|
||||
if (appendLauncherLogFromLoggerMethod == null) {
|
||||
appendLauncherLogFromLoggerMethod = method("appendLauncherLogFromLogger", String.class);
|
||||
}
|
||||
if (appendLauncherLogFromLoggerMethod == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
appendLauncherLogFromLoggerMethod.invoke(null, msg);
|
||||
} catch (Throwable t) {
|
||||
appendLauncherLogFromLoggerMethod = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Forward install progress to the JFX UI. Safe no-op in CLI mode. */
|
||||
public static void setInstallProgress(String label, int current, int total) {
|
||||
if (setInstallProgressMethod == null) {
|
||||
setInstallProgressMethod = method("setInstallProgress", String.class, int.class, int.class);
|
||||
}
|
||||
if (setInstallProgressMethod == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setInstallProgressMethod.invoke(null, label, current, total);
|
||||
} catch (Throwable t) {
|
||||
setInstallProgressMethod = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Forward install stage to the JFX UI. Safe no-op in CLI mode. */
|
||||
public static void setInstallStage(String stageName, int stageIndex, int stageCount) {
|
||||
if (setInstallStageMethod == null) {
|
||||
setInstallStageMethod = method("setInstallStage", String.class, int.class, int.class);
|
||||
}
|
||||
if (setInstallStageMethod == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setInstallStageMethod.invoke(null, stageName, stageIndex, stageCount);
|
||||
} catch (Throwable t) {
|
||||
setInstallStageMethod = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
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;
|
||||
private static volatile String onboardedVersion = "";
|
||||
private static volatile String completedOnboardBlocks = "";
|
||||
|
||||
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"));
|
||||
onboardedVersion = props.getProperty("onboardedVersion", "");
|
||||
completedOnboardBlocks = props.getProperty("completedOnboardBlocks", "");
|
||||
|
||||
} 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));
|
||||
props.setProperty("onboardedVersion", onboardedVersion);
|
||||
props.setProperty("completedOnboardBlocks", completedOnboardBlocks);
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
public static String getOnboardedVersion() { return onboardedVersion; }
|
||||
public static void setOnboardedVersion(String v) { onboardedVersion = v != null ? v : ""; save(); }
|
||||
public static String getCompletedOnboardBlocks() { return completedOnboardBlocks; }
|
||||
public static void setCompletedOnboardBlocks(String s) { completedOnboardBlocks = s != null ? s : ""; save(); }
|
||||
}
|
||||
+2
-3
@@ -10,10 +10,9 @@ public class ConsoleUtils {
|
||||
}
|
||||
|
||||
public static void pause() {
|
||||
System.out.print(ZAnsi.white("\nНажмите Enter для продолжения..."));
|
||||
System.out.print(ZAnsi.white("\nPress Enter to continue..."));
|
||||
try {
|
||||
System.in.read();
|
||||
// Очищаем буфер ввода
|
||||
while (System.in.available() > 0) {
|
||||
System.in.read();
|
||||
}
|
||||
@@ -36,4 +35,4 @@ public class ConsoleUtils {
|
||||
public static void separator() {
|
||||
System.out.println(ZAnsi.white("────────────────────────────────────────────────────────────"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Picks the fastest reachable ZernMC API domain at launcher startup.
|
||||
* TSPU/SNI-обход: если основной api.zern.cc недоступен (заблокирован ТСПУ),
|
||||
* пробиваем legacy-домены и гео-зеркала и переключаемся на первый живой/быстрый.
|
||||
*/
|
||||
public final class DomainSelector {
|
||||
|
||||
private static final HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
|
||||
private static final List<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) {
|
||||
}
|
||||
}
|
||||
+11
-30
@@ -3,23 +3,20 @@ package me.sashegdev.zernmc.launcher.utils;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Scanner;
|
||||
|
||||
/**
|
||||
* Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu
|
||||
*/
|
||||
public class Input {
|
||||
|
||||
// Используем UTF-8 явно — это помогает на Windows
|
||||
private static final Scanner scanner = new Scanner(System.in, "UTF-8");
|
||||
private static final Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8);
|
||||
|
||||
public static String readLine() {
|
||||
return scanner.nextLine().trim();
|
||||
}
|
||||
|
||||
public static String readLine(String prompt) {
|
||||
flushInput(); // Очищаем буфер
|
||||
flushInput();
|
||||
System.out.print(prompt);
|
||||
return scanner.nextLine().trim();
|
||||
}
|
||||
@@ -30,7 +27,7 @@ public class Input {
|
||||
System.out.print(prompt);
|
||||
return Integer.parseInt(scanner.nextLine().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
System.out.println(ZAnsi.brightRed("Некорректное число. Попробуйте ещё раз."));
|
||||
System.out.println(ZAnsi.brightRed("Invalid number. Try again."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,57 +38,41 @@ public class Input {
|
||||
if (value >= min && value <= max) {
|
||||
return value;
|
||||
}
|
||||
System.out.println(ZAnsi.brightRed("Значение должно быть от " + min + " до " + max + "."));
|
||||
System.out.println(ZAnsi.brightRed("Value must be between " + min + " and " + max + "."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Новый confirm через ArrowMenu
|
||||
* @throws IOException
|
||||
*/
|
||||
public static boolean confirm(String question) throws IOException {
|
||||
ConsoleUtils.clearScreen(); // опционально, можно убрать
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Да",
|
||||
"Нет"
|
||||
"Yes",
|
||||
"No"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu(question, options);
|
||||
int choice = menu.show();
|
||||
|
||||
return choice == 0; // 0 = "Да"
|
||||
return choice == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Альтернативный confirm без очистки экрана
|
||||
* @throws IOException
|
||||
*/
|
||||
public static boolean confirmInline(String question) throws IOException {
|
||||
List<String> options = List.of("Да", "Нет");
|
||||
List<String> options = List.of("Yes", "No");
|
||||
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,42 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* Resolves the java executable used to run the launcher itself, so child
|
||||
* processes (Forge/NeoForge/Fabric installers) run on the bundled JRE
|
||||
* instead of relying on a system java that may not exist or be too old.
|
||||
*
|
||||
* Order of preference:
|
||||
* 1. The JVM currently running the launcher (bundled jre21 in lib/jre21).
|
||||
* 2. A jre21 next to the working directory (dev layout).
|
||||
* 3. System PATH lookup (last resort).
|
||||
*/
|
||||
public final class JavaResolver {
|
||||
|
||||
private JavaResolver() {}
|
||||
|
||||
public static String getJavaExecutable() {
|
||||
String os = System.getProperty("os.name", "").toLowerCase();
|
||||
String javaExe = os.contains("win") ? "java.exe" : "java";
|
||||
|
||||
Path fromHome = Paths.get(System.getProperty("java.home"), "bin", javaExe);
|
||||
if (Files.exists(fromHome)) {
|
||||
return fromHome.toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
Path bundled = Paths.get("").toAbsolutePath().resolve("lib").resolve("jre21").resolve("bin").resolve(javaExe);
|
||||
if (Files.exists(bundled)) {
|
||||
return bundled.toString();
|
||||
}
|
||||
|
||||
Path devJre = Paths.get("").toAbsolutePath().resolve("jre21").resolve("bin").resolve(javaExe);
|
||||
if (Files.exists(devJre)) {
|
||||
return devJre.toString();
|
||||
}
|
||||
|
||||
return javaExe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class LauncherLogger {
|
||||
|
||||
private static Path logFile;
|
||||
private static boolean initialized = false;
|
||||
private static final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
private static final int STDOUT_QUEUE_CAPACITY = 2048;
|
||||
private static final BlockingQueue<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
|
||||
|
||||
import java.text.DecimalFormat;
|
||||
|
||||
/**
|
||||
* Progress reporting for installs/downloads.
|
||||
*
|
||||
* IMPORTANT: never writes directly to System.out/System.err. In JFX mode the
|
||||
* launcher runs under javaw.exe whose stdout pipe is drained by the parent exe;
|
||||
* once that pipe fills (or the exe stops reading), a synchronous
|
||||
* System.out.write/flush blocks the calling (install/UI) thread forever.
|
||||
* All console/progress output must go through LauncherLogger, which is
|
||||
* non-blocking by design (bounded stdout queue + tryLock file write + SSE queue).
|
||||
*/
|
||||
public class ProgressBar {
|
||||
|
||||
private static final DecimalFormat DF = new DecimalFormat("#.##");
|
||||
|
||||
private static String currentLabel = "";
|
||||
private static long currentTotal = 0;
|
||||
|
||||
private static final long DOWNLOAD_LOG_INTERVAL_MS = 500;
|
||||
private static volatile long lastDownloadLogMs = 0;
|
||||
private static volatile long lastIndeterminateLogMs = 0;
|
||||
|
||||
public static void show(String label, long current, long total, String unit) {
|
||||
currentLabel = label;
|
||||
currentTotal = total;
|
||||
|
||||
JFXBridge.setInstallProgress(label, (int) current, (int) total);
|
||||
|
||||
if (total <= 0) {
|
||||
LauncherLogger.info(label + " ...");
|
||||
return;
|
||||
}
|
||||
int percent = (int) ((double) current / total * 100);
|
||||
LauncherLogger.info(label + " " + percent + "% (" + current + "/" + total + " " + unit + ")");
|
||||
}
|
||||
|
||||
public static void showDownload(String label, long downloaded, long totalBytes) {
|
||||
currentLabel = label;
|
||||
currentTotal = totalBytes;
|
||||
|
||||
JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
|
||||
|
||||
// Throttle console/file logging: called once per chunk during downloads.
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||
lastDownloadLogMs = now;
|
||||
LauncherLogger.info(label + " " + formatBytes(downloaded)
|
||||
+ (totalBytes > 0 ? "/" + formatBytes(totalBytes) : ""));
|
||||
}
|
||||
}
|
||||
|
||||
public static void showAnimated(String label, long current, long total, String unit) {
|
||||
currentLabel = label;
|
||||
currentTotal = total;
|
||||
|
||||
JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100));
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||
lastDownloadLogMs = now;
|
||||
LauncherLogger.info(label + ": " + formatBytes(current)
|
||||
+ (total > 0 ? "/" + formatBytes(total) + " " + unit : ""));
|
||||
}
|
||||
}
|
||||
|
||||
public static void finish(String message) {
|
||||
LauncherLogger.info(message + " done");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a stage without a known total (e.g. running the Forge/NeoForge/Fabric
|
||||
* installer subprocess). Always updates the UI label so the user sees what the
|
||||
* launcher is doing right now; console/file logging is throttled to avoid spam.
|
||||
*/
|
||||
public static void showIndeterminate(String label) {
|
||||
currentLabel = label;
|
||||
currentTotal = 0;
|
||||
|
||||
JFXBridge.setInstallProgress(label, 0, 0);
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastIndeterminateLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||
lastIndeterminateLogMs = now;
|
||||
LauncherLogger.info(label + " ...");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips ANSI codes, indentation and [category] prefixes from a child-process
|
||||
* output line so it can be used as a short, human-readable progress label.
|
||||
*/
|
||||
public static String cleanLine(String line) {
|
||||
if (line == null) return "";
|
||||
String s = line.replaceAll("\\u001B\\[[;\\d]*m", "").trim();
|
||||
int bracket = s.indexOf("] ");
|
||||
if (s.startsWith("[") && bracket > 0) {
|
||||
s = s.substring(bracket + 2).trim();
|
||||
}
|
||||
if (s.length() > 60) {
|
||||
s = s.substring(0, 57) + "...";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public static void setStage(String stageName, int stageIndex, int stageCount) {
|
||||
JFXBridge.setInstallStage(stageName, stageIndex, stageCount);
|
||||
}
|
||||
|
||||
public static void clearLine() {
|
||||
// No-op: console progress now goes through LauncherLogger.
|
||||
}
|
||||
|
||||
public static String formatBytes(long bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB";
|
||||
return DF.format(bytes / (1024.0 * 1024)) + " MB";
|
||||
}
|
||||
}
|
||||
+28
-10
@@ -33,24 +33,42 @@ public class Version {
|
||||
|
||||
public static boolean isNewer(String current, String server) {
|
||||
if (current == null || server == null) return false;
|
||||
|
||||
current = current.replace("-SNAPSHOT", "").trim();
|
||||
server = server.replace("-SNAPSHOT", "").trim();
|
||||
|
||||
|
||||
// Нормализуем версии - убираем суффиксы типа -any, -alpha, -beta, -SNAPSHOT
|
||||
current = normalizeVersion(current);
|
||||
server = normalizeVersion(server);
|
||||
|
||||
if (current.equals(server)) return false;
|
||||
|
||||
|
||||
String[] cParts = current.split("\\.");
|
||||
String[] sParts = server.split("\\.");
|
||||
|
||||
|
||||
int max = Math.max(cParts.length, sParts.length);
|
||||
|
||||
|
||||
for (int i = 0; i < max; i++) {
|
||||
int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0;
|
||||
int s = i < sParts.length ? Integer.parseInt(sParts[i]) : 0;
|
||||
|
||||
int c = i < cParts.length ? parseVersionPart(cParts[i]) : 0;
|
||||
int s = i < sParts.length ? parseVersionPart(sParts[i]) : 0;
|
||||
|
||||
if (s > c) return true;
|
||||
if (s < c) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalizeVersion(String version) {
|
||||
if (version == null) return "0.0.0";
|
||||
|
||||
// Убираем суффиксы: -any, -alpha1, -beta2, -SNAPSHOT, -rc1 и т.д.
|
||||
return version.split("-")[0].split("\\+")[0].trim();
|
||||
}
|
||||
|
||||
private static int parseVersionPart(String part) {
|
||||
try {
|
||||
// Убираем всё, что не является цифрой (на случай если суффикс остался)
|
||||
String numeric = part.replaceAll("[^0-9]", "");
|
||||
return numeric.isEmpty() ? 0 : Integer.parseInt(numeric);
|
||||
} catch (Exception e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,654 @@
|
||||
<!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 id="local-mods-section" class="mods-section hidden">
|
||||
<div class="section-header">
|
||||
<span data-i18n="mods.title">Mods</span>
|
||||
<span id="mods-count" class="mods-count"></span>
|
||||
<input type="text" id="mods-search" class="mods-search" placeholder="Search mods..." data-i18n-placeholder="mods.search" oninput="app.filterLocalMods()">
|
||||
</div>
|
||||
<div id="local-mods-list" class="mods-list">
|
||||
<div class="mods-loading" data-i18n="mods.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 class="setting-card" id="onboard-settings-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="onboard.showAgain">Show tutorial again</h4>
|
||||
<p data-i18n="onboard.infoDesc">You can replay the tutorial in Settings.</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button class="btn-primary btn-sm" id="replay-onboard-btn" onclick="app.replayOnboarding()"><span data-i18n="onboard.showAgain">Show tutorial again</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()">×</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()">×</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">×</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></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="field">
|
||||
<label data-i18n="install.custom.name">Pack Name</label>
|
||||
<input type="text" id="custom-instance-name" placeholder="MyPack" maxlength="32">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label data-i18n="install.custom.mcVersion">Minecraft Version</label>
|
||||
<select id="mc-version-select"><option>Loading...</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label data-i18n="install.custom.loader">Loader</label>
|
||||
<select id="loader-select">
|
||||
<option value="vanilla">Vanilla</option>
|
||||
<option value="fabric">Fabric</option>
|
||||
<option value="forge">Forge</option>
|
||||
<option value="neoforge">NeoForge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field hidden" id="loader-ver-field">
|
||||
<label data-i18n="install.custom.loaderVersion">Loader Version</label>
|
||||
<select id="loader-ver-select"><option data-i18n="select.selectMCFirst">Select MC version first</option></select>
|
||||
</div>
|
||||
<button id="install-custom-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
|
||||
</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
File diff suppressed because it is too large
Load Diff
+274
@@ -0,0 +1,274 @@
|
||||
package me.sashegdev.zernmc.launcher.auth;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
class AuthManagerPassTest {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private static AuthManager.AuthSession createSession(String token, int role) {
|
||||
AuthManager.AuthSession s = new AuthManager.AuthSession();
|
||||
s.accessToken = token;
|
||||
s.role = role;
|
||||
s.expiresAt = System.currentTimeMillis() / 1000L + 3600;
|
||||
return s;
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_returnsFalse_whenNotLoggedIn() {
|
||||
AuthManager.resetForTest();
|
||||
assertFalse(AuthManager.hasPass());
|
||||
assertFalse(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_usesUserInfo_whenAvailable() {
|
||||
AuthManager.UserInfo info = new AuthManager.UserInfo();
|
||||
info.has_pass = true;
|
||||
info.role = 1;
|
||||
AuthManager.setTestUserInfo(info);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", 1));
|
||||
|
||||
assertTrue(AuthManager.hasPass());
|
||||
assertTrue(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_usesRole_whenUserInfoNull() {
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
|
||||
|
||||
assertTrue(AuthManager.hasPass());
|
||||
assertTrue(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_returnsFalse_whenRoleTooLow() {
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_USER));
|
||||
|
||||
assertFalse(AuthManager.hasPass());
|
||||
assertFalse(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_userInfoTakesPriorityOverRole() {
|
||||
AuthManager.UserInfo info = new AuthManager.UserInfo();
|
||||
info.has_pass = false;
|
||||
info.role = 1;
|
||||
AuthManager.setTestUserInfo(info);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
|
||||
|
||||
assertFalse(AuthManager.hasPass());
|
||||
assertFalse(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canViewPacks_usesPermissions_whenAvailable() {
|
||||
AuthManager.UserInfo info = new AuthManager.UserInfo();
|
||||
info.permissions = List.of("view_packs", "download_pack");
|
||||
info.has_pass = true;
|
||||
AuthManager.setTestUserInfo(info);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", 1));
|
||||
|
||||
assertTrue(AuthManager.canViewPacks());
|
||||
assertTrue(AuthManager.canDownloadPacks());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canViewPacks_fallsBackToHasPass_whenNoPermissions() {
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
|
||||
|
||||
assertTrue(AuthManager.canViewPacks());
|
||||
assertTrue(AuthManager.canDownloadPacks());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authSession_parsesFromLoginResponse() {
|
||||
String json = """
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh",
|
||||
"expires_in": 86400,
|
||||
"token_type": "bearer",
|
||||
"username": "testuser",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role": 1,
|
||||
"role_name": "PASS_HOLDER"
|
||||
}
|
||||
""";
|
||||
AuthManager.AuthSession session = GSON.fromJson(json, AuthManager.AuthSession.class);
|
||||
|
||||
assertNotNull(session);
|
||||
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test", session.accessToken);
|
||||
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh", session.refreshToken);
|
||||
assertEquals(86400, session.expiresIn);
|
||||
assertEquals("testuser", session.username);
|
||||
assertEquals("550e8400-e29b-41d4-a716-446655440000", session.uuid);
|
||||
assertEquals(1, session.role);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authSession_roundTrip() {
|
||||
AuthManager.AuthSession original = new AuthManager.AuthSession();
|
||||
original.accessToken = "access123";
|
||||
original.refreshToken = "refresh123";
|
||||
original.expiresIn = 86400;
|
||||
original.expiresAt = System.currentTimeMillis() / 1000L + 86400;
|
||||
original.username = "testuser";
|
||||
original.uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
original.role = 1;
|
||||
|
||||
String json = GSON.toJson(original);
|
||||
AuthManager.AuthSession parsed = GSON.fromJson(json, AuthManager.AuthSession.class);
|
||||
|
||||
assertEquals(original.accessToken, parsed.accessToken);
|
||||
assertEquals(original.refreshToken, parsed.refreshToken);
|
||||
assertEquals(original.expiresIn, parsed.expiresIn);
|
||||
assertEquals(original.expiresAt, parsed.expiresAt);
|
||||
assertEquals(original.username, parsed.username);
|
||||
assertEquals(original.uuid, parsed.uuid);
|
||||
assertEquals(original.role, parsed.role);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userInfo_parsesFromMeEndpoint() {
|
||||
String json = """
|
||||
{
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role": 1,
|
||||
"role_name": "PASS_HOLDER",
|
||||
"has_pass": true,
|
||||
"permissions": ["view_packs", "download_pack"]
|
||||
}
|
||||
""";
|
||||
AuthManager.UserInfo info = GSON.fromJson(json, AuthManager.UserInfo.class);
|
||||
|
||||
assertNotNull(info);
|
||||
assertEquals(1, info.id);
|
||||
assertEquals("testuser", info.username);
|
||||
assertEquals(1, info.role);
|
||||
assertEquals("PASS_HOLDER", info.role_name);
|
||||
assertTrue(info.has_pass);
|
||||
assertTrue(info.permissions.contains("view_packs"));
|
||||
assertTrue(info.permissions.contains("download_pack"));
|
||||
assertTrue(info.hasPermission("view_packs"));
|
||||
assertFalse(info.hasPermission("admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRole_updatesSessionRole() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestSession(createSession("tok", 0));
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
assertEquals(0, AuthManager.getRole());
|
||||
assertFalse(AuthManager.hasPass());
|
||||
|
||||
AuthManager.updateRole(1);
|
||||
|
||||
assertEquals(1, AuthManager.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLoggedIn_returnsTrue_whenSessionExists() {
|
||||
AuthManager.resetForTest();
|
||||
assertFalse(AuthManager.isLoggedIn());
|
||||
|
||||
AuthManager.AuthSession s = createSession("tok", 0);
|
||||
s.username = "testuser";
|
||||
AuthManager.setTestSession(s);
|
||||
|
||||
assertTrue(AuthManager.isLoggedIn());
|
||||
assertEquals("testuser", AuthManager.getUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUsername_returnsSessionUsername() {
|
||||
AuthManager.AuthSession s = createSession("tok", 0);
|
||||
s.username = "testuser";
|
||||
AuthManager.setTestSession(s);
|
||||
|
||||
assertEquals("testuser", AuthManager.getUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRole_returnsZero_whenSessionNull() {
|
||||
AuthManager.resetForTest();
|
||||
assertEquals(0, AuthManager.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRoleName_fallsBackToUSER_whenUserInfoNull() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestUserInfo(null);
|
||||
AuthManager.setTestSession(createSession("tok", 0));
|
||||
|
||||
assertEquals("USER", AuthManager.getRoleName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessToken_returnsToken_whenSessionValid() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestUserInfo(null);
|
||||
AuthManager.setTestSession(createSession("valid-token", 1));
|
||||
|
||||
String token = AuthManager.getAccessToken();
|
||||
assertEquals("valid-token", token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessToken_doesNotInvalidate_whenNoRefreshToken() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestUserInfo(null);
|
||||
AuthManager.AuthSession s = createSession("tok", 1);
|
||||
s.refreshToken = null;
|
||||
AuthManager.setTestSession(s);
|
||||
|
||||
String token = AuthManager.getAccessToken();
|
||||
assertEquals("tok", token);
|
||||
assertTrue(AuthManager.isLoggedIn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessToken_returnsZero_whenSessionNull() {
|
||||
AuthManager.resetForTest();
|
||||
assertEquals("0", AuthManager.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidateSession_clearsState() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestSession(createSession("tok", 1));
|
||||
AuthManager.setTestUserInfo(new AuthManager.UserInfo());
|
||||
assertTrue(AuthManager.isLoggedIn());
|
||||
|
||||
AuthManager.logout();
|
||||
|
||||
assertFalse(AuthManager.isLoggedIn());
|
||||
assertEquals(0, AuthManager.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSavedSession_returnsFalse_whenNoAuthFile() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.logout();
|
||||
assertFalse(AuthManager.loadSavedSession());
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package me.sashegdev.zernmc.launcher.integration;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.ModLoaderInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.net.URL;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
@Tag("integration")
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class ForgeInstallIntegrationTest {
|
||||
|
||||
private static final String MC_VERSION = "1.20.1";
|
||||
private static final String FORGE_VERSION = "47.4.22";
|
||||
|
||||
private static Path instanceDir;
|
||||
private static Instance instance;
|
||||
private static boolean installSuccess = false;
|
||||
|
||||
@BeforeAll
|
||||
static void setup() throws Exception {
|
||||
// Check network
|
||||
assumeTrue(networkReachable("https://maven.minecraftforge.net"),
|
||||
"Forge maven not reachable, skipping");
|
||||
assumeTrue(networkReachable("https://piston-meta.mojang.com"),
|
||||
"Mojang meta not reachable, skipping");
|
||||
|
||||
instanceDir = Files.createTempDirectory("zernmc-forge-test-");
|
||||
instance = new Instance("forge-test", instanceDir);
|
||||
instance.setMinecraftVersion(MC_VERSION);
|
||||
instance.setLoaderType("forge");
|
||||
instance.setLoaderVersion(FORGE_VERSION);
|
||||
instance.setAssetIndex(MC_VERSION);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void cleanup() throws Exception {
|
||||
if (instanceDir != null) {
|
||||
try (var stream = Files.walk(instanceDir)) {
|
||||
stream.sorted((a, b) -> b.compareTo(a))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); } catch (Exception ignored) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
@DisplayName("Install Minecraft vanilla 1.20.1")
|
||||
void installMinecraft() throws Exception {
|
||||
System.out.println("\n=== Installing Minecraft " + MC_VERSION + " ===");
|
||||
VersionInstaller versionInstaller = new VersionInstaller(instanceDir);
|
||||
String assetIndex = versionInstaller.install(MC_VERSION);
|
||||
assertNotNull(assetIndex, "assetIndex must not be null after install");
|
||||
instance.setAssetIndex(assetIndex);
|
||||
|
||||
// Verify version.json exists
|
||||
Path versionJson = instanceDir.resolve("versions/" + MC_VERSION + "/" + MC_VERSION + ".json");
|
||||
assertTrue(Files.exists(versionJson), "version.json must exist after vanilla install");
|
||||
|
||||
// Verify client jar exists
|
||||
Path clientJar = instanceDir.resolve("versions/" + MC_VERSION + "/" + MC_VERSION + ".jar");
|
||||
assertTrue(Files.exists(clientJar), "client jar must exist after vanilla install");
|
||||
|
||||
// Verify libraries exist
|
||||
Path libsDir = instanceDir.resolve("libraries");
|
||||
assertTrue(Files.exists(libsDir) && Files.list(libsDir).findAny().isPresent(),
|
||||
"At least one library must exist after vanilla install");
|
||||
|
||||
System.out.println("=== Minecraft install OK ===\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
@DisplayName("Install Forge 1.20.1-47.4.22")
|
||||
void installForge() throws Exception {
|
||||
System.out.println("\n=== Installing Forge " + MC_VERSION + "-" + FORGE_VERSION + " ===");
|
||||
ModLoaderInstaller installer = new ModLoaderInstaller(instance);
|
||||
boolean success = installer.install(MC_VERSION, FORGE_VERSION, ModLoaderInstaller.LoaderType.FORGE);
|
||||
assertTrue(success, "Forge installation must succeed");
|
||||
installSuccess = true;
|
||||
|
||||
// Verify Forge version.json exists
|
||||
String forgeVersionId = MC_VERSION + "-forge-" + FORGE_VERSION;
|
||||
Path forgeJson = instanceDir.resolve("versions/" + forgeVersionId + "/" + forgeVersionId + ".json");
|
||||
assertTrue(Files.exists(forgeJson), "Forge version.json must exist at " + forgeJson);
|
||||
System.out.println(" Forge version.json size: " + Files.size(forgeJson) + " bytes\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
@DisplayName("Build launch command and verify structure")
|
||||
void buildAndVerifyCommand() throws Exception {
|
||||
assumeTrue(installSuccess, "Skipping: Forge install did not succeed");
|
||||
System.out.println("\n=== Building launch command ===");
|
||||
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
options.setUsername("TestPlayer");
|
||||
options.setUuid("00000000-0000-0000-0000-000000000000");
|
||||
options.setAccessToken("test-token");
|
||||
options.setMaxMemory(4096);
|
||||
options.setWidth(854);
|
||||
options.setHeight(480);
|
||||
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
List<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+563
@@ -0,0 +1,563 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LaunchCommandBuilderTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
// ================================================================
|
||||
// Helpers
|
||||
// ================================================================
|
||||
|
||||
private Path createJar(Path path) throws Exception {
|
||||
Files.createDirectories(path.getParent());
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(path))) {
|
||||
zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
|
||||
zos.write("Manifest-Version: 1.0\n".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private Path createJarWithModule(Path path, String moduleName) throws Exception {
|
||||
Files.createDirectories(path.getParent());
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(path))) {
|
||||
zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
|
||||
zos.write(("Manifest-Version: 1.0\nAutomatic-Module-Name: " + moduleName + "\n").getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private void writeVersionJson(Path path, String content) throws Exception {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Fixture setup
|
||||
// ================================================================
|
||||
|
||||
private Instance createForgeFixture(Path dir) throws Exception {
|
||||
String mcVer = "1.20.1";
|
||||
String forgeVer = "47.3.0";
|
||||
String versionId = mcVer + "-forge-" + forgeVer;
|
||||
|
||||
// Vanilla parent
|
||||
String vanillaJson = """
|
||||
{
|
||||
"id": "1.20.1",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"arguments": {
|
||||
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"],
|
||||
"game": ["--username", "${auth_player_name}", "--version", "${version_name}", "--gameDir", "${game_directory}", "--assetsDir", "${assets_root}", "--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}", "--userType", "${user_type}", "--versionType", "${version_type}"]
|
||||
},
|
||||
"assetIndex": {"id": "1.20.1"},
|
||||
"libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}]
|
||||
}
|
||||
""";
|
||||
writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
|
||||
createJarWithModule(dir.resolve("versions/1.20.1/1.20.1.jar"), "minecraft");
|
||||
createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
|
||||
|
||||
// Forge child — real 1.20.1-47.3.0 format: -p is a fixed bootstrap list,
|
||||
// NO ${classpath} and NO -cp (the launcher must add -cp itself).
|
||||
String forgeJson = """
|
||||
{
|
||||
"id": "1.20.1-forge-47.3.0",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
|
||||
"arguments": {
|
||||
"jvm": ["-Djava.net.preferIPv6Addresses=system",
|
||||
"-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-,${version_name}.jar",
|
||||
"-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar",
|
||||
"-DlibraryDirectory=${library_directory}",
|
||||
"-p", "${library_directory}/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar${classpath_separator}${library_directory}/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
|
||||
"--add-modules", "ALL-MODULE-PATH",
|
||||
"--add-opens", "java.base/java.util.jar=cpw.mods.securejarhandler",
|
||||
"--add-opens", "java.base/java.lang.invoke=cpw.mods.securejarhandler"],
|
||||
"game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0",
|
||||
"--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge",
|
||||
"--fml.mcpVersion", "20230612.114412"]
|
||||
},
|
||||
"libraries": [
|
||||
{"name": "cpw.mods:bootstraplauncher:1.1.2", "downloads": {"artifact": {"path": "cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar"}}},
|
||||
{"name": "cpw.mods:securejarhandler:2.1.10", "downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar"}}},
|
||||
{"name": "net.minecraftforge:forge:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar"}}}
|
||||
]
|
||||
}
|
||||
""";
|
||||
writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), forgeJson);
|
||||
createJar(dir.resolve("libraries/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar"));
|
||||
createJar(dir.resolve("libraries/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar"));
|
||||
createJar(dir.resolve("libraries/net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar"));
|
||||
|
||||
Files.createDirectories(dir.resolve("natives"));
|
||||
|
||||
Instance instance = new Instance("test-forge", dir);
|
||||
instance.setMinecraftVersion(mcVer);
|
||||
instance.setLoaderType("forge");
|
||||
instance.setLoaderVersion(forgeVer);
|
||||
instance.setAssetIndex(mcVer);
|
||||
return instance;
|
||||
}
|
||||
|
||||
private Instance createNeoForgeFixture(Path dir) throws Exception {
|
||||
String mcVer = "1.20.1";
|
||||
String neoVer = "47.1.106";
|
||||
String versionId = mcVer + "-neoforge-" + neoVer;
|
||||
|
||||
String vanillaJson = """
|
||||
{
|
||||
"id": "1.20.1",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"arguments": {
|
||||
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"],
|
||||
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"]
|
||||
},
|
||||
"assetIndex": {"id": "1.20.1"},
|
||||
"libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}]
|
||||
}
|
||||
""";
|
||||
writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
|
||||
createJarWithModule(dir.resolve("versions/1.20.1/1.20.1.jar"), "minecraft");
|
||||
createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
|
||||
|
||||
String neoJson = """
|
||||
{
|
||||
"id": "1.20.1-neoforge-47.1.106",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
|
||||
"arguments": {
|
||||
"jvm": ["-p", "${classpath}", "--add-modules=ALL-MODULE-PATH", "--add-opens=java.base/java.util.jar=ALL-UNNAMED"],
|
||||
"game": ["--launchTarget", "neoforgeclient", "--fml.neoForgeVersion", "47.1.106", "--fml.mcVersion", "1.20.1"]
|
||||
},
|
||||
"libraries": [{"name": "net.neoforged:neoforge:1.20.1-47.1.106", "downloads": {"artifact": {"path": "net/neoforged/neoforge/1.20.1-47.1.106/neoforge-1.20.1-47.1.106.jar"}}}]
|
||||
}
|
||||
""";
|
||||
writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), neoJson);
|
||||
createJar(dir.resolve("libraries/net/neoforged/neoforge/1.20.1-47.1.106/neoforge-1.20.1-47.1.106.jar"));
|
||||
|
||||
Files.createDirectories(dir.resolve("natives"));
|
||||
|
||||
Instance instance = new Instance("test-neoforge", dir);
|
||||
instance.setMinecraftVersion(mcVer);
|
||||
instance.setLoaderType("neoforge");
|
||||
instance.setLoaderVersion(neoVer);
|
||||
instance.setAssetIndex(mcVer);
|
||||
instance.setFabricVersionId(versionId);
|
||||
return instance;
|
||||
}
|
||||
|
||||
private Instance createFabricFixture(Path dir) throws Exception {
|
||||
String mcVer = "1.20.1";
|
||||
String loaderVer = "0.16.10";
|
||||
String versionId = "fabric-loader-" + loaderVer + "-" + mcVer;
|
||||
|
||||
String vanillaJson = """
|
||||
{
|
||||
"id": "1.20.1",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"]
|
||||
},
|
||||
"assetIndex": {"id": "1.20.1"},
|
||||
"libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}]
|
||||
}
|
||||
""";
|
||||
writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
|
||||
createJar(dir.resolve("versions/1.20.1/1.20.1.jar"));
|
||||
|
||||
String fabricJson = """
|
||||
{
|
||||
"id": "fabric-loader-0.16.10-1.20.1",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}"]
|
||||
},
|
||||
"libraries": [{"name": "net.fabricmc:fabric-loader:0.16.10", "downloads": {"artifact": {"path": "net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"}}}]
|
||||
}
|
||||
""";
|
||||
writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), fabricJson);
|
||||
createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
|
||||
createJar(dir.resolve("libraries/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"));
|
||||
|
||||
Files.createDirectories(dir.resolve("natives"));
|
||||
|
||||
Instance instance = new Instance("test-fabric", dir);
|
||||
instance.setMinecraftVersion(mcVer);
|
||||
instance.setLoaderType("fabric");
|
||||
instance.setLoaderVersion(loaderVer);
|
||||
instance.setAssetIndex(mcVer);
|
||||
instance.setFabricVersionId(versionId);
|
||||
return instance;
|
||||
}
|
||||
|
||||
private Instance createVanillaFixture(Path dir) throws Exception {
|
||||
Files.createDirectories(dir.resolve("natives"));
|
||||
// No version.json — forces vanilla fallback
|
||||
Instance instance = new Instance("test-vanilla", dir);
|
||||
instance.setMinecraftVersion("1.21");
|
||||
instance.setLoaderType("vanilla");
|
||||
instance.setAssetIndex("1.21");
|
||||
return instance;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Forge tests
|
||||
// ================================================================
|
||||
|
||||
@Test
|
||||
void forge_noParentJvmArgs() throws Exception {
|
||||
Instance instance = createForgeFixture(tempDir);
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
|
||||
List<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);
|
||||
}
|
||||
}
|
||||
+184
-200
@@ -1,201 +1,185 @@
|
||||
<?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>
|
||||
<groupId>me.sashegdev</groupId>
|
||||
<artifactId>ZernMCLauncher</artifactId>
|
||||
<version>1.0.8</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<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 - just a minimalistic launcher by SashegDev</project.description>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>4.5.14</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.15.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.10.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>20231013</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.fusesource.jansi</groupId>
|
||||
<artifactId>jansi</artifactId>
|
||||
<version>2.4.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jline</groupId>
|
||||
<artifactId>jline</artifactId>
|
||||
<version>3.24.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.tongfei</groupId>
|
||||
<artifactId>progressbar</artifactId>
|
||||
<version>0.9.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.15.1</version>
|
||||
</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-surefire-plugin</artifactId>
|
||||
<version>3.2.3</version>
|
||||
</plugin>
|
||||
|
||||
<!-- Shade Plugin -->
|
||||
<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>${mainClass}</mainClass>
|
||||
<manifestEntries>
|
||||
<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>
|
||||
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Launch4j для создания .exe -->
|
||||
<plugin>
|
||||
<groupId>com.akathist.maven.plugins.launch4j</groupId>
|
||||
<artifactId>launch4j-maven-plugin</artifactId>
|
||||
<version>2.5.0</version>
|
||||
<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>console</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<jre>
|
||||
<path>jre21</path>
|
||||
<minVersion>21</minVersion>
|
||||
</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>
|
||||
|
||||
<!-- Создаём zip только с .exe и jre21 (без .jar и build.version) -->
|
||||
<zip destfile="../server/builds/ZernMCLauncher-${project.version}.zip"
|
||||
basedir="../server/builds"
|
||||
includes="ZernMCLauncher.exe,jre21/**"
|
||||
excludes="*.jar,build.version"/>
|
||||
</target>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</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>
|
||||
</profiles>
|
||||
<?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>
|
||||
<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>
|
||||
|
||||
<properties>
|
||||
<revision>1.1.1</revision>
|
||||
<hotfix>2</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>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>4.5.14</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.15.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.10.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>20231013</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.fusesource.jansi</groupId>
|
||||
<artifactId>jansi</artifactId>
|
||||
<version>2.4.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jline</groupId>
|
||||
<artifactId>jline</artifactId>
|
||||
<version>3.24.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.tongfei</groupId>
|
||||
<artifactId>progressbar</artifactId>
|
||||
<version>0.9.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.15.1</version>
|
||||
</dependency>
|
||||
<!-- JavaFX for Windows -->
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
<version>23.0.1</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-web</artifactId>
|
||||
<version>23.0.1</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-graphics</artifactId>
|
||||
<version>23.0.1</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-base</artifactId>
|
||||
<version>23.0.1</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dnsjava</groupId>
|
||||
<artifactId>dnsjava</artifactId>
|
||||
<version>3.6.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-media</artifactId>
|
||||
<version>23.0.1</version>
|
||||
<classifier>win</classifier>
|
||||
</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>
|
||||
<version>3.2.3</version>
|
||||
</plugin>
|
||||
|
||||
<!-- Shade Plugin -->
|
||||
<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>${mainClass}</mainClass>
|
||||
<manifestEntries>
|
||||
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
|
||||
<Implementation-Title>ZernMC Launcher</Implementation-Title>
|
||||
<Implementation-Vendor>SashegDev</Implementation-Vendor>
|
||||
<Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
|
||||
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>global</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<launcher.title>ZernMC Launcher</launcher.title>
|
||||
<server.url>http://87.120.187.36:1582</server.url>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -1,224 +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.utils.*;
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
|
||||
public class Main {
|
||||
|
||||
private static final String CURRENT_VERSION = Version.getCurrentVersion();
|
||||
private static final LauncherAPI api = new LauncherAPI();
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
||||
System.setProperty("file.encoding", "UTF-8");
|
||||
System.setProperty("sun.err.encoding", "UTF-8");
|
||||
System.setProperty("sun.stdout.encoding", "UTF-8");
|
||||
|
||||
ZAnsi.install();
|
||||
System.out.print("\033[H\033[2J");
|
||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
|
||||
|
||||
// Проверка всех сервисов при старте
|
||||
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();
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
|
||||
|
||||
new ProcessBuilder(javaPath, "-jar", jarPath)
|
||||
.inheritIO()
|
||||
.start();
|
||||
|
||||
System.exit(0);
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось перезапустить лаунчер."));
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractVersion(String json) {
|
||||
try {
|
||||
return json.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static Path getCurrentJarPath() {
|
||||
try {
|
||||
return Path.of(Main.class.getProtectionDomain()
|
||||
.getCodeSource()
|
||||
.getLocation()
|
||||
.toURI());
|
||||
} catch (Exception e) {
|
||||
return Path.of("zernmc-launcher-1.0-jar-with-dependencies.jar");
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
|
||||
private static void mainLoop() throws Exception {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +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 java.util.List;
|
||||
|
||||
/**
|
||||
* Центральный фасад для внутреннего API лаунчера.
|
||||
* Используется как единая точка входа для UI и других компонентов.
|
||||
*/
|
||||
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<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,157 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"Настроить путь к 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("В будущем здесь будет список предустановленных оптимизаций.");
|
||||
}
|
||||
}
|
||||
@@ -1,567 +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.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class PackDownloader {
|
||||
|
||||
private final Instance instance;
|
||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
//private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
|
||||
|
||||
public PackDownloader(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список доступных паков с сервера
|
||||
*/
|
||||
public List<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить наличие обновлений для серверной сборки
|
||||
*/
|
||||
public boolean checkForUpdates(String packName) throws Exception {
|
||||
if (!instance.isServerPack()) return false;
|
||||
|
||||
PackManifest manifest = getPackManifest(packName);
|
||||
int serverVersion = manifest.getVersion();
|
||||
int localVersion = instance.getServerVersion();
|
||||
|
||||
return serverVersion > localVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить существующую серверную сборку
|
||||
*/
|
||||
public boolean updatePack(String packName) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName() + "..."));
|
||||
|
||||
PackManifest manifest = getPackManifest(packName);
|
||||
int serverVersion = manifest.getVersion();
|
||||
|
||||
if (serverVersion <= instance.getServerVersion()) {
|
||||
System.out.println(ZAnsi.green("Сборка уже актуальна (v" + instance.getServerVersion() + ")"));
|
||||
return true;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.yellow("Доступно обновление: v" + instance.getServerVersion() + " → v" + serverVersion));
|
||||
|
||||
// Сканируем локальные файлы
|
||||
Map<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сканирование локальных файлов и вычисление хешей
|
||||
*/
|
||||
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", "config",
|
||||
"natives", "assets", "libraries", "versions", "cache"
|
||||
);
|
||||
|
||||
if (!Files.exists(instancePath)) {
|
||||
return files;
|
||||
}
|
||||
|
||||
Files.walk(instancePath)
|
||||
.filter(Files::isRegularFile)
|
||||
.forEach(file -> {
|
||||
Path relative = instancePath.relativize(file);
|
||||
String path = relative.toString().replace("\\", "/");
|
||||
|
||||
// Проверяем, не в игнорируемой ли директории
|
||||
for (String ignored : ignoredDirs) {
|
||||
if (path.startsWith(ignored + "/") || path.startsWith(ignored + "\\")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
String hash = calculateHash(file);
|
||||
files.put(path, hash);
|
||||
} catch (Exception e) {
|
||||
// Пропускаем файлы, которые не можем прочитать
|
||||
}
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить diff запрос на сервер
|
||||
*/
|
||||
private DiffResponse getDiff(String packName, Map<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() + " файлов");
|
||||
|
||||
// Создаем директории если нужно
|
||||
try {
|
||||
Files.createDirectories(instance.getPath());
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Удаляем файлы
|
||||
for (String filePath : diff.getToDelete()) {
|
||||
Path fullPath = instance.getPath().resolve(filePath);
|
||||
try {
|
||||
if (Files.deleteIfExists(fullPath)) {
|
||||
System.out.println(ZAnsi.yellow(" Удален: " + filePath));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.red(" Ошибка удаления " + filePath + ": " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// Скачиваем файлы
|
||||
AtomicInteger downloaded = new AtomicInteger(0);
|
||||
int total = diff.getToDownload().size();
|
||||
|
||||
for (FileInfo file : diff.getToDownload()) {
|
||||
String path = file.getPath();
|
||||
Path fullPath = instance.getPath().resolve(path);
|
||||
|
||||
try {
|
||||
// Создаем директории
|
||||
Files.createDirectories(fullPath.getParent());
|
||||
|
||||
// Скачиваем файл
|
||||
downloadFile(file, fullPath);
|
||||
|
||||
// Проверяем хеш
|
||||
String actualHash = calculateHash(fullPath);
|
||||
if (!actualHash.equals(file.getHash())) {
|
||||
throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() +
|
||||
", получен: " + actualHash);
|
||||
}
|
||||
|
||||
downloaded.incrementAndGet();
|
||||
if (total > 0) {
|
||||
ProgressBar.show("Скачивание", downloaded.get(), total, "файлов");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
ProgressBar.finish("Скачивание");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать один файл с сервера
|
||||
*/
|
||||
private void downloadFile(FileInfo file, Path destination) throws Exception {
|
||||
String url = ZHttpClient.getBaseUrl() + file.getUrl();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(java.net.URI.create(url))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<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();
|
||||
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
totalRead += bytesRead;
|
||||
|
||||
if (fileSize > 0 && totalRead % 8192 == 0) {
|
||||
ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProgressBar.clearLine();
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисление SHA256 хеша файла
|
||||
*/
|
||||
private String calculateHash(Path file) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] hashBytes = digest.digest();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ====================== Вложенные классы ======================
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
|
||||
public class FabricInstaller {
|
||||
|
||||
private final Instance instance;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
|
||||
public FabricInstaller(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public boolean install(String minecraftVersion, String loaderVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка Fabric " + loaderVersion + " для Minecraft " + minecraftVersion));
|
||||
|
||||
Path instancePath = instance.getPath();
|
||||
cleanOldFabricLoaders();
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(instancePath);
|
||||
String assetIndex = versionInstaller.install(minecraftVersion);
|
||||
|
||||
System.out.println(ZAnsi.green("Asset index получен: " + assetIndex));
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
instance.setMinecraftVersion(minecraftVersion);
|
||||
|
||||
String installerVersion = getLatestInstallerVersion();
|
||||
String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/"
|
||||
+ installerVersion + "/fabric-installer-" + installerVersion + ".jar";
|
||||
|
||||
Path installerJar = instancePath.resolve("fabric-installer.jar");
|
||||
|
||||
if (!Files.exists(installerJar)) {
|
||||
ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%");
|
||||
downloadFileWithFallback(installerUrl, installerJar);
|
||||
ProgressBar.finish("Fabric Installer скачан");
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.cyan("Запуск Fabric Installer..."));
|
||||
|
||||
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"java", "-jar", installerJar.toAbsolutePath().toString(),
|
||||
"client",
|
||||
"-dir", instancePath.toAbsolutePath().toString(),
|
||||
"-mcversion", minecraftVersion,
|
||||
"-loader", loaderVersion,
|
||||
"-noprofile"
|
||||
);
|
||||
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
||||
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
|
||||
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
if (exitCode != 0) {
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer завершился с ошибкой (код " + exitCode + ")"));
|
||||
return false;
|
||||
}
|
||||
|
||||
Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId);
|
||||
|
||||
if (Files.exists(fabricVersionDir)) {
|
||||
System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!"));
|
||||
|
||||
instance.setLoaderType("fabric");
|
||||
instance.setLoaderVersion(loaderVersion);
|
||||
instance.setFabricVersionId(fabricVersionId); // ← СОХРАНЯЕМ
|
||||
|
||||
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void downloadFileWithFallback(String url, Path target) throws Exception {
|
||||
try {
|
||||
ZHttpClient.downloadFile(url, target);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось скачать Fabric Installer: " + e.getMessage()));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException {
|
||||
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
|
||||
|
||||
if (!Files.exists(versionJson)) {
|
||||
System.out.println(ZAnsi.yellow("JSON файл версии не найден: " + versionJson));
|
||||
return;
|
||||
}
|
||||
|
||||
String content = Files.readString(versionJson);
|
||||
|
||||
// Проверяем и исправляем asset index
|
||||
if (!content.contains("\"assets\":\"" + assetIndex + "\"")) {
|
||||
System.out.println(ZAnsi.yellow("Исправляем asset index в JSON файле версии..."));
|
||||
|
||||
// Заменяем assets на правильное значение
|
||||
content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\"");
|
||||
|
||||
// Также проверяем assetIndex
|
||||
if (content.contains("\"assetIndex\"")) {
|
||||
content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\"");
|
||||
}
|
||||
|
||||
Files.writeString(versionJson, content);
|
||||
System.out.println(ZAnsi.green("Asset index исправлен на: " + assetIndex));
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Asset index в JSON версии правильный: " + assetIndex));
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanOldFabricLoaders() throws IOException {
|
||||
Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader");
|
||||
if (!Files.exists(librariesDir)) return;
|
||||
|
||||
System.out.println(ZAnsi.yellow("Очистка старых версий Fabric Loader..."));
|
||||
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(Files::isDirectory)
|
||||
.filter(dir -> dir.getFileName().toString().matches("\\d+\\.\\d+\\.\\d+.*"))
|
||||
.forEach(dir -> {
|
||||
try {
|
||||
Files.walk(dir)
|
||||
.sorted((a,b) -> b.compareTo(a))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException ignored) {}
|
||||
});
|
||||
} catch (IOException ignored) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private String getLatestInstallerVersion() throws Exception {
|
||||
try {
|
||||
// Используем ZHttpClient с умным прокси
|
||||
String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml");
|
||||
int start = xml.indexOf("<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-270
@@ -1,270 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import java.io.*;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ForgeInstaller {
|
||||
|
||||
private final Instance instance;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(java.time.Duration.ofSeconds(30))
|
||||
.build();
|
||||
|
||||
public ForgeInstaller(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public boolean install(String mcVersion, String forgeVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка Forge " + forgeVersion + " для Minecraft " + mcVersion));
|
||||
|
||||
// Шаг 1: Устанавливаем vanilla и получаем настоящий assetIndex
|
||||
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
|
||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||
|
||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
|
||||
return false;
|
||||
}
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
|
||||
// Шаг 2: Создаём launcher_profiles.json
|
||||
createLauncherProfile();
|
||||
|
||||
// Шаг 3: Скачиваем Forge Installer с прогресс-баром
|
||||
String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/"
|
||||
+ mcVersion + "-" + forgeVersion
|
||||
+ "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar";
|
||||
|
||||
Path installerJar = instance.getPath().resolve("forge-installer.jar");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание Forge Installer..."));
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
// Шаг 4: Запускаем Forge Installer и показываем его вывод
|
||||
System.out.println(ZAnsi.cyan("Запуск Forge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n"));
|
||||
|
||||
boolean success = runForgeInstaller(installerJar);
|
||||
|
||||
// После успешной установки Forge, но перед сохранением метаданных
|
||||
if (success) {
|
||||
// Докачиваем пропущенные библиотеки
|
||||
try {
|
||||
downloadMissingLibraries(mcVersion, forgeVersion);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage()));
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " успешно установлен!"));
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType("forge");
|
||||
instance.setLoaderVersion(forgeVersion);
|
||||
|
||||
// Очищаем временный файл установщика
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\nОшибка при установке Forge!"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void createLauncherProfile() throws IOException {
|
||||
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
|
||||
if (Files.exists(profilePath)) return;
|
||||
|
||||
String minimalProfile = """
|
||||
{
|
||||
"profiles": {},
|
||||
"selectedProfile": "Default"
|
||||
}
|
||||
""";
|
||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
System.out.println(ZAnsi.yellow("Создан launcher_profiles.json"));
|
||||
}
|
||||
|
||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-271
@@ -1,271 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class 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);
|
||||
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");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание NeoForge Installer..."));
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Запуск NeoForge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n"));
|
||||
|
||||
boolean success = runNeoForgeInstaller(installerJar);
|
||||
|
||||
if (success) {
|
||||
try {
|
||||
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage()));
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " успешно установлен!"));
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType("neoforge");
|
||||
instance.setLoaderVersion(neoForgeVersion);
|
||||
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\nОшибка при установке 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);
|
||||
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 boolean runNeoForgeInstaller(Path installerJar) throws IOException, InterruptedException {
|
||||
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"
|
||||
);
|
||||
|
||||
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")) {
|
||||
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)) {
|
||||
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("NeoForge 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. Попробуйте установить другую версию NeoForge"));
|
||||
}
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек..."));
|
||||
|
||||
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-commons/9.6/asm-commons-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar");
|
||||
alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-245
@@ -1,245 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class VersionInstaller {
|
||||
|
||||
private final Path minecraftDir;
|
||||
private final HttpClient httpClient;
|
||||
private final ExecutorService executor = Executors.newFixedThreadPool(32);
|
||||
|
||||
public VersionInstaller(Path minecraftDir) {
|
||||
this.minecraftDir = minecraftDir;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
}
|
||||
|
||||
public List<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) + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
-426
@@ -1,426 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import 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,137 +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 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";
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import java.text.DecimalFormat;
|
||||
|
||||
public class ProgressBar {
|
||||
|
||||
private static final int BAR_LENGTH = 40;
|
||||
private static final DecimalFormat DF = new DecimalFormat("#.##");
|
||||
|
||||
/**
|
||||
* Прогресс по количеству файлов (для библиотек и общего прогресса)
|
||||
*/
|
||||
public static void show(String label, long current, long total, String unit) {
|
||||
if (total <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
return;
|
||||
}
|
||||
double progress = (double) current / total;
|
||||
int filled = (int) (progress * BAR_LENGTH);
|
||||
String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled);
|
||||
int percent = (int) (progress * 100);
|
||||
|
||||
String text = String.format("%s [%s] %3d%% (%d/%d %s)",
|
||||
ZAnsi.cyan(label), bar, percent, current, total, unit);
|
||||
|
||||
System.out.print("\r" + text);
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогресс по байтам для одного файла (реальный прогресс)
|
||||
*/
|
||||
public static void showDownload(String label, long downloaded, long totalBytes) {
|
||||
if (totalBytes <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
return;
|
||||
}
|
||||
|
||||
double progress = (double) downloaded / totalBytes;
|
||||
int filled = (int) (progress * BAR_LENGTH);
|
||||
String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled);
|
||||
String percent = DF.format(progress * 100);
|
||||
|
||||
String text = String.format("%s [%s] %6s%% %s / %s",
|
||||
ZAnsi.cyan(label),
|
||||
bar,
|
||||
percent,
|
||||
formatBytes(downloaded),
|
||||
formatBytes(totalBytes));
|
||||
|
||||
System.out.print("\r" + text);
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
public static void showAnimated(String label, long current, long total, String unit) {
|
||||
if (total <= 0) {
|
||||
// Анимация для неизвестного размера
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (current / 1024) % 4;
|
||||
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
|
||||
} else {
|
||||
show(label, (int) ((current * 100) / total), 100, unit);
|
||||
}
|
||||
}
|
||||
|
||||
public static void finish(String message) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(message + " завершено ✓"));
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
public static void clearLine() {
|
||||
System.out.print("\r" + " ".repeat(110) + "\r");
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
public static String formatBytes(long bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB";
|
||||
return DF.format(bytes / (1024.0 * 1024)) + " MB";
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess, sys, os, json, time, signal
|
||||
|
||||
os.chdir(os.path.join(os.path.dirname(__file__), "server"))
|
||||
workdir = os.path.join(os.path.dirname(__file__), "server")
|
||||
|
||||
# Start server
|
||||
print("Starting server...", flush=True)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "main:app",
|
||||
"--host", "127.0.0.1", "--port", "1590",
|
||||
"--log-level", "warning"],
|
||||
cwd=workdir,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
import httpx
|
||||
|
||||
for i in range(30):
|
||||
try:
|
||||
r = httpx.get("http://127.0.0.1:1590/news", timeout=2)
|
||||
if r.status_code == 200:
|
||||
print("Server ready!", flush=True)
|
||||
break
|
||||
except Exception as e:
|
||||
if i == 0:
|
||||
print(f"Waiting for server ({e})...", flush=True)
|
||||
time.sleep(1)
|
||||
else:
|
||||
print("Server failed to start", flush=True)
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
sys.exit(1)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
def check(name, ok, detail=""):
|
||||
global passed, failed
|
||||
if ok:
|
||||
print(f" PASS {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {name} {detail}")
|
||||
failed += 1
|
||||
|
||||
BASE = "http://127.0.0.1:1590"
|
||||
client = httpx.Client(base_url=BASE, timeout=10)
|
||||
|
||||
# ===== NEWS =====
|
||||
print("\n=== NEWS ===")
|
||||
r = client.get("/news")
|
||||
check("GET /news status", r.status_code == 200, str(r.status_code))
|
||||
data = r.json()
|
||||
check("news has 'news' key", "news" in data)
|
||||
check("news is array", isinstance(data.get("news"), list))
|
||||
check("news has items", len(data["news"]) > 0, f"got {len(data['news'])} items")
|
||||
if data.get("news"):
|
||||
n = data["news"][0]
|
||||
check("news item has title", "title" in n)
|
||||
check("news item has body", "body" in n)
|
||||
check("news item has type", "type" in n)
|
||||
|
||||
# ===== AUTH =====
|
||||
print("\n=== AUTH ===")
|
||||
ts = str(int(time.time()))
|
||||
tu = f"test_{ts}"
|
||||
r = client.post("/auth/register", json={"username": tu, "password": "pass123"})
|
||||
check("register new user", r.status_code == 200, str(r.status_code))
|
||||
reg = r.json()
|
||||
check("register returns access_token", "access_token" in reg)
|
||||
|
||||
r2 = client.post("/auth/register", json={"username": tu, "password": "pass123"})
|
||||
check("register existing returns 409", r2.status_code == 409, str(r2.status_code))
|
||||
|
||||
r3 = client.post("/auth/login", json={"username": tu, "password": "pass123"})
|
||||
check("login valid", r3.status_code == 200, str(r3.status_code))
|
||||
tok = r3.json().get("access_token", "")
|
||||
if not tok: tok = reg.get("access_token", "")
|
||||
check("login returns token", bool(tok))
|
||||
|
||||
r4 = client.post("/auth/login", json={"username": tu, "password": "wrongpass123"})
|
||||
check("login wrong pass", r4.status_code == 401, str(r4.status_code))
|
||||
|
||||
r5 = client.post("/auth/login", json={"username": "nobody_valid", "password": "pass123456"})
|
||||
check("login nonexistent", r5.status_code == 401, str(r5.status_code))
|
||||
|
||||
# ===== TOKEN VALIDATION =====
|
||||
print("\n=== TOKEN ===")
|
||||
ah = {"Authorization": f"Bearer {tok}"}
|
||||
authed = httpx.Client(base_url=BASE, timeout=10, headers=ah)
|
||||
r = authed.get("/auth/pass/my")
|
||||
check("pass/my authed", r.status_code in (200, 404), str(r.status_code))
|
||||
|
||||
r2 = client.get("/auth/pass/my")
|
||||
check("pass/my unauthed", r2.status_code == 401, str(r2.status_code))
|
||||
|
||||
# ===== PACKS =====
|
||||
print("\n=== PACKS ===")
|
||||
r = client.get("/packs")
|
||||
check("packs unauthed rejects", r.status_code in (401, 403), str(r.status_code))
|
||||
r2 = authed.get("/packs")
|
||||
check("packs authed succeeds", r2.status_code in (200, 403), str(r2.status_code))
|
||||
if r2.status_code == 200:
|
||||
pdata = r2.json()
|
||||
check("packs has 'packs' key", "packs" in pdata)
|
||||
|
||||
# ===== FRIENDS =====
|
||||
print("\n=== FRIENDS ===")
|
||||
r = client.get("/api/friends/list")
|
||||
check("friends/list unauthed", r.status_code == 401, str(r.status_code))
|
||||
r = authed.get("/api/friends/list")
|
||||
check("friends/list authed", r.status_code == 200, str(r.status_code))
|
||||
check("friends/list has friends key", "friends" in r.json())
|
||||
|
||||
r = authed.get("/api/friends/requests")
|
||||
check("friends/requests authed", r.status_code == 200, str(r.status_code))
|
||||
check("friends/requests has requests key", "requests" in r.json())
|
||||
|
||||
# Register friend user
|
||||
fts = f"friend_{ts}"
|
||||
r = client.post("/auth/register", json={"username": fts, "password": "pass123"})
|
||||
check("register friend user", r.status_code == 200, str(r.status_code))
|
||||
ftok = r.json().get("access_token", "")
|
||||
fauthed = httpx.Client(base_url=BASE, timeout=10,
|
||||
headers={"Authorization": f"Bearer {ftok}"})
|
||||
# Parse user id from JWT (no account endpoint on Python server)
|
||||
import base64
|
||||
def parse_user_id(token):
|
||||
try:
|
||||
payload_b64 = token.split('.')[1]
|
||||
payload_b64 += '=' * (4 - len(payload_b64) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload_b64))["sub"]
|
||||
except:
|
||||
return 0
|
||||
friend_id = parse_user_id(ftok)
|
||||
my_id = parse_user_id(tok)
|
||||
|
||||
# Add friend
|
||||
r = authed.post("/api/friends/add", json={"username": fts})
|
||||
check("friends add", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
|
||||
ad = r.json()
|
||||
check("friends add returns message", "message" in ad)
|
||||
|
||||
# Duplicate add
|
||||
r = authed.post("/api/friends/add", json={"username": fts})
|
||||
check("friends add duplicate", r.status_code == 400, str(r.status_code))
|
||||
|
||||
# Add self
|
||||
r = authed.post("/api/friends/add", json={"username": tu})
|
||||
check("friends add self", r.status_code == 400, str(r.status_code))
|
||||
|
||||
# Add nonexistent
|
||||
r = authed.post("/api/friends/add", json={"username": "nobody_xyz_123"})
|
||||
check("friends add nonexistent", r.status_code == 404, str(r.status_code))
|
||||
|
||||
# Accept friend request (as friend)
|
||||
r = fauthed.post("/api/friends/accept", json={"user_id": my_id})
|
||||
check("friends accept", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
|
||||
|
||||
# Remove nonexistent
|
||||
r = authed.post("/api/friends/remove", json={"user_id": 99999})
|
||||
check("friends remove nonexistent", r.status_code == 404, str(r.status_code))
|
||||
|
||||
# Remove friend
|
||||
r = authed.post("/api/friends/remove", json={"user_id": friend_id})
|
||||
check("friends remove", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
|
||||
|
||||
# ===== PLAYTIME =====
|
||||
print("\n=== PLAYTIME ===")
|
||||
r = authed.post("/api/playtime/sync", json={"pack_name": "test_pack", "minutes": 30})
|
||||
check("playtime sync", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
|
||||
|
||||
r = authed.get("/api/playtime/stats")
|
||||
check("playtime stats", r.status_code == 200, str(r.status_code))
|
||||
st = r.json()
|
||||
check("playtime has total_minutes", "total_minutes" in st)
|
||||
check("playtime has packs", "packs" in st)
|
||||
|
||||
r = client.get("/api/playtime/stats")
|
||||
check("playtime stats unauthed", r.status_code == 401, str(r.status_code))
|
||||
|
||||
# ===== SUMMARY =====
|
||||
total = passed + failed
|
||||
print(f"\n{'='*40}")
|
||||
print(f"RESULTS: {passed}/{total} PASSED, {failed}/{total} FAILED")
|
||||
if failed > 0:
|
||||
print("SOME TESTS FAILED!")
|
||||
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
sys.exit(1 if failed else 0)
|
||||
+388
-12
@@ -1,13 +1,17 @@
|
||||
# admin_router.py
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, status, UploadFile, File
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
import structlog
|
||||
import time
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from auth import get_db, require_role, log_audit, get_current_user
|
||||
from pack_manager import PACKS_DIR, load_packs_config, get_pack_presets, toggle_pack_disabled
|
||||
|
||||
from auth import get_db, require_role, log_audit, get_current_user, hash_password
|
||||
from roles import (
|
||||
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
|
||||
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
|
||||
@@ -60,8 +64,8 @@ async def list_users(
|
||||
query += " FROM users"
|
||||
|
||||
if search:
|
||||
query += " AND (username LIKE ? OR email LIKE ?)"
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
query += " AND username LIKE ?"
|
||||
params.append(f"%{search}%")
|
||||
|
||||
query += " ORDER BY role DESC, username"
|
||||
|
||||
@@ -108,19 +112,13 @@ async def get_user_detail(
|
||||
"""Детальная информация о пользователе"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until
|
||||
SELECT id, username, uuid, role, created_at, last_login, is_active, banned_until
|
||||
FROM users WHERE id = ?
|
||||
""", (user_id,)).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
# Модераторы не видят email обычных пользователей
|
||||
if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR:
|
||||
email = None
|
||||
else:
|
||||
email = row["email"]
|
||||
|
||||
# Получаем активную проходку
|
||||
pass_info = None
|
||||
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
|
||||
@@ -151,7 +149,6 @@ async def get_user_detail(
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"email": email,
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||
@@ -555,6 +552,385 @@ async def get_admin_stats(
|
||||
}
|
||||
|
||||
|
||||
# ====================== WHITELIST MODS ======================
|
||||
|
||||
WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
||||
WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt"
|
||||
|
||||
|
||||
def _read_mods_list() -> list[dict]:
|
||||
"""Parse mods.txt into list of {name, size, hash}"""
|
||||
if not WHITELIST_MODS_FILE.exists():
|
||||
return []
|
||||
mods = []
|
||||
for line in WHITELIST_MODS_FILE.read_text("utf-8").strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("::")
|
||||
if len(parts) >= 3:
|
||||
mods.append({
|
||||
"name": parts[0].strip(),
|
||||
"size": int(parts[1].strip()),
|
||||
"hash": parts[2].strip()
|
||||
})
|
||||
return mods
|
||||
|
||||
|
||||
def _write_mods_list(mods: list[dict]):
|
||||
"""Write mods list to mods.txt"""
|
||||
lines = [f"{m['name']} :: {m['size']} :: {m['hash']}" for m in mods]
|
||||
WHITELIST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
WHITELIST_MODS_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
@router.get("/whitelist/mods")
|
||||
async def admin_list_whitelist_mods(current_user: dict = Depends(require_role(ROLE_ELDER))):
|
||||
"""List all whitelist mods (JSON)"""
|
||||
return {"mods": _read_mods_list()}
|
||||
|
||||
|
||||
@router.post("/whitelist/mods")
|
||||
async def add_whitelist_mod(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Upload a mod file — auto-computes hash, records size, adds to whitelist"""
|
||||
if not file.filename or not file.filename.endswith(".jar"):
|
||||
raise HTTPException(400, "Only .jar files are allowed")
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) == 0:
|
||||
raise HTTPException(400, "Empty file")
|
||||
|
||||
file_hash = hashlib.sha256(contents).hexdigest()
|
||||
file_size = len(contents)
|
||||
|
||||
WHITELIST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mod_path = WHITELIST_DIR / file_hash
|
||||
if mod_path.exists():
|
||||
raise HTTPException(409, f"Mod with hash {file_hash} already exists")
|
||||
|
||||
mod_path.write_bytes(contents)
|
||||
|
||||
mods = _read_mods_list()
|
||||
mods.append({"name": file.filename, "size": file_size, "hash": file_hash})
|
||||
_write_mods_list(mods)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(current_user["id"], "whitelist_add", f"Added {file.filename} ({file_hash})", ip)
|
||||
|
||||
return {"success": True, "name": file.filename, "size": file_size, "hash": file_hash}
|
||||
|
||||
|
||||
@router.delete("/whitelist/mods/{mod_hash}")
|
||||
async def remove_whitelist_mod(
|
||||
mod_hash: str,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Remove a mod from whitelist by hash"""
|
||||
if ".." in mod_hash:
|
||||
raise HTTPException(400, "Invalid hash")
|
||||
|
||||
mod_path = WHITELIST_DIR / mod_hash
|
||||
deleted_file = False
|
||||
if mod_path.exists() and mod_path.is_file():
|
||||
mod_path.unlink()
|
||||
deleted_file = True
|
||||
|
||||
mods = _read_mods_list()
|
||||
filtered = [m for m in mods if m["hash"] != mod_hash]
|
||||
if len(filtered) == len(mods) and not deleted_file:
|
||||
raise HTTPException(404, "Mod not found")
|
||||
|
||||
_write_mods_list(filtered)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(current_user["id"], "whitelist_remove", f"Removed mod with hash {mod_hash}", ip)
|
||||
|
||||
return {"success": True, "hash": mod_hash, "file_deleted": deleted_file}
|
||||
|
||||
|
||||
# ====================== ADMIN CLIENTS, USERS, PASSES ======================
|
||||
|
||||
@router.get("/clients")
|
||||
async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
|
||||
"""List online clients"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address
|
||||
FROM users u
|
||||
LEFT JOIN user_status us ON u.id = us.user_id
|
||||
LEFT JOIN user_sessions s ON u.id = s.user_id AND s.is_active = 1
|
||||
WHERE us.is_online = 1
|
||||
GROUP BY u.id
|
||||
ORDER BY us.last_seen DESC
|
||||
""").fetchall()
|
||||
clients = []
|
||||
for row in rows:
|
||||
clients.append({
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"online": bool(row["is_online"]),
|
||||
"current_pack": row["current_pack"] or "",
|
||||
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
|
||||
"ip": row["ip_address"] or ""
|
||||
})
|
||||
return {"success": True, "clients": clients}
|
||||
|
||||
|
||||
class UserSearchRequest(BaseModel):
|
||||
search: str
|
||||
|
||||
|
||||
@router.post("/users/search")
|
||||
async def admin_search_users(
|
||||
req: UserSearchRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
|
||||
):
|
||||
"""Search users by username"""
|
||||
if not req.search.strip():
|
||||
return {"users": []}
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT id, username, uuid, role, created_at, last_login, is_active
|
||||
FROM users WHERE username LIKE ?
|
||||
ORDER BY username LIMIT 20
|
||||
""", (f"%{req.search.strip()}%",)).fetchall()
|
||||
users = []
|
||||
for row in rows:
|
||||
users.append({
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Unknown"),
|
||||
"created_at": row["created_at"],
|
||||
"last_login": row["last_login"],
|
||||
"is_active": bool(row["is_active"])
|
||||
})
|
||||
return {"success": True, "users": users}
|
||||
|
||||
|
||||
class RoleUpdateRequest(BaseModel):
|
||||
user_id: int
|
||||
role: int = Field(..., ge=0, le=4)
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/users/role")
|
||||
async def admin_update_user_role(
|
||||
body: RoleUpdateRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Update user role"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE id = ?",
|
||||
(body.user_id,)
|
||||
).fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Cannot change creator role")
|
||||
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Cannot modify equal or higher role")
|
||||
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Cannot assign role above your own")
|
||||
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Only creator can assign Elder")
|
||||
|
||||
old_role = target["role"]
|
||||
conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, body.user_id))
|
||||
conn.commit()
|
||||
|
||||
log_audit(current_user["id"], "role_change",
|
||||
f"Changed role of {target['username']} from {old_role} to {body.role}", ip)
|
||||
|
||||
return {"success": True, "user_id": body.user_id, "username": target["username"],
|
||||
"old_role": old_role, "new_role": body.role}
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
user_id: int
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/users/reset-password")
|
||||
async def admin_reset_password(
|
||||
body: ResetPasswordRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Reset user password"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
new_password = secrets.token_hex(8)
|
||||
pw_hash = hash_password(new_password)
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username FROM users WHERE id = ?",
|
||||
(body.user_id,)
|
||||
).fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(pw_hash, body.user_id))
|
||||
conn.commit()
|
||||
log_audit(current_user["id"], "password_reset",
|
||||
f"Reset password for {target['username']}", ip)
|
||||
return {"success": True, "new_password": new_password, "username": target["username"]}
|
||||
|
||||
|
||||
@router.get("/passes")
|
||||
async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
|
||||
"""List all passes"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT p.code, p.is_active, p.owner, p.activated_by, p.activated_at,
|
||||
p.expires_at, p.max_uses, p.uses, u.username as activated_username
|
||||
FROM passes p
|
||||
LEFT JOIN users u ON p.activated_by = u.id
|
||||
ORDER BY p.activated_at DESC
|
||||
""").fetchall()
|
||||
passes = []
|
||||
for row in rows:
|
||||
passes.append({
|
||||
"code": row["code"],
|
||||
"owner": row["owner"],
|
||||
"is_active": bool(row["is_active"]),
|
||||
"activated_by": row["activated_by"],
|
||||
"activated_username": row["activated_username"],
|
||||
"activated_at": row["activated_at"],
|
||||
"expires_at": row["expires_at"],
|
||||
"max_uses": row["max_uses"],
|
||||
"uses": row["uses"]
|
||||
})
|
||||
return {"success": True, "passes": passes}
|
||||
|
||||
|
||||
@router.post("/whitelist/mods/{mod_hash}/delete")
|
||||
async def admin_delete_whitelist_mod_adapter(
|
||||
mod_hash: str,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Delete whitelist mod (adapter for POST-based deletion)"""
|
||||
return await remove_whitelist_mod(mod_hash, current_user, request)
|
||||
|
||||
|
||||
# ====================== ADMIN NEWS ======================
|
||||
|
||||
ADMIN_NEWS_DIR = Path(__file__).parent / "news"
|
||||
|
||||
|
||||
class CreateNewsRequest(BaseModel):
|
||||
title: str
|
||||
type: str = "Update"
|
||||
version: str = ""
|
||||
body: str
|
||||
|
||||
|
||||
class DeleteNewsRequest(BaseModel):
|
||||
index: int
|
||||
|
||||
|
||||
@router.post("/news")
|
||||
async def admin_create_news(
|
||||
body: CreateNewsRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Create a news item"""
|
||||
if not body.title.strip() or not body.body.strip():
|
||||
raise HTTPException(400, "Title and body are required")
|
||||
ADMIN_NEWS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
news_id = str(int(time.time()))
|
||||
file_path = ADMIN_NEWS_DIR / f"{news_id}.txt"
|
||||
content = f"{body.title.strip()}\n{body.type.strip()}\n{body.version.strip()}\n{body.body.strip()}"
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(current_user["id"], "news_create", f"Created news: {body.title}", ip)
|
||||
return {"success": True, "id": news_id}
|
||||
|
||||
|
||||
@router.post("/news/delete")
|
||||
async def admin_delete_news(
|
||||
body: DeleteNewsRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Delete a news item by displayed index (0 = newest)"""
|
||||
if not ADMIN_NEWS_DIR.exists():
|
||||
raise HTTPException(404, "No news to delete")
|
||||
files = sorted(ADMIN_NEWS_DIR.iterdir())
|
||||
news_files = [f for f in files if f.is_file() and f.suffix == ".txt"]
|
||||
if body.index < 0 or body.index >= len(news_files):
|
||||
raise HTTPException(404, "News not found")
|
||||
# Displayed list is reversed (newest first), so map index
|
||||
target = news_files[len(news_files) - 1 - body.index]
|
||||
target.unlink()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(current_user["id"], "news_delete", f"Deleted news: {target.stem}", ip)
|
||||
return {"success": True, "id": target.stem}
|
||||
|
||||
|
||||
# ====================== PACK MANAGEMENT (ELDER+) ======================
|
||||
|
||||
@router.get("/packs")
|
||||
async def admin_list_packs(
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
):
|
||||
"""List all packs with disabled status"""
|
||||
packs = []
|
||||
packs_config = load_packs_config()
|
||||
|
||||
packs_dir = Path("packs")
|
||||
if packs_dir.exists():
|
||||
for pack_dir in packs_dir.iterdir():
|
||||
if pack_dir.is_dir():
|
||||
entry = packs_config.get(pack_dir.name, {})
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"disabled": entry.get("disabled", False),
|
||||
"presets": get_pack_presets(pack_dir.name)
|
||||
})
|
||||
|
||||
return {"packs": packs}
|
||||
|
||||
|
||||
@router.post("/packs/{pack_name}/toggle")
|
||||
async def admin_toggle_pack(
|
||||
pack_name: str,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Toggle pack disabled/enabled status"""
|
||||
pack_path = PACKS_DIR / pack_name
|
||||
if not pack_path.exists() or not pack_path.is_dir():
|
||||
raise HTTPException(404, "Pack not found")
|
||||
|
||||
new_state = toggle_pack_disabled(pack_name)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"pack_toggle",
|
||||
f"{'Disabled' if new_state else 'Enabled'} pack {pack_name}",
|
||||
ip
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pack_name": pack_name,
|
||||
"disabled": new_state
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_info(current_user: dict = Depends(get_current_user)):
|
||||
"""Информация о текущем пользователе с правами"""
|
||||
|
||||
+23
-2
@@ -295,7 +295,7 @@ class LoginRequest(BaseModel):
|
||||
def validate_username(cls, v):
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', v):
|
||||
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
|
||||
return v.lower()
|
||||
return v
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=32)
|
||||
@@ -305,7 +305,7 @@ class RegisterRequest(BaseModel):
|
||||
def validate_username(cls, v):
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', v):
|
||||
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
|
||||
return v.lower()
|
||||
return v
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
@@ -383,6 +383,7 @@ async def register(body: RegisterRequest, request: Request):
|
||||
|
||||
allowed, wait = check_rate_limit(ip)
|
||||
if not allowed:
|
||||
logger.warning("register rate limited", username=body.username, client_ip=ip)
|
||||
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||
|
||||
with get_db() as conn:
|
||||
@@ -392,6 +393,7 @@ async def register(body: RegisterRequest, request: Request):
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
logger.warning("register failed: username taken", username=body.username, client_ip=ip)
|
||||
raise HTTPException(409, "Пользователь с таким именем уже существует")
|
||||
|
||||
uuid = generate_uuid()
|
||||
@@ -405,6 +407,7 @@ async def register(body: RegisterRequest, request: Request):
|
||||
)
|
||||
|
||||
user_id = cursor.lastrowid
|
||||
logger.info("register ok", username=body.username, user_id=user_id, client_ip=ip)
|
||||
|
||||
# Создаем сессию
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
@@ -456,6 +459,7 @@ async def login(body: LoginRequest, request: Request):
|
||||
|
||||
allowed, wait = check_rate_limit(ip)
|
||||
if not allowed:
|
||||
logger.warning("login rate limited", username=body.username, client_ip=ip)
|
||||
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||
|
||||
with get_db() as conn:
|
||||
@@ -465,15 +469,20 @@ async def login(body: LoginRequest, request: Request):
|
||||
).fetchone()
|
||||
|
||||
if not user or not verify_password(body.password, user["password_hash"]):
|
||||
logger.warning("login failed: bad credentials", username=body.username, client_ip=ip)
|
||||
record_login_attempt(ip, False)
|
||||
raise HTTPException(401, "Неверное имя пользователя или пароль")
|
||||
|
||||
if not user["is_active"]:
|
||||
logger.warning("login failed: account deactivated", username=body.username, client_ip=ip)
|
||||
raise HTTPException(403, "Аккаунт деактивирован")
|
||||
|
||||
if user["banned_until"] and user["banned_until"] > time.time():
|
||||
logger.warning("login failed: account banned", username=body.username, client_ip=ip)
|
||||
raise HTTPException(403, "Аккаунт забанен")
|
||||
|
||||
logger.info("login ok", username=user["username"], user_id=user["id"], client_ip=ip)
|
||||
|
||||
record_login_attempt(ip, True)
|
||||
|
||||
now = time.time()
|
||||
@@ -770,3 +779,15 @@ async def activate_pass(
|
||||
"message": f"Проходка активирована для {uname}",
|
||||
"role": 1,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pass/my")
|
||||
async def my_pass_status(current_user: dict = Depends(get_current_user)):
|
||||
"""Check if current user has an active pass"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT 1 FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
""", (current_user["id"], time.time())).fetchone()
|
||||
return {"has_active": row is not None}
|
||||
|
||||
+41
-3
@@ -15,14 +15,15 @@ def parse_args():
|
||||
mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload")
|
||||
mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers")
|
||||
mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests")
|
||||
|
||||
mode_group.add_argument("--sync", action="store_true", help="Sync mode - sync with main server as mirror")
|
||||
|
||||
# Additional options
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
|
||||
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
|
||||
parser.add_argument("--workers", type=int, default=4, help="Number of workers for production mode")
|
||||
parser.add_argument("--workers", type=int, default=1, help="Number of workers for production mode (default: 1, more causes file download slowdown)")
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
||||
|
||||
return parser.parse_args()
|
||||
return parser.parse_known_args()[0]
|
||||
|
||||
async def run_test_mode():
|
||||
"""Validate all packs and generate/update manifests"""
|
||||
@@ -53,6 +54,43 @@ async def run_test_mode():
|
||||
logger.info("All packs validated successfully")
|
||||
sys.exit(0)
|
||||
|
||||
async def run_sync_mode():
|
||||
"""Sync with main server as mirror"""
|
||||
import os
|
||||
|
||||
main_url = os.environ.get("MAIN_SERVER_URL")
|
||||
if not main_url:
|
||||
logger.error("MAIN_SERVER_URL not set. Run: MAIN_SERVER_URL=http://main:1582 python cli.py --sync")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"Starting mirror sync from {main_url}")
|
||||
|
||||
# Get version from main
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get version
|
||||
try:
|
||||
resp = await client.get(f"{main_url}/launcher/version")
|
||||
data = resp.json()
|
||||
version = data.get("version")
|
||||
logger.info(f"Main server version: {version}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get version from main: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get sync manifest
|
||||
try:
|
||||
resp = await client.get(f"{main_url}/launcher/sync/{version}")
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Files to sync: {len(sync_data.get('files', []))}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get sync manifest: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Sync happens during server startup in mirror mode
|
||||
# Just verify we can reach main
|
||||
logger.info("Mirror sync configured. Server will sync on startup.")
|
||||
|
||||
def run_production_mode(host: str, port: int, workers: int):
|
||||
"""Run with multiple workers"""
|
||||
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import structlog
|
||||
import time
|
||||
|
||||
from auth import get_db, get_current_user
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["friends"])
|
||||
|
||||
def init_friends_db():
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS friendships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
requester_id INTEGER NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(requester_id, target_id),
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id),
|
||||
FOREIGN KEY (target_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_status (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
is_online INTEGER DEFAULT 0,
|
||||
current_pack TEXT DEFAULT '',
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_target ON friendships(target_id);
|
||||
""")
|
||||
|
||||
class AddFriendRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
class RemoveFriendRequest(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class AcceptFriendRequest(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class StatusUpdateRequest(BaseModel):
|
||||
online: bool = True
|
||||
current_pack: Optional[str] = None
|
||||
|
||||
@router.post("/friends/add")
|
||||
async def add_friend(
|
||||
req: AddFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute("SELECT id FROM users WHERE username = ?", (req.username,))
|
||||
target = cursor.fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
target_id = target[0]
|
||||
|
||||
if target_id == current_user["id"]:
|
||||
raise HTTPException(400, "Cannot add yourself")
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT status FROM friendships WHERE requester_id = ? AND target_id = ?",
|
||||
(current_user["id"], target_id)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
if existing[0] == "accepted":
|
||||
raise HTTPException(400, "Already friends")
|
||||
raise HTTPException(400, f"Friend request already {existing[0]}")
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO friendships (requester_id, target_id, status) VALUES (?, ?, 'pending')",
|
||||
(current_user["id"], target_id)
|
||||
)
|
||||
logger.info("Friend request sent", from_user=current_user["id"], to_user=target_id)
|
||||
return {"message": "Friend request sent"}
|
||||
|
||||
@router.post("/friends/accept")
|
||||
async def accept_friend(
|
||||
req: AcceptFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, requester_id FROM friendships WHERE target_id = ? AND requester_id = ? AND status = 'pending'",
|
||||
(current_user["id"], req.user_id)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "No pending friend request from this user")
|
||||
conn.execute("UPDATE friendships SET status = 'accepted' WHERE id = ?", (row[0],))
|
||||
logger.info("Friend request accepted", from_user=req.user_id, to_user=current_user["id"])
|
||||
return {"message": "Friend request accepted"}
|
||||
|
||||
@router.post("/friends/remove")
|
||||
async def remove_friend(
|
||||
req: RemoveFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM friendships WHERE (requester_id = ? AND target_id = ?) OR (requester_id = ? AND target_id = ?)",
|
||||
(current_user["id"], req.user_id, req.user_id, current_user["id"])
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Not friends")
|
||||
conn.execute("DELETE FROM friendships WHERE id = ?", (row[0],))
|
||||
logger.info("Friend removed", user=current_user["id"], target=req.user_id)
|
||||
return {"message": "Friend removed"}
|
||||
|
||||
@router.get("/friends/list")
|
||||
async def list_friends(current_user: dict = Depends(get_current_user)):
|
||||
friends = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role,
|
||||
COALESCE(us.is_online, 0) as online,
|
||||
COALESCE(us.current_pack, '') as current_pack,
|
||||
us.last_seen
|
||||
FROM friendships f
|
||||
JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id
|
||||
LEFT JOIN user_status us ON u.id = us.user_id
|
||||
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
|
||||
""", (current_user["id"], current_user["id"], current_user["id"]))
|
||||
|
||||
for row in rows:
|
||||
friends.append({
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
"role": row[2],
|
||||
"online": bool(row[3]),
|
||||
"current_pack": row[4],
|
||||
"last_seen": row[5] if row[5] else None
|
||||
})
|
||||
|
||||
return {"friends": friends}
|
||||
|
||||
@router.get("/friends/requests")
|
||||
async def list_friend_requests(current_user: dict = Depends(get_current_user)):
|
||||
requests = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role, f.created_at
|
||||
FROM friendships f
|
||||
JOIN users u ON f.requester_id = u.id
|
||||
WHERE f.target_id = ? AND f.status = 'pending'
|
||||
""", (current_user["id"],))
|
||||
for row in rows:
|
||||
requests.append({
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
"role": row[2],
|
||||
"created_at": row[3] if row[3] else None
|
||||
})
|
||||
return {"requests": requests}
|
||||
|
||||
@router.post("/friends/status")
|
||||
async def update_status(
|
||||
req: StatusUpdateRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
is_online = excluded.is_online,
|
||||
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
|
||||
last_seen = CURRENT_TIMESTAMP
|
||||
""", (current_user["id"], int(req.online), req.current_pack or ""))
|
||||
return {"status": "ok"}
|
||||
+1495
-91
File diff suppressed because it is too large
Load Diff
+182
-17
@@ -5,43 +5,208 @@ import logging
|
||||
import time
|
||||
import uuid
|
||||
import traceback
|
||||
import httpx
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Public blocklist URLs
|
||||
BLOCKLIST_URLS = [
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset",
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/iblocklist_isp.netset",
|
||||
]
|
||||
|
||||
|
||||
def load_blocklist_from_url(url: str, timeout: int = 10) -> set[str]:
|
||||
"""Download and parse IP blocklist from URL"""
|
||||
ips = set()
|
||||
try:
|
||||
response = httpx.get(url, timeout=timeout, follow_redirects=True)
|
||||
if response.status_code == 200:
|
||||
for line in response.text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if re.match(r"^\d+\.\d+\.\d+\.\d+(/\d+)?$", line):
|
||||
ip = line.split("/")[0]
|
||||
ips.add(ip)
|
||||
logger.info(f"Loaded {len(ips)} IPs from blocklist: {url}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load blocklist from {url}: {e}")
|
||||
return ips
|
||||
|
||||
|
||||
def load_public_blocklists() -> set[str]:
|
||||
"""Load all public blocklists"""
|
||||
all_ips = set()
|
||||
for url in BLOCKLIST_URLS:
|
||||
all_ips.update(load_blocklist_from_url(url))
|
||||
logger.info(f"Total blocked IPs from public lists: {len(all_ips)}")
|
||||
return all_ips
|
||||
|
||||
|
||||
# Rate limiting config
|
||||
RATE_LIMIT_REQUESTS = 60 # Max requests per window
|
||||
RATE_LIMIT_WINDOW = 60 # Window in seconds
|
||||
_ip_request_counts: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
# IP blocking config (set from main.py)
|
||||
BLOCKED_IPS: set[str] = set()
|
||||
|
||||
# Request stats (for summary logging)
|
||||
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
|
||||
_stats_last_log = time.time()
|
||||
STATS_LOG_INTERVAL = 60 # Log stats every 60 seconds
|
||||
|
||||
# Suspicious paths that indicate bot scanning
|
||||
SUSPICIOUS_PATHS = {
|
||||
".env", ".env.local", ".env.production", ".env.development", ".env.bak",
|
||||
".env.old", ".env.backup", ".env.orig", ".env.save", ".env~", ".env.swp",
|
||||
".env.copy", ".env.1", ".ENV",
|
||||
"appsettings.json", "appsettings.Development.json", "appsettings.Production.json",
|
||||
"appsettings.Staging.json", "web.config",
|
||||
"phpinfo.php", "info.php", "test.php", "i.php", "phpi.php", "php.php",
|
||||
"phptest.php", "server-info.php", "phpinformation.php", "infophp.php",
|
||||
"php_info.php", "config.php",
|
||||
"actuator/env", "actuator/configprops", "actuator",
|
||||
"manage/env", "admin/env", "env",
|
||||
"actuator/env/aws", "actuator/env/cloud",
|
||||
"_layouts/15/", "_layouts/15/ToolPane.aspx",
|
||||
"wp-admin", "wp-login.php", "wordpress",
|
||||
"administrator", "phpmyadmin",
|
||||
".git", ".svn", ".hg",
|
||||
}
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP from request"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
client_ip = forwarded.split(",")[0].strip()
|
||||
return client_ip
|
||||
|
||||
|
||||
def is_ip_blocked(client_ip: str) -> bool:
|
||||
"""Check if IP is blocked"""
|
||||
return client_ip in BLOCKED_IPS
|
||||
|
||||
|
||||
def check_rate_limit(client_ip: str) -> bool:
|
||||
"""Check if IP has exceeded rate limit"""
|
||||
now = time.time()
|
||||
|
||||
# Clean old requests
|
||||
_ip_request_counts[client_ip] = [
|
||||
t for t in _ip_request_counts[client_ip]
|
||||
if now - t < RATE_LIMIT_WINDOW
|
||||
]
|
||||
|
||||
if len(_ip_request_counts[client_ip]) >= RATE_LIMIT_REQUESTS:
|
||||
return False
|
||||
|
||||
_ip_request_counts[client_ip].append(now)
|
||||
return True
|
||||
|
||||
|
||||
def is_suspicious_path(path: str) -> bool:
|
||||
"""Check if path is suspicious (bot scanning)"""
|
||||
path_lower = path.lower()
|
||||
|
||||
# Direct match
|
||||
if path_lower in SUSPICIOUS_PATHS:
|
||||
return True
|
||||
|
||||
# Contains suspicious patterns
|
||||
suspicious_patterns = [
|
||||
".env", "phpinfo", "actuator", "wp-", "phpmyadmin",
|
||||
".git", ".svn",
|
||||
]
|
||||
|
||||
for pattern in suspicious_patterns:
|
||||
if pattern in path_lower:
|
||||
return True
|
||||
|
||||
# Path traversal attempts
|
||||
if ".." in path or ".." in path.replace("%2e%2e", "").replace("%252e", ""):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def set_ip_config(blocked: Optional[set[str]] = None):
|
||||
"""Configure IP blocking (call from main.py)"""
|
||||
global BLOCKED_IPS
|
||||
if blocked is not None:
|
||||
BLOCKED_IPS = blocked
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Generate request ID
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
global _stats, _stats_last_log
|
||||
|
||||
# Get client IP
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
client_ip = forwarded.split(",")[0].strip()
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Log incoming request
|
||||
logger.info(f"→ {request.method} {request.url.path} (IP: {client_ip}, ID: {request_id})")
|
||||
# Check if IP is blocked (silent)
|
||||
if is_ip_blocked(client_ip):
|
||||
_stats["blocked"] += 1
|
||||
return Response(status_code=404, content="")
|
||||
|
||||
# Start timer
|
||||
path = request.url.path
|
||||
|
||||
# Skip rate limiting for pack file downloads (direct high-speed serving)
|
||||
if path.startswith("/pack/") and "/file/" in path:
|
||||
is_file_download = True
|
||||
elif path.startswith(("/skin/", "/css/", "/js/", "/img/", "/highlights/")):
|
||||
# SkinRestorer skins + landing site assets must not be rate-limited
|
||||
is_file_download = True
|
||||
else:
|
||||
is_file_download = False
|
||||
|
||||
# Check rate limit
|
||||
if not check_rate_limit(client_ip):
|
||||
_stats["rate_limited"] += 1
|
||||
if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
|
||||
logger.warning(f"Stats: {_stats}")
|
||||
_stats_last_log = time.time()
|
||||
return Response(status_code=429, content="Too many requests")
|
||||
|
||||
# Check suspicious path (silent 404 for bots)
|
||||
if is_suspicious_path(path):
|
||||
# Return 404 without logging - confuse the bots
|
||||
return Response(status_code=404, content="")
|
||||
|
||||
# Track total requests for stats
|
||||
_stats["total"] += 1
|
||||
|
||||
# Log legitimate requests (except file downloads)
|
||||
start_time = time.time()
|
||||
|
||||
if not is_file_download:
|
||||
logger.info(f"→ {request.method} {path} (IP: {client_ip}, ID: {request_id})")
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# Calculate duration
|
||||
duration = (time.time() - start_time) * 1000
|
||||
|
||||
# Log response
|
||||
logger.info(f"← {request.method} {request.url.path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
|
||||
if not is_file_download:
|
||||
logger.info(f"← {request.method} {path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
|
||||
|
||||
# Periodic stats logging (only log if there were blocked/rate-limited)
|
||||
now = time.time()
|
||||
if now - _stats_last_log > STATS_LOG_INTERVAL:
|
||||
if _stats["blocked"] > 0 or _stats["rate_limited"] > 0:
|
||||
logger.warning(f"Blocked requests: IP_blocked={_stats['blocked']}, rate_limited={_stats['rate_limited']}")
|
||||
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
|
||||
_stats_last_log = now
|
||||
|
||||
# Add request ID to response headers
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
# Log full traceback
|
||||
error_traceback = traceback.format_exc()
|
||||
logger.error(f"✗ {request.method} {request.url.path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
|
||||
logger.error(f"✗ {request.method} {path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
|
||||
raise
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lightweight Mirror Server - only serves static files
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import structlog
|
||||
import httpx
|
||||
|
||||
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582")
|
||||
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta")
|
||||
PORT = int(os.environ.get("PORT", "1582"))
|
||||
|
||||
BUILDS_DIR = Path("builds")
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
PACKS_DIR = Path("packs")
|
||||
|
||||
BUILDS_DIR.mkdir(exist_ok=True)
|
||||
PACKS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logging = structlog.get_logger()
|
||||
|
||||
|
||||
async def sync_with_main():
|
||||
"""Sync files from main server"""
|
||||
logging.info(f"Syncing from {MAIN_SERVER_URL}")
|
||||
|
||||
client = httpx.AsyncClient(timeout=120.0)
|
||||
headers = {"X-Master-Key": MASTER_KEY}
|
||||
|
||||
try:
|
||||
# Get launcher info
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logging.error(f"Failed to get launcher info: {resp.status_code}")
|
||||
return
|
||||
|
||||
data = resp.json()
|
||||
current_version = data.get("current_version", "1.0.9")
|
||||
files = data.get("files", {})
|
||||
zips = files.get("zips", [])
|
||||
logging.info(f"Current version: {current_version}, zips: {len(zips)}")
|
||||
|
||||
# Download latest ZIP
|
||||
for z in zips:
|
||||
if not z.get("is_legacy"):
|
||||
zip_filename = z.get("filename")
|
||||
zip_path = BUILDS_DIR / zip_filename
|
||||
if not zip_path.exists():
|
||||
logging.info(f"Downloading {zip_filename}...")
|
||||
# Try direct download
|
||||
download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}"
|
||||
resp = await client.get(download_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
zip_path.write_bytes(resp.content)
|
||||
logging.info(f"Downloaded {zip_filename}")
|
||||
|
||||
# Extract
|
||||
version = z.get("version")
|
||||
extract_to = VERSIONS_DIR / version
|
||||
extract_to.mkdir(parents=True, exist_ok=True)
|
||||
import zipfile
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
zf.extractall(extract_to)
|
||||
logging.info(f"Extracted {version}")
|
||||
|
||||
# Get launcher meta
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
(BUILDS_DIR / "meta.json").write_text(resp.text)
|
||||
logging.info("Meta synced")
|
||||
|
||||
# Sync packs list
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
packs_data = resp.json()
|
||||
packs = packs_data.get("packs", [])
|
||||
logging.info(f"Found {len(packs)} packs")
|
||||
|
||||
for pack in packs:
|
||||
pack_name = pack.get("name")
|
||||
pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}"
|
||||
resp = await client.get(pack_meta_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
pack_dir = PACKS_DIR / pack_name
|
||||
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||
(pack_dir / "meta.json").write_text(resp.text)
|
||||
logging.info(f"Synced pack: {pack_name}")
|
||||
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
logging.info("Sync complete")
|
||||
|
||||
|
||||
async def run_server():
|
||||
"""Run static server"""
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
import aiofiles
|
||||
import mimetypes
|
||||
import re
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="ZernMC Mirror")
|
||||
|
||||
async def send_file(file_path: Path, request: Request):
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
if range_header:
|
||||
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
|
||||
if match:
|
||||
start = int(match.group(1))
|
||||
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
|
||||
content_length = end - start + 1
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
await f.seek(start)
|
||||
chunk = await f.read(content_length)
|
||||
return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type,
|
||||
headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)})
|
||||
|
||||
async def file_iter():
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(file_iter(), media_type=content_type,
|
||||
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)})
|
||||
|
||||
@app.get("/launcher/info")
|
||||
async def get_launcher_info():
|
||||
meta_path = BUILDS_DIR / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
return {"current_version": "unknown", "files": {}}
|
||||
|
||||
@app.get("/launcher/version")
|
||||
async def get_version():
|
||||
return await get_launcher_info()
|
||||
|
||||
@app.get("/launcher/file/{version}/{file_path:path}")
|
||||
async def get_launcher_file(version: str, file_path: str, request: Request):
|
||||
full_path = BUILDS_DIR / "versions" / version / file_path
|
||||
if ".." in file_path:
|
||||
raise HTTPException(403, "Invalid path")
|
||||
if not full_path.exists():
|
||||
raise HTTPException(404, f"File not found: {file_path}")
|
||||
return await send_file(full_path, request)
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_zip(filename: str, request: Request):
|
||||
return await send_file(BUILDS_DIR / filename, request)
|
||||
|
||||
@app.get("/launcher/meta/{version}")
|
||||
async def get_meta(version: str):
|
||||
meta_path = BUILDS_DIR / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
raise HTTPException(404, "Meta not found")
|
||||
|
||||
@app.get("/launcher/mirrors")
|
||||
async def get_mirrors():
|
||||
return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]}
|
||||
|
||||
@app.get("/packs")
|
||||
async def list_packs():
|
||||
import json
|
||||
packs = []
|
||||
for pack_dir in PACKS_DIR.iterdir():
|
||||
if pack_dir.is_dir():
|
||||
meta_path = pack_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"version": meta.get("version", 1),
|
||||
"files_count": len(meta.get("files", {}))
|
||||
})
|
||||
except:
|
||||
packs.append({"name": pack_dir.name, "error": "invalid"})
|
||||
return {"packs": packs}
|
||||
|
||||
@app.get("/pack/{pack_name}")
|
||||
async def get_pack(pack_name: str):
|
||||
meta_path = PACKS_DIR / pack_name / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
raise HTTPException(404, "Pack not found")
|
||||
|
||||
@app.get("/pack/meta/{pack_name}")
|
||||
async def get_pack_meta(pack_name: str):
|
||||
return await get_pack(pack_name)
|
||||
|
||||
@app.get("/pack/{pack_name}/diff")
|
||||
async def get_pack_diff(pack_name: str):
|
||||
# For mirror, just return empty diff (no local changes)
|
||||
return {"added": [], "removed": [], "changed": []}
|
||||
|
||||
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||
async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
return await send_file(PACKS_DIR / pack_name / file_path, request)
|
||||
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
async def main():
|
||||
logging.info("Starting ZernMC Mirror Server")
|
||||
await sync_with_main()
|
||||
await run_server()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,10 @@
|
||||
Pack Download Stability Fix
|
||||
Update
|
||||
v1.0.13.1
|
||||
[{"bold":1,"text":"Fixed pack downloads getting stuck on Minecraft/Forge installation","click_action":"none"}]
|
||||
- All Minecraft downloads now use smart proxy system (like the rest of the launcher)
|
||||
- Added read timeouts to prevent hanging on slow/unstable connections
|
||||
- Asset download no longer blocks forever if Mojang CDN stalls
|
||||
- Forge installer process now has a 10-minute timeout
|
||||
- Progress bar now updates during Minecraft installation phase
|
||||
[{"clickable":1,"text":"Download v1.0.13.1","click_action":"open_url","url":"https://zernmc.ru/download"}]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user