Compare commits
191 Commits
dcd5e91ebe
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 025a21d01f | |||
| 146a56cdff | |||
| 3006408447 | |||
| 48f73ef0d4 | |||
| b864e17921 | |||
| 1900db3caf | |||
| 5944c7b1b6 | |||
| 19a1633ec4 | |||
| 75738ae127 | |||
| 5985887fb8 | |||
| 8d5bcca1d5 | |||
| bcd10cce06 | |||
| 592de204b6 | |||
| af415157ac | |||
| 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 | |||
| 896b58472f | |||
| a501dd1d99 | |||
| 37e7c990a7 | |||
| 14da6c1a45 | |||
| a80c00f91c | |||
| ff921def7f | |||
| ba4eea8c4e | |||
| 7daaedd327 | |||
| 95e5338635 | |||
| d78c6c27a5 | |||
| 466f155e0e | |||
| 014ffc4fae | |||
| ec8e4c05d2 | |||
| ae7532eec0 | |||
| 7b2a571180 | |||
| ce8237c0db | |||
| 75d87084e3 | |||
| 18919f7a8e | |||
| 1c2ae28511 | |||
| 9e003fe1a1 | |||
| 6bd49217d0 | |||
| 3afd832db9 | |||
| 81aebef6a5 | |||
| efff2d4ef6 | |||
| 46cafa5250 | |||
| 7ea88e0a27 | |||
| b29698a3a2 | |||
| c66a8d372a | |||
| 8d24bb9a92 | |||
| e6dacdcb61 | |||
| e6e4d88aec | |||
| 9657053e08 | |||
| 27aafeaab3 | |||
| af3183c6ef | |||
| 5627c9cc6a | |||
| f0973e93df | |||
| 02608a9856 | |||
| c5c119fcb6 | |||
| bce719bdc1 | |||
| 2509a9b4e9 | |||
| 7696cfb9fb | |||
| 27a0366fb8 | |||
| f3a6bb21a0 | |||
| 911f4120dd | |||
| 81c34b44e6 | |||
| 1c5beff21d | |||
| ea7b77b549 | |||
| 7fb2cdc059 | |||
| a10bc16d86 | |||
| 385d33ae5b | |||
| 06a1b71ab8 | |||
| f5cbd2e649 | |||
| f1ab0a6cdf | |||
| 27dcd97c15 | |||
| ca522e229b | |||
| 897637434b | |||
| b03bb845a6 |
@@ -20,3 +20,4 @@ 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,12 @@
|
||||
module zernmc-installer
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 // indirect
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
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/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
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=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
@@ -0,0 +1,324 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sqweek/dialog"
|
||||
)
|
||||
|
||||
//go:embed offline.zip
|
||||
var offlineFS embed.FS
|
||||
|
||||
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 embedded 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()
|
||||
isOnline := *onlineFlag && !*offlineFlag
|
||||
// GUI mode on Windows unless --cli
|
||||
hasCLI := false
|
||||
for _, a := range os.Args { if a=="--cli" { hasCLI=true } }
|
||||
if runtime.GOOS=="windows" && !hasCLI {
|
||||
// try GUI, fallback to console on error
|
||||
if err := runGUI(isOnline); err != nil {
|
||||
fmt.Printf("GUI failed %v, fallback console\n", err)
|
||||
runConsole(isOnline)
|
||||
}
|
||||
return
|
||||
}
|
||||
runConsole(isOnline)
|
||||
}
|
||||
|
||||
func runConsole(isOnline bool) {
|
||||
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") { createShortcut = askShortcut() }
|
||||
var err error
|
||||
if isOnline { err = installOnline(dir, createShortcut, nil) } else {
|
||||
zipPath := *offlineZipFlag
|
||||
if zipPath=="" { zipPath = findAdjacentOfflineZip() }
|
||||
if zipPath=="" {
|
||||
// try embedded
|
||||
err = installOfflineEmbedded(dir, createShortcut)
|
||||
} else { err = installOffline(dir, zipPath, createShortcut) }
|
||||
}
|
||||
if err != nil { fatal("%v", err) }
|
||||
fmt.Println("\nInstallation complete!")
|
||||
}
|
||||
|
||||
func runGUI(isOnline bool) error {
|
||||
// Fallback to console dialogs if GUI not available (walk requires windows)
|
||||
// For now use sqweek/dialog wizard to mimic Inno bootstrap
|
||||
dir := *installDirFlag
|
||||
if dir == "" {
|
||||
// Welcome
|
||||
ok := dialog.Message("Добро пожаловать в ZernMC\n\nМастер установит ZernMC Launcher на ваш компьютер.\nОнлайн — скачает 47М JRE + 50М лаунчер | Оффлайн — распакует встроенный архив\n\nНажмите ОК чтобы выбрать папку C:\\ZernMC").Title("ZernMC Setup — Приветствие").YesNo()
|
||||
if !ok { return fmt.Errorf("cancelled") }
|
||||
d, err := dialog.Directory().Title("Выберите папку установки — ZernMC").Browse()
|
||||
if err != nil || d=="" { d = defaultInstallDir }
|
||||
if !strings.Contains(strings.ToLower(d), "zernmc") { d = filepath.Join(d, "ZernMC") }
|
||||
dir = d
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil { dialog.Message("Ошибка создания папки: %v", err).Title("Ошибка").Error(); return err }
|
||||
createShortcut := *shortcutFlag
|
||||
if !isFlagSet("shortcut") { createShortcut = dialog.Message("Создать ярлык на рабочем столе?").Title("Ярлык").YesNo() }
|
||||
// Progress via console + dialog
|
||||
fmt.Printf("Установка в %s ...\n", dir)
|
||||
var err error
|
||||
if isOnline {
|
||||
err = installOnline(dir, createShortcut, func(cur, total int, name string){
|
||||
fmt.Printf("[%d/%d] %s\n", cur, total, name)
|
||||
})
|
||||
} else {
|
||||
zipPath := *offlineZipFlag
|
||||
if zipPath=="" { zipPath = findAdjacentOfflineZip() }
|
||||
if zipPath!="" { err = installOffline(dir, zipPath, createShortcut) } else { err = installOfflineEmbedded(dir, createShortcut) }
|
||||
}
|
||||
if err != nil { dialog.Message("Ошибка установки: %v", err).Title("Ошибка").Error(); return err }
|
||||
dialog.Message("Установка завершена!\n\nЗапустите с ярлыка или C:\\ZernMC\\zernmc.exe").Title("ZernMC Setup — Готово").Info()
|
||||
// launch?
|
||||
if dialog.Message("Запустить ZernMC сейчас?").Title("Готово").YesNo() {
|
||||
exec.Command(filepath.Join(dir, "zernmc.exe")).Start()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isFlagSet(name string) bool { found:=false; flag.Visit(func(f *flag.Flag){ if f.Name==name { found=true } }); return found }
|
||||
func askInstallDir() string {
|
||||
if runtime.GOOS=="windows" {
|
||||
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
|
||||
if err==nil && dir!="" {
|
||||
if !strings.Contains(strings.ToLower(dir), "zernmc") { dir = filepath.Join(dir, "ZernMC") }
|
||||
return dir
|
||||
}
|
||||
fmt.Printf("Dialog cancelled (%v), using default %s\n", err, defaultInstallDir)
|
||||
}
|
||||
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 }
|
||||
return dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
|
||||
}
|
||||
func fatal(format string, args ...interface{}) {
|
||||
msg:=fmt.Sprintf(format, args...); fmt.Fprintln(os.Stderr, msg)
|
||||
if runtime.GOOS=="windows" { dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error() }
|
||||
os.Exit(1)
|
||||
}
|
||||
func installOnline(dir string, createShortcut bool, progress func(cur,total int, name string)) error {
|
||||
fmt.Println("Online installation — downloading JRE + launcher files...")
|
||||
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 {
|
||||
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 { return fmt.Errorf("unzip JRE: %w", err) }
|
||||
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 { 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") }
|
||||
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
|
||||
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 }
|
||||
if progress!=nil { progress(i+1, len(meta.Files), rel) }
|
||||
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)
|
||||
if err:=downloadFile(url, dest); err!=nil { return fmt.Errorf("download %s: %w", rel, err) }
|
||||
hash, _:=fileSHA256(dest)
|
||||
if hash!=strings.TrimPrefix(f.Hash, "sha256:") { fmt.Printf(" WARNING hash mismatch for %s\n", rel) }
|
||||
}
|
||||
if err:=os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err!=nil { fmt.Printf("warning write build.version: %v\n", err) }
|
||||
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 installOfflineEmbedded(dir string, createShortcut bool) error {
|
||||
fmt.Println("Offline embedded installation...")
|
||||
data, err:=offlineFS.ReadFile("offline.zip")
|
||||
if err!=nil {
|
||||
// try adjacent
|
||||
zp:=findAdjacentOfflineZip()
|
||||
if zp!="" { return installOffline(dir, zp, createShortcut) }
|
||||
return fmt.Errorf("offline.zip not embedded, put ZernMC-win-*.zip next to installer or rebuild with offline.zip: %w", err)
|
||||
}
|
||||
tmp:=filepath.Join(os.TempDir(), fmt.Sprintf("offline-%d.zip", time.Now().UnixNano()))
|
||||
if err:=os.WriteFile(tmp, data, 0644); err!=nil { return err }
|
||||
defer os.Remove(tmp)
|
||||
fmt.Println("Unpacking embedded (98 MB)...")
|
||||
if err:=unzip(tmp, dir); err!=nil { return fmt.Errorf("unzip: %w", err) }
|
||||
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")
|
||||
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)...")
|
||||
if err:=unzip(zipPath, dir); err!=nil { return fmt.Errorf("unzip: %w", err) }
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
func downloadFile(url, dest string) error {
|
||||
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) }
|
||||
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()
|
||||
if err:=os.Rename(tmp, dest); err!=nil { 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)
|
||||
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) {
|
||||
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)
|
||||
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] }
|
||||
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 {
|
||||
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\necho Uninstalling ZernMC from %s\ntimeout /t 2 >nul\ndel \"%%%%USERPROFILE%%%%\\Desktop\\ZernMC Launcher.lnk\" 2>nul\ndel \"%%%%USERPROFILE%%%%\\OneDrive\\Desktop\\ZernMC Launcher.lnk\" 2>nul\necho Data in %%%%USERPROFILE%%%%\\.zernmc will be kept.\n", 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() }
|
||||
@@ -47,13 +47,43 @@ public class Bootstrap {
|
||||
return binDir.resolve(JAR_NAME);
|
||||
}
|
||||
|
||||
private static Path resolveBaseDir() {
|
||||
// 1) Стартуем от папки, где лежит сам .exe (для launch4j codeSource -> путь к exe).
|
||||
Path exeDir = null;
|
||||
try {
|
||||
java.security.CodeSource cs = Bootstrap.class.getProtectionDomain().getCodeSource();
|
||||
if (cs != null && cs.getLocation() != null) {
|
||||
Path loc = Paths.get(cs.getLocation().toURI()).toAbsolutePath();
|
||||
if (!Files.isDirectory(loc)) {
|
||||
loc = loc.getParent(); // jar/exe -> его папка
|
||||
}
|
||||
exeDir = loc;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (exeDir != null) {
|
||||
// exe в bin/ -> база родитель (lib/jre21/assets в корне)
|
||||
Path parent = exeDir.getParent();
|
||||
Path candidates[] = { exeDir, parent }; // порядок: корень дистрибутива, затем родитель
|
||||
for (Path c : candidates) {
|
||||
if (c == null) continue;
|
||||
if (Files.exists(c.resolve("bin").resolve(JAR_NAME))) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) Fallback: текущая рабочая папка (старое поведение).
|
||||
return Paths.get("").toAbsolutePath();
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
baseDir = Paths.get("").toAbsolutePath();
|
||||
baseDir = resolveBaseDir();
|
||||
binDir = baseDir.resolve("bin");
|
||||
Files.createDirectories(binDir);
|
||||
logDir = baseDir.resolve("logs");
|
||||
Files.createDirectories(logDir);
|
||||
javafxPath = baseDir.resolve("lib").resolve("javafx");
|
||||
rotateLogs();
|
||||
|
||||
log("=== ZernMC Launcher ===");
|
||||
|
||||
@@ -77,6 +107,19 @@ public class Bootstrap {
|
||||
log("Local version: " + currentVersion);
|
||||
log("Server version: " + serverVersion);
|
||||
setVersionInfo(currentVersion, serverVersion);
|
||||
|
||||
if ("unknown".equals(serverVersion)) {
|
||||
boolean ranDiagnostics = runDiagnosticsIfPossible();
|
||||
if (ranDiagnostics) {
|
||||
serverVersion = getServerVersion();
|
||||
log("Server version after diagnostics: " + serverVersion);
|
||||
setVersionInfo(currentVersion, serverVersion);
|
||||
}
|
||||
if ("unknown".equals(serverVersion) && !argList.contains("--offline")) {
|
||||
log("Server still unreachable. Launcher will start in offline mode.");
|
||||
argList.add("--offline");
|
||||
}
|
||||
}
|
||||
|
||||
loadMirrors();
|
||||
log("Primary server: " + BASE_URL);
|
||||
@@ -124,6 +167,119 @@ public class Bootstrap {
|
||||
launchInNewProcess(args);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean runDiagnosticsIfPossible() {
|
||||
Path diagJar = binDir.resolve("zernmcdiag.jar");
|
||||
if (!Files.exists(diagJar)) {
|
||||
log("Diagnostics tool not found: bin/zernmcdiag.jar is missing.");
|
||||
log("Please download the latest launcher manually from the ZernMC website.");
|
||||
showMessageUi("ZernMC — нет связи с сервером",
|
||||
"Не удалось связаться с серверами Zern.\n\n"
|
||||
+ "Файл диагностики (bin/zernmcdiag.jar) отсутствует.\n"
|
||||
+ "Скачайте свежий лаунчер вручную с официального сайта ZernMC.");
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean confirmed;
|
||||
if (isCliMode) {
|
||||
confirmed = askCliConfirm();
|
||||
} else {
|
||||
confirmed = confirmUi("ZernMC — нет связи с сервером",
|
||||
"Не удалось связаться с серверами Zern.\nПровести диагностику сети?");
|
||||
}
|
||||
if (!confirmed) {
|
||||
log("Diagnostics declined by user.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
launchDiag(diagJar);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log("Failed to launch diagnostics: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void launchDiag(Path diagJar) throws Exception {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
Path javaBin = findJava(false);
|
||||
if (os.contains("windows")) {
|
||||
Path javawPath = javaBin.resolveSibling("javaw.exe");
|
||||
if (Files.exists(javawPath)) {
|
||||
javaBin = javawPath;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(javaBin.toAbsolutePath().toString());
|
||||
cmd.add("-Dfile.encoding=UTF-8");
|
||||
cmd.add("-jar");
|
||||
cmd.add(diagJar.toAbsolutePath().toString());
|
||||
if (isCliMode) {
|
||||
cmd.add("--cli");
|
||||
}
|
||||
|
||||
log("Starting diagnostics: " + String.join(" ", cmd));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.directory(baseDir.toFile());
|
||||
pb.redirectErrorStream(false);
|
||||
|
||||
Process p = pb.start();
|
||||
drainOutput(p.getInputStream(), "diag");
|
||||
drainOutput(p.getErrorStream(), "diag-err");
|
||||
int exit = p.waitFor();
|
||||
log("Diagnostics finished, exit code: " + exit);
|
||||
}
|
||||
|
||||
private static void drainOutput(InputStream stream, String tag) {
|
||||
Thread t = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
log("[" + tag + "] " + line);
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}, "diag-drain-" + tag);
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
private static boolean askCliConfirm() {
|
||||
System.out.println("Server unreachable. Run network diagnostics? [y/N]");
|
||||
try {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||
String line = reader.readLine();
|
||||
return line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"));
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean confirmUi(String title, String message) {
|
||||
final boolean[] result = new boolean[1];
|
||||
try {
|
||||
SwingUtilities.invokeAndWait(() -> {
|
||||
int choice = JOptionPane.showConfirmDialog(null, message, title,
|
||||
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
|
||||
result[0] = choice == JOptionPane.YES_OPTION;
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log("UI confirm error: " + e.getMessage());
|
||||
}
|
||||
return result[0];
|
||||
}
|
||||
|
||||
private static void showMessageUi(String title, String message) {
|
||||
try {
|
||||
SwingUtilities.invokeAndWait(() ->
|
||||
JOptionPane.showMessageDialog(null, message, title, JOptionPane.INFORMATION_MESSAGE));
|
||||
} catch (Exception e) {
|
||||
log("UI message error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void launchInProcess(String[] args) throws Exception {
|
||||
ClassLoader parent = Bootstrap.class.getClassLoader();
|
||||
@@ -171,6 +327,12 @@ public class Bootstrap {
|
||||
cmd.add("-jar");
|
||||
cmd.add(getLauncherJar().toAbsolutePath().toString());
|
||||
cmd.add("--jfx");
|
||||
|
||||
for (String arg : args) {
|
||||
if (!"--jfx".equals(arg) && !"--cli".equals(arg) && !cmd.contains(arg)) {
|
||||
cmd.add(arg);
|
||||
}
|
||||
}
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.directory(baseDir.toFile());
|
||||
@@ -239,11 +401,50 @@ public class Bootstrap {
|
||||
return javaBin;
|
||||
}
|
||||
|
||||
private static void rotateLogs() {
|
||||
// Minecraft-style: archive previous latest.log as .log.gz, keep unlimited.
|
||||
try {
|
||||
Path latest = logDir.resolve("latest.log");
|
||||
if (Files.exists(latest) && Files.size(latest) > 0) {
|
||||
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
|
||||
int n = 1;
|
||||
Path archive;
|
||||
do {
|
||||
archive = logDir.resolve(ts + (n == 1 ? "" : "-" + n) + ".log.gz");
|
||||
n++;
|
||||
} while (Files.exists(archive));
|
||||
gzip(archive, latest);
|
||||
Files.deleteIfExists(latest);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[Bootstrap] rotate error: " + e.getMessage());
|
||||
}
|
||||
// Migrate old unlimited log.
|
||||
try {
|
||||
Files.deleteIfExists(logDir.resolve("launcher.log"));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void gzip(Path archive, Path source) {
|
||||
try (java.io.InputStream in = Files.newInputStream(source);
|
||||
java.io.OutputStream out = Files.newOutputStream(archive);
|
||||
java.util.zip.GZIPOutputStream gz = new java.util.zip.GZIPOutputStream(out)) {
|
||||
byte[] buf = new byte[65536];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
gz.write(buf, 0, n);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[Bootstrap] gzip error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void log(String msg) {
|
||||
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
|
||||
System.out.println(entry);
|
||||
try {
|
||||
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||
Files.writeString(logDir.resolve("latest.log"), entry + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (Exception ignored) {}
|
||||
if (ui != null) ui.setStatus(msg);
|
||||
@@ -749,7 +950,7 @@ public class Bootstrap {
|
||||
// Exit
|
||||
info.put("exit_code", exitCode);
|
||||
info.put("stderr", stderr != null ? stderr : "");
|
||||
info.put("launcher_log", readFileSafe(logDir.resolve("launcher.log"), 200));
|
||||
info.put("launcher_log", readFileSafe(logDir.resolve("latest.log"), 200));
|
||||
|
||||
// System
|
||||
Map<String, Object> system = new HashMap<>();
|
||||
@@ -851,6 +1052,13 @@ public class Bootstrap {
|
||||
statusLabel.setForeground(new Color(0x45, 0xe9, 0x60));
|
||||
statusLabel.setText("Crash report sent. Thank you!");
|
||||
sendBtn.setText("Sent");
|
||||
new Thread(() -> {
|
||||
try { Thread.sleep(1200); } catch (InterruptedException ignored) {}
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
frame.dispose();
|
||||
System.exit(exitCode);
|
||||
});
|
||||
}).start();
|
||||
} else {
|
||||
statusLabel.setForeground(accentColor);
|
||||
statusLabel.setText("Failed to send. Check logs folder.");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -156,9 +156,13 @@
|
||||
<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>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<classPath>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<addDependencies>false</addDependencies>
|
||||
</classPath>
|
||||
<cmdLine>--jfx</cmdLine>
|
||||
<jre>
|
||||
<path>lib/jre21</path>
|
||||
@@ -168,10 +172,12 @@
|
||||
<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>
|
||||
@@ -190,7 +196,11 @@
|
||||
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
|
||||
<headerType>console</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<classPath>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<addDependencies>false</addDependencies>
|
||||
</classPath>
|
||||
<cmdLine>--cli</cmdLine>
|
||||
<jre>
|
||||
<path>lib/jre21</path>
|
||||
<minVersion>21</minVersion>
|
||||
@@ -199,15 +209,54 @@
|
||||
<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>
|
||||
|
||||
@@ -279,22 +328,118 @@
|
||||
</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
|
||||
- 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
|
||||
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,bin/**,assets/**,lib/**,README.txt"
|
||||
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 /usr/local/go/bin/go >/dev/null 2>&1; then echo 'Building Go installers (Go absolute)...'; mkdir -p ../../server/builds; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; cd ../../installer && GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe . 2>&1 || echo 'Go online build failed'; cd ../launcher/launcher; echo 'Building offline with embedded ZIP...'; cp ../../server/builds/ZernMC-win-${revision}.${hotfix}.zip ../../installer/offline.zip 2>/dev/null || cp ../../server/builds/ZernMC-win-1.1.1.4.zip ../../installer/offline.zip 2>/dev/null || true; cd ../../installer && GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe . 2>&1 || echo 'Go offline build failed'; cd ../launcher/launcher; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; 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>
|
||||
|
||||
|
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 |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -211,7 +211,7 @@ public class Bootstrap {
|
||||
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
|
||||
System.out.println(entry);
|
||||
try {
|
||||
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||
Files.writeString(logDir.resolve("latest.log"), entry + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ public class Main {
|
||||
boolean jfxMode = argList.contains("--jfx");
|
||||
boolean cliMode = argList.contains("--cli");
|
||||
|
||||
if (argList.contains("--offline")) {
|
||||
System.setProperty("zernmc.offline", "true");
|
||||
LauncherLogger.info("Offline mode requested (--offline)");
|
||||
}
|
||||
|
||||
if (jfxMode) {
|
||||
launchJFX();
|
||||
return;
|
||||
@@ -65,6 +70,7 @@ public class Main {
|
||||
}
|
||||
|
||||
private static void startCLI() throws IOException {
|
||||
DomainSelector.selectBest();
|
||||
ZHttpClient.checkAllServicesOnStartup(true);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Checking authorization..."));
|
||||
|
||||
@@ -88,7 +88,7 @@ public class LauncherAPI {
|
||||
}
|
||||
return ApiResponse.success(mcVersions);
|
||||
} catch (Exception e) {
|
||||
System.out.println("[API] MC versions fetch failed: " + e.getMessage());
|
||||
LauncherLogger.warn("[API] MC versions fetch failed: " + e.getMessage());
|
||||
}
|
||||
return ApiResponse.error("Failed to load Minecraft versions");
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public class LauncherAPI {
|
||||
}
|
||||
return ApiResponse.success(versions);
|
||||
} catch (Exception e) {
|
||||
System.out.println("[API] Loader versions fetch failed: " + e.getMessage());
|
||||
LauncherLogger.warn("[API] Loader versions fetch failed: " + e.getMessage());
|
||||
return ApiResponse.error("Failed to load loader versions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,15 @@ public class AuthService {
|
||||
return ApiResponse.error(result.error != null ? result.error : "Registration failed");
|
||||
} catch (Exception e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && msg.contains("HTTP 409")) {
|
||||
return ApiResponse.error("Username already taken");
|
||||
if (msg != null) {
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile("HTTP (\\d{3})").matcher(msg);
|
||||
if (m.find()) {
|
||||
int code = Integer.parseInt(m.group(1));
|
||||
String body = msg.substring(m.end()).trim();
|
||||
return ApiResponse.error(AuthManager.friendlyHttpError(code, body));
|
||||
}
|
||||
}
|
||||
return ApiResponse.error("Registration error: " + msg);
|
||||
return ApiResponse.error(AuthManager.friendlyConnectionError(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +45,9 @@ public class AuthService {
|
||||
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
|
||||
return ApiResponse.success(loginResult);
|
||||
}
|
||||
return ApiResponse.error(result.error != null ? result.error : "Invalid login or password");
|
||||
return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Auth error: " + e.getMessage());
|
||||
return ApiResponse.error(AuthManager.friendlyConnectionError(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public class LaunchService {
|
||||
|
||||
static {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
System.out.println("[LAUNCH] Shutting down all running processes...");
|
||||
LauncherLogger.info("[LAUNCH] Shutting down all running processes...");
|
||||
runningProcesses.values().forEach(p -> {
|
||||
try {
|
||||
p.destroy();
|
||||
@@ -67,13 +67,44 @@ public class LaunchService {
|
||||
|
||||
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
|
||||
|
||||
long t0 = System.currentTimeMillis();
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
LaunchOptions options = createOptions();
|
||||
options.setUsername(AuthManager.getUsername());
|
||||
options.setAccessToken(AuthManager.getAccessToken());
|
||||
options.setUuid(AuthManager.getUuid());
|
||||
|
||||
return launchProcess(instance, instanceName, options);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("Launch error for " + instanceName, e);
|
||||
return ApiResponse.error("Launch error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<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 + ":");
|
||||
|
||||
@@ -113,17 +113,62 @@ public class AuthManager {
|
||||
saveSession();
|
||||
userInfo = fetchUserInfo();
|
||||
return AuthResult.ok();
|
||||
} else if (resp.statusCode() == 422) {
|
||||
return AuthResult.fail("Validation error: " + extractError(resp.body()));
|
||||
} else {
|
||||
return AuthResult.fail(extractError(resp.body()));
|
||||
return AuthResult.fail(friendlyHttpError(resp.statusCode(), resp.body()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return AuthResult.fail("Connection error: " + e.getMessage());
|
||||
LauncherLogger.warn("authRequest failed (" + endpoint + "): " + e.getMessage());
|
||||
return AuthResult.fail(friendlyConnectionError(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an HTTP status code + raw body to a short, human-readable error message
|
||||
* (in Russian) suitable for showing directly to the user.
|
||||
*/
|
||||
public static String friendlyHttpError(int statusCode, String body) {
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
case 403:
|
||||
return "Неверный логин или пароль";
|
||||
case 404:
|
||||
return "Сервис временно недоступен, попробуйте позже";
|
||||
case 409:
|
||||
return "Логин уже занят";
|
||||
case 422:
|
||||
return "Некорректные данные: " + extractError(body);
|
||||
case 429:
|
||||
return "Слишком много попыток. Подождите немного.";
|
||||
default:
|
||||
if (statusCode >= 500) {
|
||||
return "Сервер временно недоступен, попробуйте позже";
|
||||
}
|
||||
}
|
||||
String detail = extractError(body);
|
||||
if (detail.isEmpty() || detail.startsWith("<")) {
|
||||
return "Ошибка сервера (код " + statusCode + ")";
|
||||
}
|
||||
if (detail.length() > 120) {
|
||||
detail = detail.substring(0, 117) + "...";
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a network/IO exception to a short, human-readable message.
|
||||
*/
|
||||
public static String friendlyConnectionError(Exception e) {
|
||||
String m = e == null ? "" : String.valueOf(e.getMessage()).toLowerCase();
|
||||
if (e instanceof java.net.SocketTimeoutException
|
||||
|| e instanceof java.net.ConnectException
|
||||
|| e instanceof java.net.UnknownHostException
|
||||
|| m.contains("timeout") || m.contains("connect") || m.contains("unknownhost")
|
||||
|| m.contains("refused")) {
|
||||
return "Нет соединения с сервером. Проверьте интернет.";
|
||||
}
|
||||
return "Ошибка соединения с сервером. Попробуйте позже.";
|
||||
}
|
||||
|
||||
public static void logout() {
|
||||
if (session != null && session.refreshToken != null) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import java.io.IOException;
|
||||
@@ -142,7 +144,7 @@ public class Instance {
|
||||
this.serverPackName = meta.serverPackName;
|
||||
this.presetId = meta.presetId;
|
||||
} catch (Exception e) {
|
||||
System.err.println("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
|
||||
LauncherLogger.warn("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -71,12 +71,12 @@ public class MinecraftLib {
|
||||
* Stub - will be expanded
|
||||
*/
|
||||
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Starting full pack install: " + packName));
|
||||
LauncherLogger.info("Starting full pack install: " + packName);
|
||||
|
||||
// 1. Install Minecraft
|
||||
boolean mcInstalled = installMinecraft(minecraftVersion);
|
||||
if (!mcInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion));
|
||||
LauncherLogger.error("Failed to install Minecraft " + minecraftVersion);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -84,40 +84,39 @@ public class MinecraftLib {
|
||||
if ("fabric".equalsIgnoreCase(loaderType)) {
|
||||
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
|
||||
if (!fabricInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Fabric"));
|
||||
LauncherLogger.error("Failed to install Fabric");
|
||||
return false;
|
||||
}
|
||||
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
||||
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
|
||||
if (!forgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Forge"));
|
||||
LauncherLogger.error("Failed to install Forge");
|
||||
return false;
|
||||
}
|
||||
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
|
||||
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
|
||||
if (!neoforgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
||||
LauncherLogger.error("Failed to install NeoForge");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. In the future: diff and mod download
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Basic pack install complete!"));
|
||||
LauncherLogger.info("Basic pack install complete!");
|
||||
return true;
|
||||
}
|
||||
|
||||
//Launch
|
||||
public void launch(LaunchOptions options) throws Exception {
|
||||
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
|
||||
LauncherLogger.info("Launching pack: " + instance.getName());
|
||||
cleanupOldLoaders();
|
||||
validateJarFiles();
|
||||
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
List<String> command = builder.build(options);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):"));
|
||||
command.forEach(arg -> System.out.println(" " + arg));
|
||||
LauncherLogger.info("Launch command (" + command.size() + " args) written below");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(instance.getPath().toFile());
|
||||
@@ -126,12 +125,12 @@ public class MinecraftLib {
|
||||
Path cmdLogFile = instance.getPath().resolve("launch-command.log");
|
||||
try {
|
||||
Files.writeString(cmdLogFile, String.join(" \\\n ", command));
|
||||
System.out.println(ZAnsi.green(" Launch command written to " + cmdLogFile.toAbsolutePath()));
|
||||
LauncherLogger.info("Launch command written to " + cmdLogFile.toAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow(" Failed to write launch command log: " + e.getMessage()));
|
||||
LauncherLogger.warn("Failed to write launch command log: " + e.getMessage());
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
|
||||
LauncherLogger.info("Starting Minecraft...");
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
Process process = pb.start();
|
||||
@@ -168,7 +167,7 @@ public class MinecraftLib {
|
||||
outThread.join(1000);
|
||||
errThread.join(1000);
|
||||
|
||||
System.out.println(ZAnsi.yellow("\nMinecraft exited with code: " + exitCode));
|
||||
LauncherLogger.info("Minecraft exited with code: " + exitCode);
|
||||
}
|
||||
|
||||
private void safeDeleteDirectory(Path dir) {
|
||||
@@ -215,7 +214,7 @@ public class MinecraftLib {
|
||||
|
||||
if (currentLoaderVer == null) return;
|
||||
|
||||
System.out.println(ZAnsi.yellow("Cleaning old loader versions..."));
|
||||
LauncherLogger.info("Cleaning old loader versions...");
|
||||
|
||||
// Delete all old fabric-loader / forge
|
||||
Path libraries = instance.getPath().resolve("libraries");
|
||||
@@ -247,14 +246,14 @@ public class MinecraftLib {
|
||||
.filter(p -> !isValidJar(p))
|
||||
.forEach(p -> {
|
||||
try {
|
||||
System.out.println(ZAnsi.yellow(" Removing corrupt JAR: " + instance.getPath().relativize(p)));
|
||||
LauncherLogger.warn("Removing corrupt JAR: " + instance.getPath().relativize(p));
|
||||
Files.delete(p);
|
||||
} catch (IOException e) {
|
||||
System.out.println(ZAnsi.red(" Failed to delete: " + p.getFileName()));
|
||||
LauncherLogger.error("Failed to delete: " + p.getFileName());
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow(" Error scanning for corrupt JARs: " + e.getMessage()));
|
||||
LauncherLogger.warn("Error scanning for corrupt JARs: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +184,7 @@ public class PackDownloader {
|
||||
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success);
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
|
||||
LauncherLogger.error("installOrUpdatePack: Fabric install failed");
|
||||
LauncherLogger.error("[INSTALL] Failed to install Fabric");
|
||||
reportProgress("Failed to install Fabric", 0, "error", 0, 1);
|
||||
return false;
|
||||
}
|
||||
@@ -195,8 +194,7 @@ public class PackDownloader {
|
||||
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success);
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
||||
LauncherLogger.error("installOrUpdatePack: NeoForge install failed");
|
||||
LauncherLogger.error("[INSTALL] Failed to install NeoForge");
|
||||
reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
|
||||
return false;
|
||||
}
|
||||
@@ -206,8 +204,7 @@ public class PackDownloader {
|
||||
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
LauncherLogger.info("installOrUpdatePack: Forge install result=" + success);
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Forge"));
|
||||
LauncherLogger.error("installOrUpdatePack: Forge install failed");
|
||||
LauncherLogger.error("[INSTALL] Failed to install Forge");
|
||||
reportProgress("Failed to install Forge", 0, "error", 0, 1);
|
||||
return false;
|
||||
}
|
||||
@@ -217,16 +214,14 @@ public class PackDownloader {
|
||||
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
|
||||
LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success);
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
|
||||
LauncherLogger.error("installOrUpdatePack: Vanilla install failed");
|
||||
LauncherLogger.error("[INSTALL] Failed to install Vanilla Minecraft");
|
||||
reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully");
|
||||
} else {
|
||||
LauncherLogger.info("installOrUpdatePack: Minecraft already installed, skipping");
|
||||
System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
|
||||
LauncherLogger.info("Minecraft already installed, skipping...");
|
||||
}
|
||||
|
||||
reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5);
|
||||
@@ -239,7 +234,7 @@ public class PackDownloader {
|
||||
// If pack has no files (vanilla/loader only), skip diff
|
||||
if (manifest.files == null || manifest.files.isEmpty()) {
|
||||
LauncherLogger.info("installOrUpdatePack: no files in manifest, skipping diff");
|
||||
System.out.println(ZAnsi.green("Pack contains no additional files"));
|
||||
LauncherLogger.info("Pack contains no additional files");
|
||||
reportProgress("Installing...", 50, "Installing pack files", 4, 5);
|
||||
|
||||
saveUserFilesSnapshot(localFiles);
|
||||
@@ -253,7 +248,7 @@ public class PackDownloader {
|
||||
instance.setAssetIndex(resolveAssetIndex(manifest));
|
||||
|
||||
reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5);
|
||||
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
|
||||
LauncherLogger.info("Pack installed successfully!");
|
||||
return true;
|
||||
}
|
||||
LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff");
|
||||
@@ -302,7 +297,7 @@ public class PackDownloader {
|
||||
instance.setLoaderVersion(manifest.getLoaderVersion());
|
||||
instance.setAssetIndex(resolveAssetIndex(manifest));
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
|
||||
LauncherLogger.info("Pack installed successfully!");
|
||||
LauncherLogger.info("installOrUpdatePack: SUCCESS");
|
||||
} else {
|
||||
LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception");
|
||||
@@ -332,17 +327,17 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
public boolean updatePack(String packName, String preset) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "..."));
|
||||
LauncherLogger.info("Checking updates for " + instance.getName() + "...");
|
||||
|
||||
PackManifest manifest = getPackManifest(packName);
|
||||
int serverVersion = manifest.getVersion();
|
||||
|
||||
if (serverVersion <= instance.getServerVersion()) {
|
||||
System.out.println(ZAnsi.green("Pack is already up to date (v" + instance.getServerVersion() + ")"));
|
||||
LauncherLogger.info("Pack is already up to date (v" + instance.getServerVersion() + ")");
|
||||
return true;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " → v" + serverVersion));
|
||||
LauncherLogger.info("Update available: v" + instance.getServerVersion() + " -> v" + serverVersion);
|
||||
|
||||
// Scan local files
|
||||
Map<String, String> localFiles = scanLocalFiles();
|
||||
@@ -351,7 +346,7 @@ public class PackDownloader {
|
||||
Set<String> protectedFiles = getUserModifiedFiles(localFiles);
|
||||
if (!protectedFiles.isEmpty()) {
|
||||
LauncherLogger.info("[DEBUG] Found " + protectedFiles.size() + " user-modified files (will not overwrite)");
|
||||
System.out.println(ZAnsi.yellow("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten"));
|
||||
LauncherLogger.info("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten");
|
||||
}
|
||||
|
||||
// Get diff
|
||||
@@ -366,7 +361,7 @@ public class PackDownloader {
|
||||
saveUserFilesSnapshot(updatedHashes);
|
||||
|
||||
instance.setServerVersion(serverVersion);
|
||||
System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
|
||||
LauncherLogger.info("Pack updated to v" + serverVersion);
|
||||
}
|
||||
|
||||
return success;
|
||||
@@ -519,34 +514,30 @@ public class PackDownloader {
|
||||
|
||||
private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles, String preset) {
|
||||
LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size());
|
||||
System.out.println(ZAnsi.cyan("\nApplying changes:"));
|
||||
System.out.println(" Download: " + diff.getToDownload().size() + " files");
|
||||
System.out.println(" Delete: " + diff.getToDelete().size() + " files");
|
||||
if (!protectedFiles.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow(" Protected: " + protectedFiles.size() + " user-modified files (skipped)"));
|
||||
}
|
||||
LauncherLogger.info("Apply changes: Download " + diff.getToDownload().size() + " files, Delete " + diff.getToDelete().size() + " files"
|
||||
+ (!protectedFiles.isEmpty() ? ", Protected " + protectedFiles.size() + " user-modified files (skipped)" : ""));
|
||||
|
||||
// Create directories if needed
|
||||
try {
|
||||
Files.createDirectories(instance.getPath());
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage()));
|
||||
LauncherLogger.error("Error creating directories: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete files (skip protected)
|
||||
for (String filePath : diff.getToDelete()) {
|
||||
if (protectedFiles.contains(filePath)) {
|
||||
System.out.println(ZAnsi.yellow(" Skipped delete (user-modified): " + filePath));
|
||||
LauncherLogger.info("Skipped delete (user-modified): " + filePath);
|
||||
continue;
|
||||
}
|
||||
Path fullPath = instance.getPath().resolve(filePath);
|
||||
try {
|
||||
if (Files.deleteIfExists(fullPath)) {
|
||||
System.out.println(ZAnsi.yellow(" Deleted: " + filePath));
|
||||
LauncherLogger.info("Deleted: " + filePath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.red(" Error deleting " + filePath + ": " + e.getMessage()));
|
||||
LauncherLogger.error("Error deleting " + filePath + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +556,7 @@ public class PackDownloader {
|
||||
|
||||
if (protectedFiles.contains(path)) {
|
||||
skipped++;
|
||||
System.out.println(ZAnsi.yellow(" Skipped (user-modified): " + path));
|
||||
LauncherLogger.info("Skipped (user-modified): " + path);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -597,7 +588,6 @@ public class PackDownloader {
|
||||
}
|
||||
if (!hashMatch) {
|
||||
LauncherLogger.warn("applyDiff: skipping file " + path + " due to hash mismatch (expected=" + file.getHash() + " got=" + actualHash + ")");
|
||||
System.err.println(ZAnsi.yellow(" Skipped (hash mismatch): " + path));
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -642,8 +632,7 @@ public class PackDownloader {
|
||||
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("applyDiff: download FAILED for path=" + path + " error=" + e.getClass().getName() + ": " + e.getMessage());
|
||||
System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage()));
|
||||
System.err.println(ZAnsi.yellow(" Skipping file " + path + " due to download error"));
|
||||
LauncherLogger.error("Download error " + path + ": " + e.getMessage() + " - skipping file");
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -653,7 +642,7 @@ public class PackDownloader {
|
||||
ProgressBar.finish("Download");
|
||||
}
|
||||
if (skipped > 0) {
|
||||
System.out.println(ZAnsi.cyan(" Skipped " + skipped + " user-modified files"));
|
||||
LauncherLogger.info("Skipped " + skipped + " user-modified files");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -6,7 +6,9 @@ import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -33,6 +35,8 @@ public class FabricInstaller {
|
||||
instance.setAssetIndex(assetIndex);
|
||||
instance.setMinecraftVersion(minecraftVersion);
|
||||
|
||||
ProgressBar.showIndeterminate("Preparing Fabric installer");
|
||||
|
||||
String installerVersion = getLatestInstallerVersion();
|
||||
String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/"
|
||||
+ installerVersion + "/fabric-installer-" + installerVersion + ".jar";
|
||||
@@ -40,7 +44,7 @@ public class FabricInstaller {
|
||||
Path installerJar = instancePath.resolve("fabric-installer.jar");
|
||||
|
||||
if (!Files.exists(installerJar)) {
|
||||
ProgressBar.show("Downloading Fabric Installer", 0, 100, "%");
|
||||
ProgressBar.showIndeterminate("Downloading Fabric Installer");
|
||||
downloadFileWithFallback(installerUrl, installerJar);
|
||||
ProgressBar.finish("Fabric Installer downloaded");
|
||||
}
|
||||
@@ -48,6 +52,7 @@ public class FabricInstaller {
|
||||
preDownloadFabricLibraries(minecraftVersion, loaderVersion);
|
||||
|
||||
LauncherLogger.info("Running Fabric Installer...");
|
||||
ProgressBar.showIndeterminate("Running Fabric Installer (may take a few minutes)");
|
||||
|
||||
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
||||
|
||||
@@ -60,17 +65,50 @@ public class FabricInstaller {
|
||||
"-noprofile"
|
||||
);
|
||||
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
||||
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
boolean finished = process.waitFor(10, TimeUnit.MINUTES);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
LauncherLogger.error("Fabric Installer timed out after 10 minutes");
|
||||
return false;
|
||||
|
||||
boolean hasErrors = false;
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.contains("Downloading") || line.contains("Extracting")
|
||||
|| line.contains("Installing") || line.contains("Creating")
|
||||
|| line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
ProgressBar.showIndeterminate("Fabric Installer: " + ProgressBar.cleanLine(line));
|
||||
}
|
||||
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
LauncherLogger.info(" -> " + line);
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
LauncherLogger.info(" + " + line);
|
||||
} else if (line.contains("WARNING") || line.contains("warning")) {
|
||||
LauncherLogger.warn(" ! " + line);
|
||||
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
|
||||
LauncherLogger.error(" X " + line);
|
||||
if (line.contains("timed out") || line.contains("failed to download")) {
|
||||
hasErrors = true;
|
||||
}
|
||||
} else if (!line.isBlank()) {
|
||||
LauncherLogger.info(" " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int exitCode;
|
||||
if (hasErrors) {
|
||||
process.destroyForcibly();
|
||||
exitCode = 1;
|
||||
} else {
|
||||
if (!process.waitFor(10, TimeUnit.MINUTES)) {
|
||||
process.destroyForcibly();
|
||||
LauncherLogger.error("Fabric Installer timed out after 10 minutes");
|
||||
return false;
|
||||
}
|
||||
exitCode = process.exitValue();
|
||||
}
|
||||
int exitCode = process.exitValue();
|
||||
|
||||
if (exitCode != 0) {
|
||||
LauncherLogger.error("Fabric Installer failed (code " + exitCode + ")");
|
||||
|
||||
@@ -48,7 +48,16 @@ public class ForgeInstaller {
|
||||
LauncherLogger.info("Downloading Forge Installer...");
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
// Step 4: Run Forge Installer and show its output
|
||||
// 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...");
|
||||
|
||||
@@ -105,6 +114,13 @@ public class ForgeInstaller {
|
||||
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",
|
||||
@@ -175,19 +191,8 @@ public class ForgeInstaller {
|
||||
if (attempt < maxRetries) {
|
||||
LauncherLogger.warn("Install error. Retrying in 5 seconds...");
|
||||
Thread.sleep(5000);
|
||||
|
||||
// Clean temp files before retry
|
||||
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 */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.*;
|
||||
@@ -57,15 +58,19 @@ public class ModLoaderInstaller {
|
||||
instance.setAssetIndex(assetIndex);
|
||||
createLauncherProfile();
|
||||
|
||||
ProgressBar.showIndeterminate("Preparing " + type.loaderName + " installer");
|
||||
|
||||
String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
|
||||
String jarName = type.loaderName + "-installer.jar";
|
||||
Path installerJar = instance.getPath().resolve(jarName);
|
||||
|
||||
LauncherLogger.info("Downloading " + type.loaderName + " Installer...");
|
||||
ProgressBar.showIndeterminate("Downloading " + type.loaderName + " Installer");
|
||||
ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar);
|
||||
|
||||
LauncherLogger.info("Running " + type.loaderName + " Installer...");
|
||||
LauncherLogger.info("This may take a few minutes. Please wait...");
|
||||
ProgressBar.showIndeterminate("Running " + type.loaderName + " Installer (may take a few minutes)");
|
||||
|
||||
boolean success = runInstaller(installerJar, type);
|
||||
|
||||
@@ -145,6 +150,11 @@ public class ModLoaderInstaller {
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
|
||||
if (line.contains("Downloading") || line.contains("Extracting")
|
||||
|| line.contains("Installing") || line.contains("Creating")
|
||||
|| line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
ProgressBar.showIndeterminate(type.loaderName + " Installer: " + ProgressBar.cleanLine(line));
|
||||
}
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
LauncherLogger.info(" -> " + line);
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
@@ -218,6 +228,7 @@ public class ModLoaderInstaller {
|
||||
|
||||
private void downloadMissingLibraries(LoaderType type) throws Exception {
|
||||
LauncherLogger.info("Checking and downloading missing libraries...");
|
||||
ProgressBar.showIndeterminate("Downloading missing libraries");
|
||||
ZHttpClient.repairMissingLibraries(instance.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ public class NeoForgeInstaller {
|
||||
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...");
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@ public class VersionInstaller {
|
||||
Files.createDirectories(versionDir);
|
||||
|
||||
boolean alreadyInstalled = Files.exists(versionDir.resolve(versionId + ".json"))
|
||||
&& Files.exists(versionDir.resolve(versionId + ".jar"));
|
||||
&& 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"));
|
||||
@@ -73,20 +74,29 @@ public class VersionInstaller {
|
||||
? 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) {
|
||||
System.err.println(ZAnsi.red("[VERSION] Failed to fetch version info: " + e.getMessage()));
|
||||
LauncherLogger.error("[VERSION] Failed to fetch version info: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.red("[VERSION] Failed to write version info: " + e.getMessage()));
|
||||
LauncherLogger.error("[VERSION] Failed to write version info: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
ProgressBar.show("Version info", 1, 1, "files");
|
||||
@@ -95,16 +105,20 @@ public class VersionInstaller {
|
||||
|
||||
// client.jar
|
||||
ProgressBar.show("Downloading client.jar", 0, 1, "files");
|
||||
LauncherLogger.info("[VERSION] Downloading client.jar for " + versionId + "...");
|
||||
Path clientJar = versionDir.resolve(versionId + ".jar");
|
||||
if (!Files.exists(clientJar)) {
|
||||
try {
|
||||
ZHttpClient.downloadFileWithSmartProxy(
|
||||
versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
|
||||
clientJar);
|
||||
LauncherLogger.info("[VERSION] client.jar downloaded for " + versionId);
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.red("[VERSION] Failed to download client.jar: " + e.getMessage()));
|
||||
LauncherLogger.error("[VERSION] Failed to download client.jar: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
LauncherLogger.info("[VERSION] client.jar already present for " + versionId);
|
||||
}
|
||||
ProgressBar.show("Client.jar", 1, 1, "files");
|
||||
|
||||
@@ -233,7 +247,7 @@ public class VersionInstaller {
|
||||
try {
|
||||
ZHttpClient.downloadFileWithSmartProxy(url, target);
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.yellow("[LIB] Failed to download " + path + ": " + e.getMessage()));
|
||||
LauncherLogger.warn("[LIB] Failed to download " + path + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
count++;
|
||||
@@ -306,7 +320,7 @@ public class VersionInstaller {
|
||||
synchronized (this) {
|
||||
failed[0]++;
|
||||
}
|
||||
System.err.println("Failed to download " + hash);
|
||||
LauncherLogger.error("[VERSION] Failed to download asset " + hash);
|
||||
} else {
|
||||
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
|
||||
}
|
||||
@@ -322,7 +336,7 @@ public class VersionInstaller {
|
||||
.get(10, TimeUnit.MINUTES);
|
||||
} catch (TimeoutException e) {
|
||||
futures.forEach(f -> f.cancel(true));
|
||||
System.err.println("Asset download timed out after 10 minutes");
|
||||
LauncherLogger.error("[VERSION] Asset download timed out after 10 minutes");
|
||||
} catch (CancellationException e) {
|
||||
// one of the futures was cancelled
|
||||
} catch (CompletionException e) {
|
||||
@@ -337,6 +351,14 @@ public class VersionInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -36,7 +37,7 @@ public class LaunchCommandBuilder {
|
||||
}
|
||||
|
||||
public List<String> build(LaunchOptions options) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
|
||||
LauncherLogger.info(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
|
||||
@@ -57,7 +58,7 @@ public class LaunchCommandBuilder {
|
||||
// with placeholder substitution. Do NOT manually add -p, -cp,
|
||||
// --add-modules, --add-opens, or -DignoreList. The version.json
|
||||
// defines all of these. This matches AstralRinth's approach.
|
||||
System.out.println(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution"));
|
||||
LauncherLogger.info(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution"));
|
||||
|
||||
// Ensure version jar exists at versions/<versionId>/<versionId>.jar
|
||||
// so securejarhandler creates module "minecraft" (from Automatic-Module-Name)
|
||||
@@ -92,9 +93,9 @@ public class LaunchCommandBuilder {
|
||||
boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs);
|
||||
if (referencesClasspath) {
|
||||
vars.put("classpath", writeClasspathFile(fullClasspath));
|
||||
System.out.println(ZAnsi.green(" ${classpath} referenced, substituted full classpath"));
|
||||
LauncherLogger.info(ZAnsi.green(" ${classpath} referenced, substituted full classpath"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting"));
|
||||
LauncherLogger.info(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting"));
|
||||
}
|
||||
|
||||
boolean hasCpInJson = containsCpArg(allJvmArgs);
|
||||
@@ -107,9 +108,9 @@ public class LaunchCommandBuilder {
|
||||
command.add(resolved);
|
||||
}
|
||||
}
|
||||
System.out.println(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json"));
|
||||
LauncherLogger.info(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
|
||||
LauncherLogger.info(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
|
||||
command.addAll(getJvmArguments(options));
|
||||
}
|
||||
|
||||
@@ -119,16 +120,19 @@ public class LaunchCommandBuilder {
|
||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
||||
}
|
||||
|
||||
// Append memory/GC args (always after version.json args, like AstralRinth)
|
||||
// Append memory/GC args (always after version.json args, like AstralRinth) — skip if systemBasedJvm adds its own GC (ZGC/G1)
|
||||
boolean useSystemGc = me.sashegdev.zernmc.launcher.utils.Config.isSystemBasedJvm();
|
||||
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
|
||||
command.add("-Xmx" + ramMB + "M");
|
||||
command.add("-Xms" + Math.max(512, ramMB / 2) + "M");
|
||||
command.add("-XX:+UseG1GC");
|
||||
command.add("-XX:+UnlockExperimentalVMOptions");
|
||||
command.add("-XX:G1NewSizePercent=20");
|
||||
command.add("-XX:G1ReservePercent=20");
|
||||
command.add("-XX:MaxGCPauseMillis=50");
|
||||
command.add("-XX:G1HeapRegionSize=32M");
|
||||
if (!useSystemGc) {
|
||||
command.add("-XX:+UseG1GC");
|
||||
command.add("-XX:+UnlockExperimentalVMOptions");
|
||||
command.add("-XX:G1NewSizePercent=20");
|
||||
command.add("-XX:G1ReservePercent=20");
|
||||
command.add("-XX:MaxGCPauseMillis=50");
|
||||
command.add("-XX:G1HeapRegionSize=32M");
|
||||
}
|
||||
|
||||
// Append custom user JVM args
|
||||
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
|
||||
@@ -141,7 +145,7 @@ public class LaunchCommandBuilder {
|
||||
if (!hasCpInJson) {
|
||||
command.add("-cp");
|
||||
command.add(writeClasspathFile(fullClasspath));
|
||||
System.out.println(ZAnsi.green(" Added -cp classpath for main class lookup"));
|
||||
LauncherLogger.info(ZAnsi.green(" Added -cp classpath for main class lookup"));
|
||||
}
|
||||
|
||||
// Main class from version.json
|
||||
@@ -151,9 +155,9 @@ public class LaunchCommandBuilder {
|
||||
}
|
||||
if (mainClass == null || mainClass.isEmpty()) {
|
||||
mainClass = "cpw.mods.bootstraplauncher.BootstrapLauncher";
|
||||
System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
|
||||
} else {
|
||||
System.out.println(ZAnsi.green(" Main class from manifest: " + mainClass));
|
||||
LauncherLogger.info(ZAnsi.green(" Main class from manifest: " + mainClass));
|
||||
}
|
||||
command.add(mainClass);
|
||||
|
||||
@@ -167,9 +171,9 @@ public class LaunchCommandBuilder {
|
||||
command.add(resolved);
|
||||
}
|
||||
}
|
||||
System.out.println(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json"));
|
||||
LauncherLogger.info(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback"));
|
||||
LauncherLogger.info(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback"));
|
||||
command.addAll(getVanillaGameArguments(options));
|
||||
command.addAll(getModloaderLaunchArgs());
|
||||
}
|
||||
@@ -183,24 +187,24 @@ public class LaunchCommandBuilder {
|
||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
System.out.println(ZAnsi.cyan(" Fabric: using vanilla classpath"));
|
||||
LauncherLogger.info(ZAnsi.cyan(" Fabric: using vanilla classpath"));
|
||||
command.add("-cp");
|
||||
command.add(writeClasspathFile(buildVanillaClasspath()));
|
||||
String mainClass = null;
|
||||
if (manifest != null) {
|
||||
mainClass = manifest.getMainClass();
|
||||
System.out.println(ZAnsi.cyan(" Main class from manifest: " + mainClass));
|
||||
LauncherLogger.info(ZAnsi.cyan(" Main class from manifest: " + mainClass));
|
||||
}
|
||||
if (mainClass == null || mainClass.isEmpty()) {
|
||||
mainClass = getVanillaMainClass();
|
||||
System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
|
||||
}
|
||||
command.add(mainClass);
|
||||
command.addAll(getVanillaGameArguments(options));
|
||||
} else if (manifest != null) {
|
||||
String classpath = buildClasspathFromManifest(manifest, true);
|
||||
if (classpath.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
|
||||
LauncherLogger.info(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
|
||||
command.add("-cp");
|
||||
command.add(writeClasspathFile(buildVanillaClasspath()));
|
||||
command.add(getVanillaMainClass());
|
||||
@@ -229,20 +233,20 @@ public class LaunchCommandBuilder {
|
||||
if (versionJson != null && Files.exists(versionJson)) {
|
||||
String content = Files.readString(versionJson);
|
||||
JSONObject json = new JSONObject(content);
|
||||
System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
|
||||
LauncherLogger.info(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
|
||||
VersionManifest manifest = new VersionManifest(json);
|
||||
manifest.resolveParent(instance.getPath().resolve("versions"));
|
||||
if (manifest.getInheritsFrom() != null) {
|
||||
System.out.println(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
|
||||
LauncherLogger.info(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
|
||||
}
|
||||
return manifest;
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName()));
|
||||
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
|
||||
System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
|
||||
LauncherLogger.info(ZAnsi.yellow("version.json not found for " + instance.getName()));
|
||||
LauncherLogger.info(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
|
||||
LauncherLogger.info(ZAnsi.yellow(" path=" + instance.getPath()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
|
||||
LauncherLogger.info(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -496,14 +500,14 @@ public class LaunchCommandBuilder {
|
||||
String assetIndex = instance.getAssetIndex();
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
assetIndex = instance.getMinecraftVersion();
|
||||
System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
|
||||
LauncherLogger.info(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
|
||||
} else {
|
||||
// Validate that the index file exists. A wrong/stale asset index
|
||||
// (e.g. the Zern-OBT "1.20.1" bug, real index is "5") makes the game
|
||||
// fail to load the base resource pack (grey panorama, no sounds).
|
||||
Path indexPath = instance.getPath().resolve("assets").resolve("indexes").resolve(assetIndex + ".json");
|
||||
if (!Files.exists(indexPath)) {
|
||||
System.out.println(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index..."));
|
||||
LauncherLogger.info(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index..."));
|
||||
try (java.util.stream.Stream<Path> stream = Files.list(instance.getPath().resolve("assets").resolve("indexes"))) {
|
||||
java.util.List<Path> existing = stream
|
||||
.filter(p -> p.getFileName().toString().endsWith(".json"))
|
||||
@@ -512,16 +516,16 @@ public class LaunchCommandBuilder {
|
||||
if (!existing.isEmpty()) {
|
||||
String found = existing.get(existing.size() - 1).getFileName().toString()
|
||||
.replace(".json", "");
|
||||
System.out.println(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")"));
|
||||
LauncherLogger.info(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")"));
|
||||
assetIndex = found;
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("No asset index files found in assets/indexes/"));
|
||||
LauncherLogger.info(ZAnsi.yellow("No asset index files found in assets/indexes/"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage()));
|
||||
LauncherLogger.info(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
System.out.println(ZAnsi.green("Using asset index: " + assetIndex));
|
||||
LauncherLogger.info(ZAnsi.green("Using asset index: " + assetIndex));
|
||||
}
|
||||
args.add(assetIndex);
|
||||
args.add("--username");
|
||||
@@ -612,7 +616,7 @@ public class LaunchCommandBuilder {
|
||||
List<String> paths = new ArrayList<>();
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
|
||||
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)"));
|
||||
LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)"));
|
||||
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType);
|
||||
@@ -628,7 +632,7 @@ public class LaunchCommandBuilder {
|
||||
if (isValidJar(libPath)) {
|
||||
paths.add(libPath.toAbsolutePath().toString());
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||
Files.delete(libPath);
|
||||
}
|
||||
} else {
|
||||
@@ -638,7 +642,7 @@ public class LaunchCommandBuilder {
|
||||
if (isValidJar(fallbackPath)) {
|
||||
paths.add(fallbackPath.toAbsolutePath().toString());
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||
Files.delete(fallbackPath);
|
||||
}
|
||||
} else {
|
||||
@@ -647,23 +651,23 @@ public class LaunchCommandBuilder {
|
||||
Path found = scanForJar(librariesDir, artifactName);
|
||||
if (found != null) {
|
||||
if (isValidJar(found)) {
|
||||
System.out.println(ZAnsi.green(" Found by scan: " + lib.name + " → " + librariesDir.relativize(found)));
|
||||
LauncherLogger.info(ZAnsi.green(" Found by scan: " + lib.name + " → " + librariesDir.relativize(found)));
|
||||
paths.add(found.toAbsolutePath().toString());
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName()));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName()));
|
||||
Files.delete(found);
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Library not found (even after scan): " + lib.name));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Library not found (even after scan): " + lib.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
|
||||
LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
|
||||
if (isForgeLike) {
|
||||
System.out.println(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
|
||||
System.out.println(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
|
||||
LauncherLogger.info(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
|
||||
LauncherLogger.info(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
|
||||
}
|
||||
|
||||
if (includeVersionJar) {
|
||||
@@ -671,9 +675,9 @@ public class LaunchCommandBuilder {
|
||||
if (versionJar != null) {
|
||||
if (isValidJar(versionJar)) {
|
||||
paths.add(0, versionJar.toAbsolutePath().toString());
|
||||
System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
|
||||
LauncherLogger.info(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
|
||||
Files.delete(versionJar);
|
||||
}
|
||||
}
|
||||
@@ -785,7 +789,7 @@ public class LaunchCommandBuilder {
|
||||
Path targetJar = targetDir.resolve(versionId + ".jar");
|
||||
|
||||
if (Files.exists(targetJar) && isValidJar(targetJar)) {
|
||||
System.out.println(ZAnsi.green(" Version jar already exists: " + targetJar));
|
||||
LauncherLogger.info(ZAnsi.green(" Version jar already exists: " + targetJar));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -795,11 +799,11 @@ public class LaunchCommandBuilder {
|
||||
Path vanillaJar = versionsDir.resolve(mcVersion).resolve(mcVersion + ".jar");
|
||||
if (Files.exists(vanillaJar) && isValidJar(vanillaJar)) {
|
||||
Files.copy(vanillaJar, targetJar);
|
||||
System.out.println(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + " → " + targetJar));
|
||||
LauncherLogger.info(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + " → " + targetJar));
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar));
|
||||
LauncherLogger.info(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -833,7 +837,7 @@ public class LaunchCommandBuilder {
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (found != null) {
|
||||
System.out.println(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")"));
|
||||
LauncherLogger.info(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")"));
|
||||
return found;
|
||||
}
|
||||
} catch (Exception e) { /* continue */ }
|
||||
@@ -848,7 +852,7 @@ public class LaunchCommandBuilder {
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (found != null) {
|
||||
System.out.println(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")"));
|
||||
LauncherLogger.info(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")"));
|
||||
return found;
|
||||
}
|
||||
} catch (Exception e) { /* continue */ }
|
||||
@@ -903,12 +907,12 @@ public class LaunchCommandBuilder {
|
||||
for (String candidate : candidates) {
|
||||
Path jarPath = librariesDir.resolve(candidate);
|
||||
if (Files.exists(jarPath) && isValidJar(jarPath)) {
|
||||
System.out.println(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName()));
|
||||
LauncherLogger.info(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName()));
|
||||
return jarPath;
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.yellow(" No Forge client/universal jar found!"));
|
||||
LauncherLogger.info(ZAnsi.yellow(" No Forge client/universal jar found!"));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1047,7 +1051,7 @@ public class LaunchCommandBuilder {
|
||||
Path tempFile = Files.createTempFile("zernmc-cp-", ".txt");
|
||||
Files.writeString(tempFile, classpath);
|
||||
tempFiles.add(tempFile);
|
||||
System.out.println(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile));
|
||||
LauncherLogger.info(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile));
|
||||
return "@" + tempFile.toAbsolutePath();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -75,7 +77,7 @@ public class VersionManifest {
|
||||
this.parent = new VersionManifest(json);
|
||||
this.parent.resolveParent(versionsDir);
|
||||
} catch (Exception e) {
|
||||
System.out.println("Failed to load parent version " + inheritsFrom + ": " + e.getMessage());
|
||||
LauncherLogger.warn("Failed to load parent version " + inheritsFrom + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.DomainSelector;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.Version;
|
||||
|
||||
@@ -205,6 +206,77 @@ public class JFXLauncher extends Application {
|
||||
launch(args);
|
||||
}
|
||||
|
||||
private static final byte[] PNG_SIGNATURE = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
|
||||
private static javafx.scene.image.Image loadWindowIcon() {
|
||||
try {
|
||||
byte[] ico = readAssetBytes("assets/ui/icons/zernmc.ico");
|
||||
if (ico != null) {
|
||||
byte[] png = extractIcoPngFrame(ico);
|
||||
if (png != null) {
|
||||
return new javafx.scene.image.Image(new java.io.ByteArrayInputStream(png));
|
||||
}
|
||||
}
|
||||
byte[] png = readAssetBytes("assets/ui/icons/zernmc.png");
|
||||
if (png != null) {
|
||||
return new javafx.scene.image.Image(new java.io.ByteArrayInputStream(png));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Window icon load error: " + e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] readAssetBytes(String resourcePath) {
|
||||
try (InputStream in = JFXLauncher.class.getResourceAsStream("/" + resourcePath)) {
|
||||
if (in != null) {
|
||||
return in.readAllBytes();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
try {
|
||||
Path file = Paths.get(resourcePath);
|
||||
if (Files.exists(file)) {
|
||||
return Files.readAllBytes(file);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] extractIcoPngFrame(byte[] ico) {
|
||||
if (ico == null || ico.length < 6 || ico[0] != 0 || ico[1] != 0) {
|
||||
return null;
|
||||
}
|
||||
int count = (ico[4] & 0xFF) | ((ico[5] & 0xFF) << 8);
|
||||
int bestSize = -1;
|
||||
int bestOffset = -1;
|
||||
int bestLength = -1;
|
||||
for (int i = 0; i < count; i++) {
|
||||
int off = 6 + i * 16;
|
||||
if (off + 16 > ico.length) break;
|
||||
int w = ico[off] & 0xFF;
|
||||
int h = ico[off + 1] & 0xFF;
|
||||
int size = (w == 0 ? 256 : w) * (h == 0 ? 256 : h);
|
||||
int len = (ico[off + 8] & 0xFF) | ((ico[off + 9] & 0xFF) << 8)
|
||||
| ((ico[off + 10] & 0xFF) << 16) | ((ico[off + 11] & 0xFF) << 24);
|
||||
int imgOff = (ico[off + 12] & 0xFF) | ((ico[off + 13] & 0xFF) << 8)
|
||||
| ((ico[off + 14] & 0xFF) << 16) | ((ico[off + 15] & 0xFF) << 24);
|
||||
if (imgOff < 0 || len <= 0 || imgOff + len > ico.length) continue;
|
||||
boolean isPng = true;
|
||||
for (int b = 0; b < PNG_SIGNATURE.length; b++) {
|
||||
if (ico[imgOff + b] != PNG_SIGNATURE[b]) { isPng = false; break; }
|
||||
}
|
||||
if (isPng && size > bestSize) {
|
||||
bestSize = size;
|
||||
bestOffset = imgOff;
|
||||
bestLength = len;
|
||||
}
|
||||
}
|
||||
if (bestOffset < 0 || bestLength <= 0) return null;
|
||||
byte[] png = new byte[bestLength];
|
||||
System.arraycopy(ico, bestOffset, png, 0, bestLength);
|
||||
return png;
|
||||
}
|
||||
|
||||
private static void extractAssets() {
|
||||
try {
|
||||
Path assetsDir = Paths.get("assets");
|
||||
@@ -216,7 +288,7 @@ public class JFXLauncher extends Application {
|
||||
try {
|
||||
String existingVersion = Files.readString(versionFile).trim();
|
||||
if (existingVersion.equals(currentVersion)) {
|
||||
System.out.println("[JFX] Assets up to date (v" + currentVersion + ")");
|
||||
log("Assets up to date (v" + currentVersion + ")");
|
||||
return;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
@@ -233,16 +305,16 @@ public class JFXLauncher extends Application {
|
||||
|
||||
String serverVersion = getServerVersion();
|
||||
if (serverVersion != null && !serverVersion.isEmpty()) {
|
||||
System.out.println("[JFX] Loading assets via meta for version " + serverVersion);
|
||||
log("Loading assets via meta for version " + serverVersion);
|
||||
if (downloadAssetsFromMeta(serverVersion)) {
|
||||
System.out.println("[JFX] Assets loaded via meta");
|
||||
log("Assets loaded via meta");
|
||||
Files.writeString(versionFile, currentVersion);
|
||||
return;
|
||||
}
|
||||
System.out.println("[JFX] Meta unavailable, using fallback");
|
||||
log("Meta unavailable, using fallback");
|
||||
}
|
||||
|
||||
System.out.println("[JFX] Extracting assets from JAR...");
|
||||
log("Extracting assets from JAR...");
|
||||
Files.createDirectories(assetsDir);
|
||||
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
|
||||
@@ -263,11 +335,11 @@ public class JFXLauncher extends Application {
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println("[JFX] Assets extracted from JAR");
|
||||
log("Assets extracted from JAR");
|
||||
Files.writeString(versionFile, currentVersion);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[JFX] Error extracting assets: " + e.getMessage());
|
||||
log("Error extracting assets: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +404,7 @@ public class JFXLauncher extends Application {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[JFX] Error loading via meta: " + e.getMessage());
|
||||
log("Error loading assets via meta: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -363,6 +435,7 @@ public class JFXLauncher extends Application {
|
||||
log("Starting background network init...");
|
||||
Thread netInitThread = new Thread(() -> {
|
||||
try {
|
||||
DomainSelector.selectBest();
|
||||
ZHttpClient.checkAllServicesOnStartup();
|
||||
log("Network init complete");
|
||||
} catch (Exception e) {
|
||||
@@ -374,7 +447,7 @@ public class JFXLauncher extends Application {
|
||||
startServer();
|
||||
|
||||
WebView webView = new WebView();
|
||||
webView.setContextMenuEnabled(false);
|
||||
webView.setContextMenuEnabled(true);
|
||||
WebEngine engine = webView.getEngine();
|
||||
engine.setJavaScriptEnabled(true);
|
||||
|
||||
@@ -391,9 +464,9 @@ public class JFXLauncher extends Application {
|
||||
engine.setOnAlert(e -> {
|
||||
String msg = e.getData();
|
||||
if (msg.startsWith("[LOG] ")) {
|
||||
System.out.println("[JS] " + msg.substring(6));
|
||||
log("[JS] " + msg.substring(6));
|
||||
} else if (msg.startsWith("[ERR] ")) {
|
||||
System.err.println("[JS] " + msg.substring(6));
|
||||
log("[JS ERROR] " + msg.substring(6));
|
||||
} else {
|
||||
log("[UI] Alert: " + msg);
|
||||
}
|
||||
@@ -435,6 +508,9 @@ public class JFXLauncher extends Application {
|
||||
" req.open('POST', '/api/open-url', false);" +
|
||||
" req.send(JSON.stringify({url: target.href}));" +
|
||||
" }" +
|
||||
"}, true);" +
|
||||
"document.addEventListener('contextmenu', function(e) {" +
|
||||
" e.preventDefault();" +
|
||||
"}, true);"
|
||||
);
|
||||
}
|
||||
@@ -443,6 +519,11 @@ public class JFXLauncher extends Application {
|
||||
String url = "http://localhost:" + PORT + "/assets/ui/index.html";
|
||||
engine.load(url);
|
||||
|
||||
javafx.scene.image.Image windowIcon = loadWindowIcon();
|
||||
if (windowIcon != null) {
|
||||
stage.getIcons().add(windowIcon);
|
||||
}
|
||||
|
||||
stage.setTitle(APP_TITLE);
|
||||
int winW = Config.getWindowWidth();
|
||||
int winH = Config.getWindowHeight();
|
||||
@@ -493,6 +574,8 @@ public class JFXLauncher extends Application {
|
||||
server.createContext("/api/account", this::handleAccount);
|
||||
server.createContext("/api/instances", this::handleInstances);
|
||||
server.createContext("/api/launch", this::handleLaunch);
|
||||
server.createContext("/api/offline/status", this::handleOfflineStatus);
|
||||
server.createContext("/api/offline-launch", this::handleOfflineLaunch);
|
||||
server.createContext("/api/install", this::handleInstall);
|
||||
server.createContext("/api/install/progress", this::handleInstallProgress);
|
||||
server.createContext("/api/network/status", this::handleNetworkStatus);
|
||||
@@ -514,6 +597,7 @@ public class JFXLauncher extends Application {
|
||||
server.createContext("/api/exit-parent", this::handleExitParent);
|
||||
server.createContext("/api/system-info", this::handleSystemInfo);
|
||||
server.createContext("/api/open-log-file", this::handleOpenLogFile);
|
||||
server.createContext("/api/clipboard", this::handleClipboard);
|
||||
server.createContext("/api/friends/list", this::handleFriendList);
|
||||
server.createContext("/api/friends/add", this::handleFriendAdd);
|
||||
server.createContext("/api/friends/remove", this::handleFriendRemove);
|
||||
@@ -526,6 +610,12 @@ public class JFXLauncher extends Application {
|
||||
server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
|
||||
server.createContext("/api/whitelist/mods", this::handleWhitelistMods);
|
||||
server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod);
|
||||
server.createContext("/api/mods/toggle", this::handleModsToggle);
|
||||
server.createContext("/api/mods/delete", this::handleModsDelete);
|
||||
server.createContext("/api/pack/open-folder", this::handlePackOpenFolder);
|
||||
server.createContext("/api/pack/settings", this::handlePackSettings);
|
||||
server.createContext("/api/pack/delete", this::handlePackDelete);
|
||||
server.createContext("/api/filemanager", this::handleFileManager);
|
||||
server.createContext("/api/admin", this::handleAdmin);
|
||||
server.createContext("/assets/", this::handleStatic);
|
||||
|
||||
@@ -557,21 +647,43 @@ public class JFXLauncher extends Application {
|
||||
|
||||
private void handleOpenLogFile(HttpExchange exchange) {
|
||||
try {
|
||||
Path target = null;
|
||||
if (gameLogFile != null && Files.exists(gameLogFile)) {
|
||||
target = gameLogFile;
|
||||
} else if (launcherLogFile != null && Files.exists(launcherLogFile)) {
|
||||
target = launcherLogFile;
|
||||
}
|
||||
if (target != null) {
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
Desktop.getDesktop().open(gameLogFile.toFile());
|
||||
sendJson(exchange, Map.of("success", true));
|
||||
Desktop.getDesktop().open(target.toFile());
|
||||
sendJson(exchange, Map.of("success", true, "file", target.toString()));
|
||||
} else {
|
||||
sendJson(exchange, Map.of("success", false, "error", "Desktop not supported"));
|
||||
}
|
||||
} else {
|
||||
sendJson(exchange, Map.of("success", false, "error", "No game log file found"));
|
||||
sendJson(exchange, Map.of("success", false, "error", "No log file found"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleClipboard(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||
String text = body != null ? body.get("text") : null;
|
||||
if (text == null) {
|
||||
sendJson(exchange, Map.of("success", false, "error", "Empty clipboard text"));
|
||||
return;
|
||||
}
|
||||
java.awt.Toolkit.getDefaultToolkit().getSystemClipboard().setContents(
|
||||
new java.awt.datatransfer.StringSelection(text), null);
|
||||
sendJson(exchange, Map.of("success", true));
|
||||
} catch (Exception e) {
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleOpenUrl(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||
@@ -612,9 +724,8 @@ public class JFXLauncher extends Application {
|
||||
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Login error: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
log("Login error: " + AuthManager.friendlyConnectionError(e));
|
||||
sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,7 +752,6 @@ public class JFXLauncher extends Application {
|
||||
try {
|
||||
boolean loggedIn = AuthManager.isLoggedIn();
|
||||
boolean authExists = AuthManager.authFileExists();
|
||||
System.out.println("[AUTH] handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
|
||||
log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
|
||||
if (AuthManager.tryAutoLogin()) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
@@ -650,15 +760,12 @@ public class JFXLauncher extends Application {
|
||||
data.put("role", AuthManager.getRole());
|
||||
data.put("roleName", AuthManager.getRoleName());
|
||||
sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true));
|
||||
System.out.println("[AUTH] Auto-login performed: " + AuthManager.getUsername());
|
||||
log("Auto-login performed: " + AuthManager.getUsername());
|
||||
} else {
|
||||
System.out.println("[AUTH] handleAutoLogin: no valid session (authFile=" + authExists + ")");
|
||||
log("handleAutoLogin: no valid session (authFile=" + authExists + ")");
|
||||
sendJson(exchange, Map.of("success", false, "autoLogin", false));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[AUTH] handleAutoLogin error: " + e.getMessage());
|
||||
log("handleAutoLogin error: " + e.getMessage());
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
}
|
||||
@@ -721,6 +828,54 @@ public class JFXLauncher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleOfflineStatus(HttpExchange exchange) {
|
||||
try {
|
||||
boolean offline = isOfflineMode();
|
||||
sendJson(exchange, Map.of("success", true, "offline", offline));
|
||||
} catch (Exception e) {
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleOfflineLaunch(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||
String name = body.get("name");
|
||||
String nickname = body.get("nickname");
|
||||
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
if (instance == null) {
|
||||
sendJson(exchange, Map.of("success", false, "error", "Pack not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
var result = api.launch().launchOffline(name, nickname);
|
||||
if (result.isSuccess()) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("pid", result.getData().getPid());
|
||||
data.put("status", result.getData().getStatus());
|
||||
sendJson(exchange, Map.of("success", true, "data", data));
|
||||
log("Offline launched: " + name + " pid=" + result.getData().getPid());
|
||||
} else {
|
||||
log("Offline launch failed: " + result.getError());
|
||||
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("handleOfflineLaunch error: " + e.getMessage());
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isOfflineMode() {
|
||||
if ("true".equalsIgnoreCase(System.getProperty("zernmc.offline", "false"))) {
|
||||
return true;
|
||||
}
|
||||
if (ZHttpClient.isNetworkInitialized() && !ZHttpClient.isZernServerHealthy()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void handleInstall(HttpExchange exchange) {
|
||||
if (!installInProgress.compareAndSet(false, true)) {
|
||||
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
|
||||
@@ -769,8 +924,8 @@ public class JFXLauncher extends Application {
|
||||
boolean success = false;
|
||||
|
||||
if (!ZHttpClient.isNetworkInitialized()) {
|
||||
log("Network init not finished, checking Mojang services synchronously...");
|
||||
ZHttpClient.forceCheckMojangServices();
|
||||
log("Network init not finished, running full service check (CLI-style)...");
|
||||
ZHttpClient.checkAllServicesOnStartup();
|
||||
}
|
||||
|
||||
if ("zernmc".equalsIgnoreCase(loader)) {
|
||||
@@ -895,17 +1050,16 @@ public class JFXLauncher extends Application {
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
|
||||
final OutputStream os = exchange.getResponseBody();
|
||||
// Bounded queue: producers (LauncherLogger on the install thread) must
|
||||
// never block on a slow/stalled SSE client. If the UI can't keep up,
|
||||
// lines are dropped here - they still reach the log file and buffer.
|
||||
final java.util.concurrent.BlockingQueue<String> queue =
|
||||
new java.util.concurrent.LinkedBlockingQueue<>(5000);
|
||||
|
||||
LogConsumer consumer = new LogConsumer() {
|
||||
@Override
|
||||
public synchronized void onLog(String line) {
|
||||
try {
|
||||
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
|
||||
os.write(data.getBytes(StandardCharsets.UTF_8));
|
||||
os.flush();
|
||||
} catch (Exception e) {
|
||||
removeLogConsumer(this);
|
||||
}
|
||||
public void onLog(String line) {
|
||||
queue.offer(line);
|
||||
}
|
||||
};
|
||||
consumerRef[0] = consumer;
|
||||
@@ -915,9 +1069,13 @@ public class JFXLauncher extends Application {
|
||||
Thread.currentThread().setName("sse-logs-stream");
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
|
||||
String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS);
|
||||
if (line != null) {
|
||||
os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
os.flush();
|
||||
Thread.sleep(10000);
|
||||
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
|
||||
}
|
||||
} catch (Exception ignored) {} finally {
|
||||
@@ -939,17 +1097,14 @@ public class JFXLauncher extends Application {
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
|
||||
final OutputStream os = exchange.getResponseBody();
|
||||
// Bounded queue: producers must never block on a slow/stalled SSE client.
|
||||
final java.util.concurrent.BlockingQueue<String> queue =
|
||||
new java.util.concurrent.LinkedBlockingQueue<>(5000);
|
||||
|
||||
LogConsumer consumer = new LogConsumer() {
|
||||
@Override
|
||||
public synchronized void onLog(String line) {
|
||||
try {
|
||||
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
|
||||
os.write(data.getBytes(StandardCharsets.UTF_8));
|
||||
os.flush();
|
||||
} catch (Exception e) {
|
||||
removeGameLogConsumer(this);
|
||||
}
|
||||
public void onLog(String line) {
|
||||
queue.offer(line);
|
||||
}
|
||||
};
|
||||
consumerRef[0] = consumer;
|
||||
@@ -959,9 +1114,13 @@ public class JFXLauncher extends Application {
|
||||
Thread.currentThread().setName("sse-game-logs-stream");
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
|
||||
String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS);
|
||||
if (line != null) {
|
||||
os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
os.flush();
|
||||
Thread.sleep(10000);
|
||||
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
|
||||
}
|
||||
} catch (Exception ignored) {} finally {
|
||||
@@ -1080,7 +1239,7 @@ public class JFXLauncher extends Application {
|
||||
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1100,14 +1259,55 @@ public class JFXLauncher extends Application {
|
||||
Path dir = instance.getPath();
|
||||
Map<String, Object> info = new HashMap<>();
|
||||
|
||||
int modsCount = 0;
|
||||
List<Map<String, Object>> modsList = new ArrayList<>();
|
||||
Path modsDir = dir.resolve("mods");
|
||||
if (Files.exists(modsDir)) {
|
||||
try (var files = Files.list(modsDir)) {
|
||||
modsCount = (int) files.filter(p -> p.toString().endsWith(".jar")).count();
|
||||
}
|
||||
files.forEach(p -> {
|
||||
String fn = p.getFileName().toString();
|
||||
boolean isJar = fn.endsWith(".jar");
|
||||
boolean isDisabled = fn.endsWith(".jar.disabled");
|
||||
if (!isJar && !isDisabled) return;
|
||||
String display = isDisabled ? fn.substring(0, fn.length()-9) : fn;
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("fileName", fn);
|
||||
// try parse mod metadata
|
||||
String modId = null, modName = null, modVer = null;
|
||||
try (var zf = new java.util.jar.JarFile(p.toFile())) {
|
||||
var e1 = zf.getEntry("fabric.mod.json");
|
||||
if (e1 != null) {
|
||||
String json = new String(zf.getInputStream(e1).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
var jo = new org.json.JSONObject(json);
|
||||
modId = jo.optString("id", null);
|
||||
modName = jo.optString("name", null);
|
||||
modVer = jo.optString("version", null);
|
||||
} else {
|
||||
var e2 = zf.getEntry("META-INF/mods.toml");
|
||||
if (e2 == null) e2 = zf.getEntry("META-INF/neoforge.mods.toml");
|
||||
if (e2 == null) e2 = zf.getEntry("mods.toml");
|
||||
if (e2 != null) {
|
||||
String toml = new String(zf.getInputStream(e2).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
var mid = java.util.regex.Pattern.compile("modId\\s*=\\s*\"([^\"]+)\"").matcher(toml);
|
||||
if (mid.find()) modId = mid.group(1);
|
||||
var dname = java.util.regex.Pattern.compile("displayName\\s*=\\s*\"([^\"]+)\"").matcher(toml);
|
||||
if (dname.find()) modName = dname.group(1);
|
||||
var ver = java.util.regex.Pattern.compile("version\\s*=\\s*\"([^\"]+)\"").matcher(toml);
|
||||
if (ver.find()) modVer = ver.group(1);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
m.put("displayName", modName != null ? modName : (modId != null ? modId : display));
|
||||
m.put("modId", modId != null ? modId : display.replace(".jar",""));
|
||||
m.put("version", modVer != null ? modVer : "?");
|
||||
m.put("enabled", !isDisabled);
|
||||
try { m.put("size", Files.size(p)); } catch (Exception e) { m.put("size", 0L); }
|
||||
modsList.add(m);
|
||||
});
|
||||
} catch (Exception ignored) {}
|
||||
modsList.sort((a,b) -> String.valueOf(a.get("displayName")).compareToIgnoreCase(String.valueOf(b.get("displayName"))));
|
||||
}
|
||||
info.put("modsCount", modsCount);
|
||||
info.put("modsCount", modsList.size());
|
||||
info.put("mods", modsList);
|
||||
|
||||
List<String> screenshots = new ArrayList<>();
|
||||
Path screenshotsDir = dir.resolve("screenshots");
|
||||
@@ -1177,6 +1377,12 @@ public class JFXLauncher extends Application {
|
||||
if (body.containsKey("systemBasedJvm")) {
|
||||
Config.setSystemBasedJvm(Boolean.parseBoolean(body.get("systemBasedJvm")));
|
||||
}
|
||||
if (body.containsKey("onboardedVersion")) {
|
||||
Config.setOnboardedVersion(body.get("onboardedVersion"));
|
||||
}
|
||||
if (body.containsKey("completedOnboardBlocks")) {
|
||||
Config.setCompletedOnboardBlocks(body.get("completedOnboardBlocks"));
|
||||
}
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
res.put("success", true);
|
||||
res.put("maxMemory", Config.getMaxMemory());
|
||||
@@ -1193,6 +1399,8 @@ public class JFXLauncher extends Application {
|
||||
data.put("javaPath", Config.getJavaPath());
|
||||
data.put("locale", Config.getLocale());
|
||||
data.put("systemBasedJvm", Config.isSystemBasedJvm());
|
||||
data.put("onboardedVersion", Config.getOnboardedVersion());
|
||||
data.put("completedOnboardBlocks", Config.getCompletedOnboardBlocks());
|
||||
data.put("cpuCores", Config.getSystemCpuCores());
|
||||
data.put("totalRamMB", Config.getSystemTotalRamMB());
|
||||
data.put("systemJvmFlags", Config.getSystemJvmFlags());
|
||||
@@ -1624,22 +1832,10 @@ public class JFXLauncher extends Application {
|
||||
Files.createDirectories(modsDir);
|
||||
Path targetFile = modsDir.resolve(modName);
|
||||
|
||||
// Download mod from server
|
||||
// Download mod from server (through ZHttpClient so the read timeout is
|
||||
// actually enforced — raw client.send() hangs forever on a stalled body)
|
||||
String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName;
|
||||
java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder()
|
||||
.uri(java.net.URI.create(url))
|
||||
.timeout(java.time.Duration.ofMinutes(5))
|
||||
.header("Authorization", "Bearer " + AuthManager.getAccessToken())
|
||||
.GET()
|
||||
.build();
|
||||
java.net.http.HttpResponse<Path> resp = java.net.http.HttpClient.newHttpClient()
|
||||
.send(request, java.net.http.HttpResponse.BodyHandlers.ofFile(targetFile));
|
||||
|
||||
if (resp.statusCode() != 200) {
|
||||
Files.deleteIfExists(targetFile);
|
||||
sendJson(exchange, Map.of("success", false, "error", "HTTP " + resp.statusCode()));
|
||||
return;
|
||||
}
|
||||
ZHttpClient.downloadFileWithSmartProxy(url, targetFile);
|
||||
|
||||
sendJson(exchange, Map.of("success", true, "message", modName + " installed"));
|
||||
} catch (Exception e) {
|
||||
@@ -1647,6 +1843,162 @@ public class JFXLauncher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleModsToggle(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String,String> body = parseJson(exchange.getRequestBody());
|
||||
String instName = body.get("instance");
|
||||
String file = body.get("file");
|
||||
String enStr = body.get("enable");
|
||||
if (instName == null || file == null) { sendJson(exchange, Map.of("success", false, "error", "Missing params")); return; }
|
||||
boolean enable = enStr == null || enStr.equals("true");
|
||||
Instance inst = InstanceManager.getInstance(instName);
|
||||
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
|
||||
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot toggle mods in server pack (only whitelist)")); return; }
|
||||
Path modsDir = inst.getPath().resolve("mods");
|
||||
Path src = modsDir.resolve(file);
|
||||
if (!Files.exists(src)) {
|
||||
// Try alternative name (enable: caller may send base name without .disabled)
|
||||
String alt = enable ? file + ".disabled" : file.replace(".disabled", "");
|
||||
Path altSrc = modsDir.resolve(alt);
|
||||
if (Files.exists(altSrc)) src = altSrc;
|
||||
else if (enable && file.endsWith(".jar")) {
|
||||
Path disabledSrc = modsDir.resolve(file + ".disabled");
|
||||
if (Files.exists(disabledSrc)) src = disabledSrc;
|
||||
else { sendJson(exchange, Map.of("success", false, "error", "File not found: " + file)); return; }
|
||||
file = file + ".disabled";
|
||||
} else { sendJson(exchange, Map.of("success", false, "error", "File not found: " + file)); return; }
|
||||
}
|
||||
Path dst;
|
||||
if (enable) {
|
||||
if (file.endsWith(".disabled")) dst = modsDir.resolve(file.substring(0, file.length()-9));
|
||||
else if (src.getFileName().toString().endsWith(".disabled")) dst = modsDir.resolve(src.getFileName().toString().replace(".disabled", ""));
|
||||
else { sendJson(exchange, Map.of("success", true)); return; }
|
||||
} else {
|
||||
if (file.endsWith(".jar")) dst = modsDir.resolve(file + ".disabled");
|
||||
else if (src.getFileName().toString().endsWith(".jar")) dst = modsDir.resolve(src.getFileName().toString() + ".disabled");
|
||||
else { sendJson(exchange, Map.of("success", true)); return; }
|
||||
}
|
||||
if (Files.exists(dst)) Files.delete(dst);
|
||||
Files.move(src, dst, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
sendJson(exchange, Map.of("success", true));
|
||||
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
|
||||
}
|
||||
|
||||
private void handleModsDelete(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String,String> body = parseJson(exchange.getRequestBody());
|
||||
String instName = body.get("instance");
|
||||
String file = body.get("file");
|
||||
if (instName == null || file == null) { sendJson(exchange, Map.of("success", false, "error", "Missing params")); return; }
|
||||
Instance inst = InstanceManager.getInstance(instName);
|
||||
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
|
||||
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot delete mods in server pack")); return; }
|
||||
Path target = inst.getPath().resolve("mods").resolve(file);
|
||||
if (!Files.exists(target)) { sendJson(exchange, Map.of("success", false, "error", "File not found")); return; }
|
||||
Files.delete(target);
|
||||
sendJson(exchange, Map.of("success", true));
|
||||
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
|
||||
}
|
||||
|
||||
private void handlePackOpenFolder(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String,String> body = parseJson(exchange.getRequestBody());
|
||||
String name = body.get("name");
|
||||
if (name == null) {
|
||||
Map<String,String> q = parseQuery(exchange.getRequestURI().getQuery());
|
||||
name = q.get("name");
|
||||
}
|
||||
if (name == null || name.isBlank()) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
|
||||
Instance inst = InstanceManager.getInstance(name);
|
||||
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
|
||||
Path dir = inst.getPath();
|
||||
try {
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
Desktop.getDesktop().open(dir.toFile());
|
||||
} else {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
String cmd = os.contains("win") ? "explorer" : os.contains("mac") ? "open" : "xdg-open";
|
||||
new ProcessBuilder(cmd, dir.toString()).start();
|
||||
}
|
||||
sendJson(exchange, Map.of("success", true));
|
||||
} catch (Exception e) {
|
||||
// fallback xdg
|
||||
try { new ProcessBuilder("xdg-open", dir.toString()).start(); sendJson(exchange, Map.of("success", true)); }
|
||||
catch (Exception e2) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
|
||||
}
|
||||
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
|
||||
}
|
||||
|
||||
private void handlePackSettings(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String,String> body = parseJson(exchange.getRequestBody());
|
||||
String name = body.get("name");
|
||||
String newName = body.get("newName");
|
||||
String loader = body.get("loader");
|
||||
String loaderVersion = body.get("loaderVersion");
|
||||
if (name == null) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
|
||||
Instance inst = InstanceManager.getInstance(name);
|
||||
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
|
||||
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot edit server pack")); return; }
|
||||
boolean renamed = false;
|
||||
if (newName != null && !newName.isBlank() && !newName.equals(name)) {
|
||||
if (newName.matches(".*[\\\\/:*?\"<>|].*") || newName.length() > 32) { sendJson(exchange, Map.of("success", false, "error", "Invalid name")); return; }
|
||||
if (InstanceManager.getInstance(newName) != null) { sendJson(exchange, Map.of("success", false, "error", "Name already exists")); return; }
|
||||
Path src = inst.getPath();
|
||||
Path dst = src.getParent().resolve(newName);
|
||||
Files.move(src, dst);
|
||||
// need to update instance path internally - recreate
|
||||
inst = InstanceManager.getInstance(newName);
|
||||
renamed = true;
|
||||
}
|
||||
Instance target = renamed ? InstanceManager.getInstance(newName != null ? newName : name) : inst;
|
||||
if (loader != null && !loader.isBlank()) {
|
||||
target.setLoaderType(loader);
|
||||
if (loaderVersion != null) target.setLoaderVersion(loaderVersion);
|
||||
// if loader changed, reinstall loader libs
|
||||
if (!"vanilla".equalsIgnoreCase(loader) && loaderVersion != null && !loaderVersion.isBlank()) {
|
||||
// trigger install in background? For now just save meta; user will reinstall manually via manage
|
||||
}
|
||||
}
|
||||
sendJson(exchange, Map.of("success", true, "renamed", renamed));
|
||||
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
|
||||
}
|
||||
|
||||
private void handlePackDelete(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String,String> body = parseJson(exchange.getRequestBody());
|
||||
String name = body.get("name");
|
||||
if (name == null) {
|
||||
Map<String,String> q = parseQuery(exchange.getRequestURI().getQuery());
|
||||
name = q.get("name");
|
||||
}
|
||||
if (name == null || name.isBlank()) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
|
||||
boolean ok = InstanceManager.deleteInstance(name);
|
||||
sendJson(exchange, Map.of("success", ok, "error", ok ? "" : "Delete failed"));
|
||||
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
|
||||
}
|
||||
|
||||
private void handleFileManager(HttpExchange exchange) {
|
||||
try {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
String fm;
|
||||
if (os.contains("win")) fm = "Explorer";
|
||||
else if (os.contains("mac")) fm = "Finder";
|
||||
else {
|
||||
String desktop = System.getenv("XDG_CURRENT_DESKTOP");
|
||||
if (desktop == null) desktop = System.getenv("DESKTOP_SESSION");
|
||||
if (desktop != null) {
|
||||
String d = desktop.toLowerCase();
|
||||
if (d.contains("gnome")) fm = "Nautilus";
|
||||
else if (d.contains("kde")) fm = "Dolphin";
|
||||
else if (d.contains("xfce")) fm = "Thunar";
|
||||
else fm = "File Manager ("+desktop+")";
|
||||
} else fm = "File Manager";
|
||||
}
|
||||
sendJson(exchange, Map.of("success", true, "data", Map.of("name", fm, "os", os)));
|
||||
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
|
||||
}
|
||||
|
||||
// ====================== ADMIN ======================
|
||||
|
||||
private void handleAdmin(HttpExchange exchange) {
|
||||
@@ -1735,7 +2087,7 @@ public class JFXLauncher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
private void log(String msg) {
|
||||
private static void log(String msg) {
|
||||
String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"));
|
||||
String entry = "[" + timestamp + "] " + msg;
|
||||
synchronized (launcherLogBuffer) {
|
||||
|
||||
@@ -13,7 +13,7 @@ public class Config {
|
||||
private static final Properties props = new Properties();
|
||||
|
||||
private static volatile int maxMemory = 4096;
|
||||
private static volatile String serverUrl = "https://api.zernmc.ru";
|
||||
private static volatile String serverUrl = "https://api.zern.cc";
|
||||
private static volatile String lastUsername = "Player";
|
||||
private static volatile int windowWidth = 1280;
|
||||
private static volatile int windowHeight = 720;
|
||||
@@ -22,6 +22,8 @@ public class Config {
|
||||
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();
|
||||
@@ -61,6 +63,8 @@ public class Config {
|
||||
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());
|
||||
@@ -79,6 +83,8 @@ public class Config {
|
||||
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");
|
||||
@@ -144,6 +150,11 @@ public class Config {
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
public static void setServerUrl(String url) {
|
||||
serverUrl = url != null && !url.isBlank() ? url : serverUrl;
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getLastUsername() {
|
||||
return lastUsername;
|
||||
}
|
||||
@@ -261,4 +272,9 @@ public class Config {
|
||||
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(); }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class LauncherLogger {
|
||||
|
||||
@@ -25,6 +26,7 @@ public class LauncherLogger {
|
||||
private static final int STDOUT_QUEUE_CAPACITY = 2048;
|
||||
private static final BlockingQueue<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(() -> {
|
||||
@@ -51,11 +53,10 @@ public class LauncherLogger {
|
||||
try {
|
||||
Path logsDir = Paths.get("").toAbsolutePath().resolve("logs");
|
||||
Files.createDirectories(logsDir);
|
||||
logFile = logsDir.resolve("launcher.log");
|
||||
rotateLogs(logsDir);
|
||||
logFile = logsDir.resolve("latest.log");
|
||||
|
||||
Files.writeString(logFile,
|
||||
"=== Launcher Log " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " ===\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
Files.writeString(logFile, sessionHeader(), StandardOpenOption.CREATE);
|
||||
|
||||
System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
@@ -64,6 +65,62 @@ public class LauncherLogger {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -97,9 +154,12 @@ public class LauncherLogger {
|
||||
// block application threads.
|
||||
offerStdout(line);
|
||||
|
||||
// File and UI log never get ANSI escape codes; the console keeps them.
|
||||
String plainLine = ANSI_PATTERN.matcher(line).replaceAll("");
|
||||
|
||||
// Forward to the JFX UI log so installer/progress output is visible
|
||||
// in the GUI. The bridge is a safe no-op when JavaFX is unavailable.
|
||||
JFXBridge.appendLauncherLogFromLogger(line);
|
||||
JFXBridge.appendLauncherLogFromLogger(plainLine);
|
||||
|
||||
if (t != null) {
|
||||
StringWriter sw = new StringWriter();
|
||||
@@ -120,7 +180,7 @@ public class LauncherLogger {
|
||||
}
|
||||
if (acquired) {
|
||||
try {
|
||||
Files.writeString(logFile, line + "\n", StandardOpenOption.APPEND);
|
||||
Files.writeString(logFile, plainLine + "\n", StandardOpenOption.APPEND);
|
||||
if (t != null) {
|
||||
StringWriter sw = new StringWriter();
|
||||
PrintWriter pw = new PrintWriter(sw);
|
||||
|
||||
@@ -4,81 +4,107 @@ import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
|
||||
|
||||
import java.text.DecimalFormat;
|
||||
|
||||
/**
|
||||
* Progress reporting for installs/downloads.
|
||||
*
|
||||
* IMPORTANT: never writes directly to System.out/System.err. In JFX mode the
|
||||
* launcher runs under javaw.exe whose stdout pipe is drained by the parent exe;
|
||||
* once that pipe fills (or the exe stops reading), a synchronous
|
||||
* System.out.write/flush blocks the calling (install/UI) thread forever.
|
||||
* All console/progress output must go through LauncherLogger, which is
|
||||
* non-blocking by design (bounded stdout queue + tryLock file write + SSE queue).
|
||||
*/
|
||||
public class ProgressBar {
|
||||
|
||||
private static final int BAR_LENGTH = 40;
|
||||
private static final DecimalFormat DF = new DecimalFormat("#.##");
|
||||
|
||||
|
||||
private static String currentLabel = "";
|
||||
private static long currentTotal = 0;
|
||||
|
||||
private static final long DOWNLOAD_LOG_INTERVAL_MS = 500;
|
||||
private static volatile long lastDownloadLogMs = 0;
|
||||
private static volatile long lastIndeterminateLogMs = 0;
|
||||
|
||||
public static void show(String label, long current, long total, String unit) {
|
||||
currentLabel = label;
|
||||
currentTotal = total;
|
||||
|
||||
|
||||
JFXBridge.setInstallProgress(label, (int) current, (int) total);
|
||||
|
||||
|
||||
if (total <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
LauncherLogger.info(label + " ...");
|
||||
return;
|
||||
}
|
||||
double progress = (double) current / total;
|
||||
int filled = (int) (progress * BAR_LENGTH);
|
||||
String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled);
|
||||
int percent = (int) (progress * 100);
|
||||
|
||||
String text = String.format("%s [%s] %3d%% (%d/%d %s)",
|
||||
ZAnsi.cyan(label), bar, percent, current, total, unit);
|
||||
|
||||
System.out.print("\r" + text);
|
||||
System.out.flush();
|
||||
int percent = (int) ((double) current / total * 100);
|
||||
LauncherLogger.info(label + " " + percent + "% (" + current + "/" + total + " " + unit + ")");
|
||||
}
|
||||
|
||||
public static void showDownload(String label, long downloaded, long totalBytes) {
|
||||
currentLabel = label;
|
||||
currentTotal = totalBytes;
|
||||
|
||||
|
||||
JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
|
||||
|
||||
if (totalBytes <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
return;
|
||||
|
||||
// Throttle console/file logging: called once per chunk during downloads.
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||
lastDownloadLogMs = now;
|
||||
LauncherLogger.info(label + " " + formatBytes(downloaded)
|
||||
+ (totalBytes > 0 ? "/" + formatBytes(totalBytes) : ""));
|
||||
}
|
||||
|
||||
double progress = (double) downloaded / totalBytes;
|
||||
int filled = (int) (progress * BAR_LENGTH);
|
||||
String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled);
|
||||
String percent = DF.format(progress * 100);
|
||||
|
||||
String text = String.format("%s [%s] %6s%% %s / %s",
|
||||
ZAnsi.cyan(label),
|
||||
bar,
|
||||
percent,
|
||||
formatBytes(downloaded),
|
||||
formatBytes(totalBytes));
|
||||
|
||||
System.out.print("\r" + text);
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
public static void showAnimated(String label, long current, long total, String unit) {
|
||||
currentLabel = label;
|
||||
currentTotal = total;
|
||||
|
||||
|
||||
JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100));
|
||||
|
||||
if (total <= 0) {
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (current / 1024) % 4;
|
||||
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
|
||||
} else {
|
||||
show(label, (int) ((current * 100) / total), 100, unit);
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||
lastDownloadLogMs = now;
|
||||
LauncherLogger.info(label + ": " + formatBytes(current)
|
||||
+ (total > 0 ? "/" + formatBytes(total) + " " + unit : ""));
|
||||
}
|
||||
}
|
||||
|
||||
public static void finish(String message) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓"));
|
||||
System.out.flush();
|
||||
LauncherLogger.info(message + " done");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a stage without a known total (e.g. running the Forge/NeoForge/Fabric
|
||||
* installer subprocess). Always updates the UI label so the user sees what the
|
||||
* launcher is doing right now; console/file logging is throttled to avoid spam.
|
||||
*/
|
||||
public static void showIndeterminate(String label) {
|
||||
currentLabel = label;
|
||||
currentTotal = 0;
|
||||
|
||||
JFXBridge.setInstallProgress(label, 0, 0);
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastIndeterminateLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||
lastIndeterminateLogMs = now;
|
||||
LauncherLogger.info(label + " ...");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips ANSI codes, indentation and [category] prefixes from a child-process
|
||||
* output line so it can be used as a short, human-readable progress label.
|
||||
*/
|
||||
public static String cleanLine(String line) {
|
||||
if (line == null) return "";
|
||||
String s = line.replaceAll("\\u001B\\[[;\\d]*m", "").trim();
|
||||
int bracket = s.indexOf("] ");
|
||||
if (s.startsWith("[") && bracket > 0) {
|
||||
s = s.substring(bracket + 2).trim();
|
||||
}
|
||||
if (s.length() > 60) {
|
||||
s = s.substring(0, 57) + "...";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public static void setStage(String stageName, int stageIndex, int stageCount) {
|
||||
@@ -86,8 +112,7 @@ public class ProgressBar {
|
||||
}
|
||||
|
||||
public static void clearLine() {
|
||||
System.out.print("\r" + " ".repeat(110) + "\r");
|
||||
System.out.flush();
|
||||
// No-op: console progress now goes through LauncherLogger.
|
||||
}
|
||||
|
||||
public static String formatBytes(long bytes) {
|
||||
@@ -95,4 +120,4 @@ public class ProgressBar {
|
||||
if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB";
|
||||
return DF.format(bytes / (1024.0 * 1024)) + " MB";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -19,18 +20,54 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class ZHttpClient {
|
||||
|
||||
/**
|
||||
* Dedicated daemon executor for the shared HttpClient. sendAsync() work runs
|
||||
* here (never the common ForkJoinPool, so a stalled download cannot starve
|
||||
* JFX's pools), and daemon threads never keep the launcher JVM alive.
|
||||
*/
|
||||
private static final ExecutorService CLIENT_EXECUTOR = Executors.newCachedThreadPool(r -> {
|
||||
Thread t = new Thread(r, "zern-http-client");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
private static final HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.executor(CLIENT_EXECUTOR)
|
||||
.build();
|
||||
|
||||
private static String BASE_URL = "https://api.zernmc.ru";
|
||||
private static String BASE_URL = "https://api.zern.cc";
|
||||
|
||||
/**
|
||||
* Alternate proxy endpoints (geo mirrors) used when BASE_URL's proxy fails with
|
||||
* a connection error (e.g. CDN "Connection reset" from the current region).
|
||||
* Stay in sync with LAUNCHER_MIRRORS in server/main.py.
|
||||
*/
|
||||
private static final List<String> GEO_PROXY_BASES = List.of(
|
||||
"https://api.zernmc.ru",
|
||||
"https://api.zernmc.online",
|
||||
"https://api.pl.zern.cc",
|
||||
"https://api.swe.zern.cc"
|
||||
);
|
||||
|
||||
/**
|
||||
* Extra proxy bases discovered at runtime (e.g. mirrors fetched from
|
||||
* /launcher/mirrors by DomainSelector). Kept in insertion order, deduplicated.
|
||||
*/
|
||||
private static final List<String> extraProxyBases = new ArrayList<>();
|
||||
|
||||
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
|
||||
private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
|
||||
@@ -40,12 +77,44 @@ public class ZHttpClient {
|
||||
BASE_URL = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an additional proxy base URL (mirror/geo domain) that the smart
|
||||
* proxy fallback may rotate through. No-op if already present.
|
||||
*/
|
||||
public static void registerProxyBase(String url) {
|
||||
if (url == null || url.isBlank()) return;
|
||||
String normalized = url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
|
||||
synchronized (extraProxyBases) {
|
||||
if (!extraProxyBases.contains(normalized)) {
|
||||
extraProxyBases.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered, deduplicated list of proxy endpoints to attempt: the primary
|
||||
* BASE_URL first, then geo mirrors and any runtime-discovered mirrors.
|
||||
*/
|
||||
private static List<String> getProxyBases() {
|
||||
List<String> bases = new ArrayList<>();
|
||||
bases.add(BASE_URL);
|
||||
for (String geo : GEO_PROXY_BASES) {
|
||||
if (!bases.contains(geo)) bases.add(geo);
|
||||
}
|
||||
synchronized (extraProxyBases) {
|
||||
for (String m : extraProxyBases) {
|
||||
if (!bases.contains(m)) bases.add(m);
|
||||
}
|
||||
}
|
||||
return bases;
|
||||
}
|
||||
|
||||
public static String getBaseUrl() {
|
||||
return BASE_URL;
|
||||
}
|
||||
|
||||
public enum ServiceType {
|
||||
ZERN_SERVER("https://api.zernmc.ru", true),
|
||||
ZERN_SERVER("https://api.zern.cc", true),
|
||||
FABRIC_META("https://meta.fabricmc.net", false),
|
||||
FABRIC_MAVEN("https://maven.fabricmc.net", false),
|
||||
MOJANG_META("https://piston-meta.mojang.com", false),
|
||||
@@ -129,12 +198,19 @@ public class ZHttpClient {
|
||||
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
|
||||
}
|
||||
}
|
||||
LauncherLogger.info("[NET] " + service.name() + ": "
|
||||
+ (isHealthy ? "OK" : "unavailable") +
|
||||
(serviceProxyMode.get(service) ? " (proxy mode)" : " (direct)"));
|
||||
}
|
||||
|
||||
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
|
||||
if (verbose) {
|
||||
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
|
||||
}
|
||||
LauncherLogger.warn("[NET] Critical: Zern server unreachable");
|
||||
} else {
|
||||
String ip = getClientPublicIp();
|
||||
LauncherLogger.info("[NET] Client public IP: " + (ip != null ? ip : "unknown"));
|
||||
}
|
||||
|
||||
proxyTested.set(true);
|
||||
@@ -144,6 +220,29 @@ public class ZHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the Zern server what public IP it sees for this client (used to
|
||||
* attribute user logs when login/registration fails). Returns null on failure.
|
||||
*/
|
||||
public static String getClientPublicIp() {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(BASE_URL + "/launcher/ip"))
|
||||
.timeout(Duration.ofMillis(15000))
|
||||
.GET()
|
||||
.header("User-Agent", "ZernMC-Launcher/IPCheck")
|
||||
.build();
|
||||
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 15);
|
||||
if (response.statusCode() == 200) {
|
||||
JSONObject json = new JSONObject(response.body());
|
||||
return json.optString("ip", null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void forceCheckMojangServices() {
|
||||
System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
|
||||
|
||||
@@ -164,6 +263,9 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
private static boolean checkServiceHealth(ServiceType service) {
|
||||
if (service == ServiceType.ZERN_SERVER) {
|
||||
return checkDirectConnection(BASE_URL);
|
||||
}
|
||||
return checkDirectConnection(service.getBaseUrl());
|
||||
}
|
||||
|
||||
@@ -219,7 +321,7 @@ public class ZHttpClient {
|
||||
if (isHealthy && serviceProxyMode.get(service)) {
|
||||
serviceProxyMode.put(service, false);
|
||||
serviceFailCount.put(service, 0);
|
||||
System.out.println(ZAnsi.green("[NET] " + service.name() + " restored, switched to direct connection"));
|
||||
LauncherLogger.info("[NET] " + service.name() + " restored, switched to direct connection");
|
||||
} else if (!isHealthy && !serviceProxyMode.get(service)) {
|
||||
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
|
||||
serviceFailCount.put(service, fails);
|
||||
@@ -227,7 +329,7 @@ public class ZHttpClient {
|
||||
|
||||
if (fails >= MAX_FAILS_BEFORE_PROXY) {
|
||||
serviceProxyMode.put(service, true);
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " unavailable, proxy mode enabled"));
|
||||
LauncherLogger.warn("[NET] " + service.name() + " unavailable, proxy mode enabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,6 +373,7 @@ public class ZHttpClient {
|
||||
msg.contains("connection") ||
|
||||
msg.contains("timeout") ||
|
||||
msg.contains("refused") ||
|
||||
msg.contains("received within") ||
|
||||
msg.contains("closed") ||
|
||||
msg.contains("reset") ||
|
||||
msg.contains("abort");
|
||||
@@ -296,23 +399,46 @@ public class ZHttpClient {
|
||||
|
||||
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
|
||||
serviceProxyMode.put(service, true);
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " blocked, switching to proxy"));
|
||||
LauncherLogger.warn("[NET] " + service.name() + " blocked, switching to proxy");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous HTTP send with a hard timeout. Uses client.send() (blocking on the
|
||||
* calling thread) rather than sendAsync(...).get(...) — the latter submits work
|
||||
* to the HttpClient's executor and, when that was a non-daemon CachedThreadPool,
|
||||
* kept the JVM alive / could starve the common ForkJoinPool (the v1.0.14.2
|
||||
* "JFX hang (ForkJoinPool)" regression, re-introduced in v1.0.14.3).
|
||||
* The request-level .timeout(Duration) bounds the actual network I/O.
|
||||
* HTTP send with a hard, actually-enforced timeout.
|
||||
*
|
||||
* NB: HttpRequest.timeout() alone is NOT reliable for streaming body handlers
|
||||
* (BodyHandlers.ofFile/ofString): the JDK cancels the request timer as soon as
|
||||
* response headers arrive, so a download that stalls mid-body blocks
|
||||
* client.send() forever (no exception, no retry, no proxy fallback — the
|
||||
* exact "install hangs at 0%" bug). Therefore we drive the exchange through
|
||||
* sendAsync() + future.get(timeout) and abort it with cancel() on timeout.
|
||||
*
|
||||
* The client runs on a dedicated daemon executor (see CLIENT_EXECUTOR), so
|
||||
* this does not reintroduce the v1.0.14.2 JFX hang (ForkJoinPool starvation /
|
||||
* non-daemon executor keeping the JVM alive) that forced the old sync-send
|
||||
* implementation.
|
||||
*/
|
||||
private static <T> HttpResponse<T> sendBounded(HttpRequest request, HttpResponse.BodyHandler<T> handler, long timeoutSeconds) throws IOException, InterruptedException {
|
||||
HttpRequest timed = HttpRequest.newBuilder(request, (name, value) -> true)
|
||||
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||
.build();
|
||||
return client.send(timed, handler);
|
||||
CompletableFuture<HttpResponse<T>> future = client.sendAsync(timed, handler);
|
||||
try {
|
||||
return future.get(timeoutSeconds, TimeUnit.SECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
throw new HttpTimeoutException("Response not received within " + timeoutSeconds + "s: " + timed.uri());
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof IOException) throw (IOException) cause;
|
||||
if (cause instanceof InterruptedException) throw (InterruptedException) cause;
|
||||
if (cause instanceof RuntimeException) throw (RuntimeException) cause;
|
||||
throw new IOException("HTTP request failed: " + timed.uri(), cause);
|
||||
} catch (InterruptedException e) {
|
||||
future.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
|
||||
@@ -342,11 +468,15 @@ public class ZHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
List<String> proxyBases = getProxyBases();
|
||||
int maxRetries = 3;
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
// Rotate through every proxy base so one region's Connection Reset does not
|
||||
// burn all retries on the same endpoint.
|
||||
String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size());
|
||||
try {
|
||||
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
||||
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
|
||||
String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl;
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(proxyUrl))
|
||||
@@ -366,8 +496,10 @@ public class ZHttpClient {
|
||||
|
||||
} catch (Exception e) {
|
||||
if (attempt == maxRetries || !isRetryableError(e)) {
|
||||
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
|
||||
throw new IOException("Failed to fetch data directly or via proxy (" + proxyBase + "): " + e.getMessage(), e);
|
||||
}
|
||||
LauncherLogger.warn("[NET] Proxy fetch attempt " + attempt + "/" + maxRetries
|
||||
+ " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base");
|
||||
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
|
||||
}
|
||||
}
|
||||
@@ -376,6 +508,11 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
|
||||
Path tmp = target.resolveSibling(target.getFileName() + ".part");
|
||||
try {
|
||||
Files.deleteIfExists(tmp);
|
||||
} catch (IOException ignored) {}
|
||||
|
||||
if (!shouldUseProxyForUrl(url)) {
|
||||
try {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||
@@ -393,12 +530,14 @@ public class ZHttpClient {
|
||||
|
||||
HttpRequest request = requestBuilder.build();
|
||||
|
||||
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 45);
|
||||
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 45);
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
moveIntoPlace(tmp, target);
|
||||
directSuccessCount++;
|
||||
return;
|
||||
}
|
||||
// non-200 response: fall through to proxy below
|
||||
} catch (Exception e) {
|
||||
if (isConnectionError(e)) {
|
||||
directFailCount++;
|
||||
@@ -410,11 +549,19 @@ public class ZHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
List<String> proxyBases = getProxyBases();
|
||||
int maxRetries = 3;
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
// Rotate through every proxy base so one region's Connection Reset does not
|
||||
// burn all retries on the same endpoint.
|
||||
String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size());
|
||||
try {
|
||||
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
||||
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
|
||||
String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl;
|
||||
|
||||
try {
|
||||
Files.deleteIfExists(tmp);
|
||||
} catch (IOException ignored) {}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(proxyUrl))
|
||||
@@ -423,24 +570,45 @@ public class ZHttpClient {
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 300);
|
||||
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 300);
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("Proxy download failed: HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
moveIntoPlace(tmp, target);
|
||||
proxySuccessCount++;
|
||||
return;
|
||||
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
Files.deleteIfExists(tmp);
|
||||
} catch (IOException ignored) {}
|
||||
if (attempt == maxRetries || !isRetryableError(e)) {
|
||||
throw new IOException("Proxy download failed: " + e.getMessage(), e);
|
||||
throw new IOException("Proxy download failed (" + proxyBase + "): " + e.getMessage(), e);
|
||||
}
|
||||
LauncherLogger.warn("[NET] Proxy download attempt " + attempt + "/" + maxRetries
|
||||
+ " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base");
|
||||
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replaces the destination with the fully-downloaded temp file.
|
||||
* Prevents a Connection Reset mid-transfer from leaving a corrupt partial
|
||||
* file at the final path that later "file exists" checks would treat as installed.
|
||||
*/
|
||||
private static void moveIntoPlace(Path tmp, Path target) throws IOException {
|
||||
Files.createDirectories(target.getParent());
|
||||
try {
|
||||
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (java.nio.file.AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries a request against the Zern server. The server proxies are slow on cold cache
|
||||
* and may return transient 502/5xx when upstreams are flaky, so a single failure should
|
||||
@@ -455,7 +623,7 @@ public class ZHttpClient {
|
||||
last = e;
|
||||
if (!isRetryableError(e)) throw e;
|
||||
if (attempt < maxAttempts) {
|
||||
System.out.println(ZAnsi.yellow("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint));
|
||||
LauncherLogger.warn("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint + " (" + e.getMessage() + ")");
|
||||
try { Thread.sleep(500L * attempt); } catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw ie;
|
||||
@@ -501,7 +669,7 @@ public class ZHttpClient {
|
||||
try {
|
||||
return getMojangVersionManifestViaServer();
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta..."));
|
||||
LauncherLogger.warn("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta...");
|
||||
return new JSONObject(getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"));
|
||||
}
|
||||
}
|
||||
@@ -523,17 +691,30 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
|
||||
long deadline = System.currentTimeMillis() + 90_000;
|
||||
LauncherLogger.info("[NET] Fetching version info for " + versionId + " via server proxy...");
|
||||
try {
|
||||
return getMojangVersionViaServer(versionId);
|
||||
JSONObject result = getMojangVersionViaServer(versionId);
|
||||
LauncherLogger.info("[NET] Version info for " + versionId + " fetched via proxy (" + (System.currentTimeMillis() - (deadline - 90_000)) + "ms)");
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta..."));
|
||||
if (System.currentTimeMillis() >= deadline) {
|
||||
throw new IOException("Version " + versionId + " fetch timed out after 90s: " + e.getMessage(), e);
|
||||
}
|
||||
LauncherLogger.warn("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta...");
|
||||
JSONObject manifest = getMojangVersionManifest();
|
||||
JSONArray versions = manifest.getJSONArray("versions");
|
||||
|
||||
for (int i = 0; i < versions.length(); i++) {
|
||||
JSONObject v = versions.getJSONObject(i);
|
||||
if (v.getString("id").equals(versionId)) {
|
||||
return new JSONObject(getWithSmartProxy(v.getString("url")));
|
||||
if (System.currentTimeMillis() >= deadline) {
|
||||
throw new IOException("Version " + versionId + " fetch timed out after 90s");
|
||||
}
|
||||
LauncherLogger.info("[NET] Fetching version info for " + versionId + " directly...");
|
||||
JSONObject result = new JSONObject(getWithSmartProxy(v.getString("url")));
|
||||
LauncherLogger.info("[NET] Version info for " + versionId + " fetched directly");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
throw new IOException("Version " + versionId + " not found");
|
||||
@@ -574,12 +755,12 @@ public class ZHttpClient {
|
||||
try {
|
||||
repairLibrariesFromJson(versionJson, minecraftDir);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage()));
|
||||
LauncherLogger.warn("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
System.out.println(ZAnsi.yellow("[LIB] Repair scan failed: " + e.getMessage()));
|
||||
LauncherLogger.warn("[LIB] Repair scan failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,15 +800,92 @@ public class ZHttpClient {
|
||||
}
|
||||
} catch (Exception e) {
|
||||
String libName = lib.optString("name", lib.optString("path", "?"));
|
||||
System.out.println(ZAnsi.yellow("[LIB] Repair failed for " + libName + ": " + e.getMessage()));
|
||||
LauncherLogger.warn("[LIB] Repair failed for " + libName + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (repaired > 0) {
|
||||
System.out.println(ZAnsi.green("[LIB] Repaired " + repaired + " missing libraries"));
|
||||
LauncherLogger.info("[LIB] Repaired " + repaired + " missing libraries");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-downloads every library referenced by a loader installer jar (Forge/NeoForge).
|
||||
* The installer later runs as a separate JVM subprocess that downloads libraries
|
||||
* directly from the loader's maven repository, bypassing this launcher's smart proxy.
|
||||
* On blocked networks (e.g. maven.minecraftforge.net unreachable) that subprocess
|
||||
* fails with "Connection reset". Because the installer reuses any library already
|
||||
* present with a matching checksum ("File exists: Checksum validated"), pre-seeding
|
||||
* those files through the proxy lets the install complete without direct downloads.
|
||||
*
|
||||
* @return number of new library files downloaded
|
||||
*/
|
||||
public static int prefetchInstallerLibraries(Path installerJar, Path minecraftDir) {
|
||||
int prefetched = 0;
|
||||
try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(installerJar.toFile())) {
|
||||
for (String jsonName : List.of("version.json", "install_profile.json")) {
|
||||
java.util.zip.ZipEntry entry = zip.getEntry(jsonName);
|
||||
if (entry == null) continue;
|
||||
String content = new String(zip.getInputStream(entry).readAllBytes(), StandardCharsets.UTF_8);
|
||||
JSONObject root = new JSONObject(content);
|
||||
JSONArray libs = root.optJSONArray("libraries");
|
||||
if (libs == null) continue;
|
||||
for (int i = 0; i < libs.length(); i++) {
|
||||
JSONObject lib = libs.optJSONObject(i);
|
||||
if (lib == null) continue;
|
||||
JSONObject downloads = lib.optJSONObject("downloads");
|
||||
if (downloads == null) continue;
|
||||
JSONObject artifact = downloads.optJSONObject("artifact");
|
||||
if (artifact == null) continue;
|
||||
String url = artifact.optString("url", null);
|
||||
String path = artifact.optString("path", null);
|
||||
String sha1 = artifact.optString("sha1", null);
|
||||
if (url == null || path == null) continue;
|
||||
|
||||
Path target = minecraftDir.resolve("libraries").resolve(path);
|
||||
try {
|
||||
if (Files.exists(target)) {
|
||||
if (sha1 == null || sha1.isEmpty() || sha1Matches(target, sha1)) {
|
||||
continue;
|
||||
}
|
||||
Files.delete(target);
|
||||
}
|
||||
Files.createDirectories(target.getParent());
|
||||
downloadFileWithSmartProxy(url, target);
|
||||
if (sha1 != null && !sha1.isEmpty() && !sha1Matches(target, sha1)) {
|
||||
Files.delete(target);
|
||||
LauncherLogger.warn("[LIB] Checksum mismatch after prefetch: " + path);
|
||||
continue;
|
||||
}
|
||||
prefetched++;
|
||||
LauncherLogger.info("[LIB] Prefetched " + path);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("[LIB] Prefetch failed for " + path + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("[LIB] Installer prefetch scan failed: " + e.getMessage());
|
||||
}
|
||||
return prefetched;
|
||||
}
|
||||
|
||||
private static boolean sha1Matches(Path file, String expectedSha1) throws Exception {
|
||||
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-1");
|
||||
try (java.io.InputStream in = Files.newInputStream(file)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest.digest()) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString().equalsIgnoreCase(expectedSha1);
|
||||
}
|
||||
|
||||
private static String resolveCoordinatesUrl(JSONObject lib) {
|
||||
String name = lib.getString("name");
|
||||
String base = lib.getString("url");
|
||||
@@ -724,6 +982,10 @@ public class ZHttpClient {
|
||||
return proxyTested.get();
|
||||
}
|
||||
|
||||
public static boolean isZernServerHealthy() {
|
||||
return serviceHealthy.getOrDefault(ServiceType.ZERN_SERVER, false);
|
||||
}
|
||||
|
||||
public static Map<String, Object> getNetworkStatus() {
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
status.put("initialized", proxyTested.get());
|
||||
|
||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -39,6 +39,7 @@
|
||||
<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>
|
||||
@@ -57,6 +58,38 @@
|
||||
</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>
|
||||
@@ -108,24 +141,30 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
|
||||
<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 id="server-packs-list" class="pack-list"></div>
|
||||
<div class="pack-list-collapse"><div id="server-packs-list" class="pack-list"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section" id="local-packs-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
|
||||
<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 id="local-packs-list" class="pack-list"></div>
|
||||
<div class="pack-list-collapse"><div id="local-packs-list" class="pack-list"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,6 +245,16 @@
|
||||
<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>
|
||||
|
||||
@@ -480,6 +529,15 @@
|
||||
<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>
|
||||
@@ -511,7 +569,7 @@
|
||||
<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.req('/open-log-file', {method:'POST'})"><span data-i18n="logViewer.openFile">Open File</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>
|
||||
@@ -531,7 +589,7 @@
|
||||
<div class="modal-body">
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab active" data-tab="zernmc"><span data-i18n="install.tab.serverPack">Server Pack</span></button>
|
||||
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span> <span class="tag-wip">WIP</span></button>
|
||||
<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">
|
||||
@@ -551,11 +609,28 @@
|
||||
</div>
|
||||
|
||||
<div id="tab-custom" class="modal-tab-content">
|
||||
<div class="disabled-tab">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<h3 data-i18n="install.custom.unavailable">Not available yet</h3>
|
||||
<p data-i18n="install.custom.desc">Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.</p>
|
||||
<div 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">
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body { -webkit-user-select: none; user-select: none; }
|
||||
.pack-entry, .pack-entry *, .ctx-menu, .ctx-menu * { -webkit-user-select: none; user-select: none; }
|
||||
input, textarea, [contenteditable], .log-viewer-content, .news-modal-body, .pack-description-text { -webkit-user-select: text; user-select: text; }
|
||||
.pack-entry { -webkit-touch-callout: default; }
|
||||
|
||||
html { font-size: 14px; }
|
||||
|
||||
body {
|
||||
@@ -42,8 +47,11 @@ body {
|
||||
|
||||
#bg-canvas {
|
||||
position: fixed; inset: 0; width: 100%; height: 100%;
|
||||
z-index: 0; opacity: 0.08; pointer-events: none;
|
||||
z-index: 0; opacity: 0.14; pointer-events: none;
|
||||
}
|
||||
#login-screen.exiting { pointer-events: none; }
|
||||
.login-container.exit { will-change: transform, opacity, filter; }
|
||||
#bg-canvas.bg-exit { will-change: transform; }
|
||||
|
||||
#app { position: relative; z-index: 1; height: 100vh; display: flex; }
|
||||
|
||||
@@ -99,6 +107,14 @@ body {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||
}
|
||||
.field input.invalid {
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 0 2px rgba(248,113,113,0.15);
|
||||
}
|
||||
.field .field-hint {
|
||||
font-size: 12px; color: var(--error); line-height: 1.4;
|
||||
}
|
||||
.field .field-hint.hidden { display: none; }
|
||||
.field select {
|
||||
width: 100%; padding: 10px 12px; font-size: 15px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
@@ -211,12 +227,38 @@ body {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 8px; padding: 0 4px;
|
||||
}
|
||||
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
|
||||
.section-collapsible .section-header { cursor: pointer; user-select: none; }
|
||||
.section-collapsible .section-header:hover .section-title { color: var(--text-secondary); }
|
||||
.section-title-wrap {
|
||||
display: flex; align-items: center; gap: 6px; min-width: 0;
|
||||
}
|
||||
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); transition: var(--transition); }
|
||||
.section-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform .2s var(--ease, ease); }
|
||||
.section-collapsible { display: flex; flex-direction: column; min-height: 0; }
|
||||
.section-collapsible:not(.collapsed) { flex: 1 1 auto; }
|
||||
.section-collapsible:not(.collapsed) .section-chevron { transform: rotate(0deg); }
|
||||
.section-collapsible.collapsed .section-chevron { transform: rotate(-90deg); }
|
||||
.section-collapsible.collapsed .section-header { margin-bottom: 0; }
|
||||
.section-collapsible.collapsed .section-title { color: var(--text-muted); }
|
||||
|
||||
.pack-list-collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows .3s var(--ease, ease);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-collapsible:not(.collapsed) .pack-list-collapse {
|
||||
grid-template-rows: 1fr;
|
||||
flex: 1;
|
||||
}
|
||||
.pack-list-collapse > .pack-list {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pack-list {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
overflow-y: auto; max-height: calc((100vh - 460px) / 2);
|
||||
min-height: 40px;
|
||||
}
|
||||
.pack-list:empty::after {
|
||||
content: 'No packs'; display: block; padding: 12px 8px;
|
||||
@@ -294,6 +336,9 @@ body {
|
||||
|
||||
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.view.active { display: flex; }
|
||||
#view-packs { overflow: hidden; }
|
||||
#view-packs .pack-detail { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
#view-packs .play-bar { position: sticky; bottom: 0; z-index: 5; flex-shrink: 0; margin-top: 12px; }
|
||||
|
||||
.view-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
@@ -642,6 +687,15 @@ body {
|
||||
background: linear-gradient(90deg, var(--accent), #ff6b6b);
|
||||
border-radius: 3px; transition: width 0.3s ease;
|
||||
}
|
||||
.progress-fill.indeterminate {
|
||||
width: 30%;
|
||||
transition: none;
|
||||
animation: indeterminate 1.3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes indeterminate {
|
||||
0% { margin-left: -30%; }
|
||||
100% { margin-left: 100%; }
|
||||
}
|
||||
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
|
||||
.progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; }
|
||||
|
||||
@@ -900,3 +954,82 @@ body {
|
||||
.pack-entry.disabled .pack-entry-name::after { content: ' (' attr(data-disabled-text) ')'; color: var(--error); font-size: 11px; }
|
||||
.admin-pack-disabled { color: var(--error) !important; font-weight: 600; }
|
||||
.admin-pack-enabled { color: var(--success) !important; font-weight: 600; }
|
||||
|
||||
/* ========== OFFLINE MODE ========== */
|
||||
.offline-container {
|
||||
position: relative; z-index: 1;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 32px 32px 24px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
|
||||
text-align: center;
|
||||
}
|
||||
.offline-badge {
|
||||
width: 64px; height: 64px;
|
||||
margin: 0 auto 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(233,69,96,0.12);
|
||||
border: 1px solid rgba(233,69,96,0.3);
|
||||
border-radius: 14px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.offline-title { font-size: 22px; font-weight: 700; color: var(--text); }
|
||||
.offline-subtitle {
|
||||
color: var(--text-muted); font-size: 13px; margin: 8px 0 20px; line-height: 1.5;
|
||||
}
|
||||
.offline-container .login-form { text-align: left; }
|
||||
.offline-footer {
|
||||
display: flex; gap: 8px; justify-content: center; margin-top: 18px;
|
||||
}
|
||||
.btn-ghost {
|
||||
padding: 9px 14px; font-size: 13px;
|
||||
background: transparent; color: var(--text-secondary);
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
cursor: pointer; font-family: var(--font);
|
||||
transition: border-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* ========== LOCAL MODS ========== */
|
||||
.mods-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
.mods-section .section-header { display:flex; align-items:center; gap:10px; flex-wrap:wrap; font-size:11px; font-weight:600; text-transform:uppercase; color:var(--text-muted); padding:4px; margin-bottom:8px; letter-spacing:0.5px; }
|
||||
.mods-count { font-size:11px; color:var(--text-secondary); margin-left:auto; }
|
||||
.mods-search { margin-left:auto; min-width:160px; padding:6px 10px; border-radius:var(--radius-sm); background:var(--bg-surface); border:1px solid var(--border-light); color:var(--text); font-size:12px; outline:none; }
|
||||
.mods-search:focus { border-color:var(--accent); }
|
||||
.mods-list { display:flex; flex-direction:column; gap:4px; }
|
||||
.mods-loading { text-align:center; padding:20px; color:var(--text-muted); font-size:12px; }
|
||||
.mod-item { display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:var(--radius-sm); background:var(--bg-card); border:1px solid var(--border); transition:var(--transition); }
|
||||
.mod-item:hover { background:var(--bg-card-hover); }
|
||||
.mod-item.mod-disabled { opacity:0.6; }
|
||||
.mod-icon { width:32px; height:32px; border-radius:6px; display:flex; align-items:center; justify-content:center; background:var(--bg-inset); color:var(--text-muted); flex-shrink:0; }
|
||||
.mod-info { flex:1; min-width:0; }
|
||||
.mod-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.mod-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.mod-badge { font-size:10px; font-weight:700; padding:2px 6px; border-radius:4px; flex-shrink:0; }
|
||||
.mod-badge-enabled { background:rgba(74,222,128,0.15); color:var(--success); }
|
||||
.mod-badge-disabled { background:rgba(248,113,113,0.12); color:var(--error); }
|
||||
|
||||
/* ========== CONTEXT MENU (PKM) ========== */
|
||||
.ctx-menu { position:fixed; z-index:300; min-width:200px; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-sm); box-shadow:0 12px 32px rgba(0,0,0,0.5); padding:4px; display:flex; flex-direction:column; gap:2px; animation:ctxIn 0.12s ease; }
|
||||
@keyframes ctxIn { from { opacity:0; transform:translateY(4px) scale(0.98); } to { opacity:1; transform:none; } }
|
||||
.ctx-item { display:flex; align-items:center; gap:10px; padding:8px 12px; border-radius:4px; font-size:13px; color:var(--text-secondary); cursor:pointer; transition:var(--transition); border:none; background:transparent; font-family:var(--font); text-align:left; width:100%; }
|
||||
.ctx-item:hover { background:var(--bg-card); color:var(--text); }
|
||||
.ctx-item.danger { color:var(--error); }
|
||||
.ctx-item.danger:hover { background:rgba(248,113,113,0.08); }
|
||||
.ctx-sep { height:1px; background:var(--border); margin:2px 0; }
|
||||
.ctx-backdrop { position:fixed; inset:0; z-index:299; }
|
||||
|
||||
/* ========== ONBOARDING SPOTLIGHT ========== */
|
||||
.onboard-overlay { position:fixed; inset:0; z-index:400; background:rgba(7,7,10,0.72); backdrop-filter:blur(2px); animation:fadeIn 0.2s ease; }
|
||||
.onboard-card { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); z-index:405; max-width:420px; width:90%; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-lg); padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.6); animation:floatIn 0.25s ease; }
|
||||
.onboard-card h3 { font-size:18px; font-weight:700; margin-bottom:8px; }
|
||||
.onboard-card p { font-size:13px; color:var(--text-secondary); line-height:1.6; margin-bottom:16px; }
|
||||
.onboard-progress { font-size:11px; color:var(--text-muted); letter-spacing:0.5px; text-transform:uppercase; margin-bottom:12px; }
|
||||
.onboard-actions { display:flex; gap:8px; justify-content:flex-end; }
|
||||
.onboard-spot { position:fixed; z-index:401; border:2px solid var(--accent); border-radius:var(--radius-sm); box-shadow:0 0 0 9999px rgba(7,7,10,0.55), 0 0 24px var(--accent-glow); pointer-events:auto; cursor:pointer; transition:all 0.35s ease; }
|
||||
.onboard-spot-label { position:fixed; z-index:402; background:var(--accent); color:#fff; font-size:12px; font-weight:600; padding:6px 10px; border-radius:999px; pointer-events:auto; cursor:pointer; transform:translate(-50%, -100%); margin-top:-8px; white-space:nowrap; }
|
||||
|
||||
/* ========== PACK SETTINGS MODAL ========== */
|
||||
.pack-settings-form { display:flex; flex-direction:column; gap:16px; }
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
|
||||
<modules>
|
||||
<module>bootstrap</module>
|
||||
<module>diag</module>
|
||||
<module>launcher</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.16</revision>
|
||||
<hotfix>2</hotfix>
|
||||
<revision>1.1.1</revision>
|
||||
<hotfix>5</hotfix>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
@@ -95,6 +96,11 @@
|
||||
<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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -12,7 +12,8 @@ import json
|
||||
import structlog
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
|
||||
# Disable httpx debug logging
|
||||
@@ -42,14 +43,26 @@ logger = structlog.get_logger(__name__)
|
||||
# Cache for manifests - expires after 5 minutes
|
||||
manifest_cache = TTLCache(maxsize=100, ttl=300)
|
||||
|
||||
BUILDS_DIR = Path("builds")
|
||||
BUILDS_DIR = Path(__file__).parent / "builds"
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
||||
|
||||
# Website (landing) static files
|
||||
SITE_DIR = Path(__file__).parent / "site"
|
||||
|
||||
# SkinRestorer skins (shared with TG bot)
|
||||
SKINS_DIR = Path("/root/python/site/skins")
|
||||
|
||||
# Minecraft server status (same host the TG bot pings)
|
||||
MC_STATUS_HOST = os.environ.get("MC_STATUS_HOST", "mc.zernmc.ru")
|
||||
|
||||
# Mirror configuration
|
||||
LAUNCHER_MIRRORS = {
|
||||
"main": "https://api.zernmc.ru",
|
||||
"mirror-1": "https://api.zernmc.online",
|
||||
"main": "https://api.zern.cc", # primary
|
||||
"mirror-1": "https://api.zernmc.ru", # legacy
|
||||
"mirror-2": "https://api.zernmc.online", # legacy
|
||||
"geo-pl": "https://api.pl.zern.cc",
|
||||
"geo-swe": "https://api.swe.zern.cc",
|
||||
}
|
||||
|
||||
# Server role: "main" or "mirror"
|
||||
@@ -71,6 +84,9 @@ BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
|
||||
# Crash reports directory
|
||||
CRASH_REPORTS_DIR = Path("data/crash_reports")
|
||||
|
||||
# Network diagnostics reports directory
|
||||
DIAG_REPORTS_DIR = Path("data/diag_reports")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -832,7 +848,7 @@ class CacheControlMiddleware:
|
||||
path = scope.get("path", "")
|
||||
|
||||
# Skip caching for dynamic endpoints
|
||||
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"])
|
||||
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install", "/skin/"])
|
||||
if skip_cache:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
@@ -947,6 +963,17 @@ app.include_router(admin_router)
|
||||
app.include_router(friends_router)
|
||||
app.include_router(playtime_router)
|
||||
|
||||
# Static assets for the landing site
|
||||
for sub in ("css", "js", "img"):
|
||||
mount_dir = SITE_DIR / sub
|
||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount(f"/{sub}", StaticFiles(directory=mount_dir), name=f"site-{sub}")
|
||||
|
||||
# Server highlights media (screenshots / clips for the landing carousel)
|
||||
HIGHLIGHTS_DIR = SITE_DIR / "highlights"
|
||||
HIGHLIGHTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/highlights", StaticFiles(directory=HIGHLIGHTS_DIR), name="site-highlights")
|
||||
|
||||
|
||||
# Monkey patch to catch invalid HTTP requests
|
||||
original_data_received = HttpToolsProtocol.data_received
|
||||
@@ -984,8 +1011,10 @@ HttpToolsProtocol.data_received = patched_data_received
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
logger.info("Root endpoint accessed")
|
||||
"""Root endpoint - serves the launcher landing site"""
|
||||
index_path = SITE_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
return HTMLResponse(index_path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "ZernMC Launcher Server is running",
|
||||
@@ -994,6 +1023,83 @@ async def root():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/skin/{filename}")
|
||||
@app.head("/skin/{filename}")
|
||||
async def get_skin(filename: str, request: Request):
|
||||
"""Serve SkinRestorer skins (compatibility with TG bot direct links)"""
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(status_code=404, detail="Скин не найден")
|
||||
|
||||
filepath = SKINS_DIR / filename
|
||||
|
||||
if not filepath.is_file():
|
||||
raise HTTPException(status_code=404, detail="Скин не найден")
|
||||
|
||||
try:
|
||||
content = filepath.read_bytes()
|
||||
size = len(content)
|
||||
|
||||
headers = {
|
||||
"Content-Length": str(size),
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Content-Encoding": "identity",
|
||||
}
|
||||
|
||||
if request.method == "HEAD":
|
||||
return Response(status_code=200, headers=headers)
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="image/png",
|
||||
headers=headers
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Skin read error", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Ошибка чтения скина")
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_server_status():
|
||||
"""Live Minecraft server status via mcstatus ping (same host as TG bot)"""
|
||||
try:
|
||||
from mcstatus import JavaServer
|
||||
server = JavaServer.lookup(f"{MC_STATUS_HOST}")
|
||||
status = await server.async_status()
|
||||
|
||||
players_list = []
|
||||
try:
|
||||
query = await server.async_query()
|
||||
if query.players.names:
|
||||
players_list = query.players.names
|
||||
elif query.players.online > 0 and query.players.sample:
|
||||
players_list = [p.name for p in query.players.sample]
|
||||
except Exception:
|
||||
if status.players.sample:
|
||||
players_list = [p.name for p in status.players.sample]
|
||||
|
||||
version = getattr(status.version, "name", None) or "unknown"
|
||||
motd = status.description
|
||||
|
||||
return {
|
||||
"online": True,
|
||||
"players_online": status.players.online,
|
||||
"players_max": status.players.max,
|
||||
"players_list": ", ".join(players_list) if players_list else "никого нет",
|
||||
"players": players_list,
|
||||
"motd": motd,
|
||||
"version": version if isinstance(version, str) else str(version),
|
||||
"updated": datetime.now().strftime("%H:%M:%S")
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
return {"online": False, "error": "Таймаут подключения к серверу", "updated": datetime.now().strftime("%H:%M:%S")}
|
||||
except Exception as e:
|
||||
return {"online": False, "error": f"{type(e).__name__}: {e}", "updated": datetime.now().strftime("%H:%M:%S")}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint"""
|
||||
@@ -1204,6 +1310,32 @@ async def get_pack_manifest(pack_name: str, request: Request, current_user: dict
|
||||
)
|
||||
|
||||
|
||||
@app.get("/pack/{pack_name}/zip")
|
||||
async def download_pack_zip(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Download whole pack as zip for manual installation (requires pass)"""
|
||||
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
|
||||
raise HTTPException(403, "Requires active pass")
|
||||
if is_pack_disabled(pack_name):
|
||||
raise HTTPException(403, "Pack is disabled")
|
||||
pack_path = PACKS_DIR / pack_name
|
||||
if not pack_path.exists() or not pack_path.is_dir():
|
||||
raise HTTPException(404, "Pack not found")
|
||||
import tempfile, zipfile
|
||||
tmp = Path(tempfile.gettempdir()) / f"{pack_name}.zip"
|
||||
# re-use if fresh (<5 min)
|
||||
if tmp.exists() and (datetime.utcnow().timestamp() - tmp.stat().st_mtime) < 300:
|
||||
return await send_file_async(tmp, request, content_type="application/zip", cache=False)
|
||||
# create zip
|
||||
def _zip():
|
||||
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in pack_path.rglob("*"):
|
||||
if f.is_file():
|
||||
zf.write(f, f.relative_to(pack_path))
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, _zip)
|
||||
return await send_file_async(tmp, request, content_type="application/zip", cache=False)
|
||||
|
||||
|
||||
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||
async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user), preset: Optional[str] = None):
|
||||
"""Get a file from a pack. If preset is specified, try preset dir first, fall back to base."""
|
||||
@@ -1350,6 +1482,14 @@ def generate_launcher_builds_meta():
|
||||
# CLI exe is not shipped to users
|
||||
if rel_path == "zernmc-cli.exe":
|
||||
continue
|
||||
# zernmc.exe embeds the launcher version, so its hash changes on
|
||||
# every build even when the bootstrap code is unchanged. Exclude it
|
||||
# from the incremental update: a new exe is only delivered via a
|
||||
# full ZIP reinstall. Without this the bootstrap would re-download
|
||||
# and re-stage its own exe on every launcher version bump, and the
|
||||
# detached .cmd helper fails with "file not found".
|
||||
if rel_path == "zernmc.exe":
|
||||
continue
|
||||
stat = file_path.stat()
|
||||
|
||||
# Calculate hash
|
||||
@@ -1391,6 +1531,8 @@ def generate_version_meta(version_path: Path, version: str) -> dict:
|
||||
for file_path in version_path.rglob("*"):
|
||||
if file_path.is_file() and file_path.name != "meta.json":
|
||||
rel_path = str(file_path.relative_to(version_path))
|
||||
if rel_path in ("zernmc.exe", "zernmc-cli.exe"):
|
||||
continue
|
||||
stat = file_path.stat()
|
||||
file_hash = calculate_file_hash(file_path)
|
||||
files.append({
|
||||
@@ -1488,13 +1630,18 @@ def extract_new_format_versions():
|
||||
# Find all ZernMC-win-*.zip files
|
||||
new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip"))
|
||||
|
||||
total = len(new_format_zips)
|
||||
extracted = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for zip_file in new_format_zips:
|
||||
version = zip_file.stem.replace("ZernMC-win-", "")
|
||||
extract_dir = VERSIONS_DIR / version
|
||||
|
||||
# Skip if already extracted and meta exists
|
||||
if extract_dir.exists() and (extract_dir / "meta.json").exists():
|
||||
logger.debug(f"Version {version} already extracted")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
|
||||
@@ -1511,9 +1658,13 @@ def extract_new_format_versions():
|
||||
# writes versions/<v>/meta.json, so every scan would re-extract the zip.
|
||||
generate_version_meta(extract_dir, version)
|
||||
|
||||
extracted += 1
|
||||
logger.info(f"Extracted {zip_file.name} successfully")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.error(f"Failed to extract {zip_file.name}: {e}")
|
||||
|
||||
logger.info(f"Version scan complete: {total} total, {extracted} extracted, {skipped} skipped (already present), {failed} failed")
|
||||
|
||||
|
||||
# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ======================
|
||||
@@ -1594,6 +1745,14 @@ def get_legacy_zips() -> list:
|
||||
return zips
|
||||
|
||||
|
||||
@app.get("/launcher/ip")
|
||||
async def get_launcher_client_ip(request: Request):
|
||||
"""Return the public IP that the server sees for this client."""
|
||||
from middleware import get_client_ip
|
||||
client_ip = get_client_ip(request)
|
||||
return {"ip": client_ip}
|
||||
|
||||
|
||||
@app.get("/launcher/version")
|
||||
async def get_launcher_version():
|
||||
"""Return launcher version information"""
|
||||
@@ -1665,6 +1824,64 @@ async def download_launcher_exe(request: Request = None):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/launcher/download/jre")
|
||||
async def download_jre(request: Request):
|
||||
"""Download JRE zip for online installer (47M, Range supported)"""
|
||||
candidates = [
|
||||
BUILDS_DIR / ".." / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip", # /root/launcher/OpenJDK...
|
||||
Path(__file__).parent.parent / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip",
|
||||
Path("/root/launcher/OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"),
|
||||
BUILDS_DIR / "jre.zip",
|
||||
]
|
||||
for p in candidates:
|
||||
pp = p.resolve()
|
||||
if pp.exists() and pp.is_file():
|
||||
return await send_file_async(pp, request, content_type="application/zip", cache=True)
|
||||
raise HTTPException(404, "JRE zip not found on server")
|
||||
|
||||
def _find_latest_setup(pattern: str) -> Path | None:
|
||||
"""Find latest Go setup exe by pattern (Online/Offline)"""
|
||||
cands = list(BUILDS_DIR.glob(pattern))
|
||||
if not cands:
|
||||
return None
|
||||
# sort by version key from filename
|
||||
def ver_key(p: Path):
|
||||
# ZernMC-Online-Setup-1.1.1.2.exe -> 1.1.1.2
|
||||
name = p.stem
|
||||
ver = name.split("-")[-1]
|
||||
try:
|
||||
return tuple(int(x) for x in ver.split("."))
|
||||
except:
|
||||
return (0,)
|
||||
cands.sort(key=ver_key, reverse=True)
|
||||
return cands[0]
|
||||
|
||||
@app.get("/launcher/download/online")
|
||||
async def download_online_setup(request: Request):
|
||||
"""Download online Go installer (5.8M, STANDALONE EXE without bundled JRE)"""
|
||||
p = _find_latest_setup("ZernMC-Online-Setup-*.exe")
|
||||
if p and p.exists():
|
||||
resp = await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
|
||||
resp.headers["Content-Disposition"] = f'attachment; filename="{p.name}"'
|
||||
return resp
|
||||
raise HTTPException(404, "Online installer not found")
|
||||
|
||||
@app.get("/launcher/download/offline-setup")
|
||||
async def download_offline_setup(request: Request):
|
||||
"""Download offline Go installer (STANDALONE EXE with embedded build, ~100M)"""
|
||||
p = _find_latest_setup("ZernMC-Offline-Setup-*.exe")
|
||||
if p and p.exists():
|
||||
resp = await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
|
||||
resp.headers["Content-Disposition"] = f'attachment; filename="{p.name}"'
|
||||
return resp
|
||||
# fallback: try legacy offline zip embed name without -Setup
|
||||
p2 = _find_latest_setup("ZernMC-Offline-*.exe")
|
||||
if p2 and p2.exists():
|
||||
resp = await send_file_async(p2, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
|
||||
resp.headers["Content-Disposition"] = f'attachment; filename="{p2.name}"'
|
||||
return resp
|
||||
raise HTTPException(404, "Offline installer not found")
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_launcher_zip(filename: str, request: Request = None):
|
||||
"""Download specific launcher ZIP archive"""
|
||||
@@ -1965,6 +2182,36 @@ async def receive_crash_report(request: Request):
|
||||
return {"status": "ok", "id": report_id}
|
||||
|
||||
|
||||
@app.post("/diag/upload")
|
||||
async def receive_diag_report(request: Request):
|
||||
"""Receive and store network diagnostics reports from the diag utility"""
|
||||
try:
|
||||
body = await request.body()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Failed to read body")
|
||||
|
||||
if not body:
|
||||
raise HTTPException(status_code=400, detail="Empty body")
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
name = request.query_params.get("name", "").strip()
|
||||
if not name or "/" in name or "\\" in name or ".." in name:
|
||||
name = f"diag_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{ip}.log"
|
||||
|
||||
report_path = DIAG_REPORTS_DIR / name
|
||||
|
||||
try:
|
||||
DIAG_REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
async with aiofiles.open(report_path, "w", encoding="utf-8") as f:
|
||||
await f.write(body.decode("utf-8", errors="replace"))
|
||||
logger.info(f"Diag report saved: {report_path.name} from {ip}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save diag report: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to save report")
|
||||
|
||||
return {"status": "ok", "name": report_path.name}
|
||||
|
||||
|
||||
# ====================== НОВОСТИ ======================
|
||||
|
||||
NEWS_DIR = Path(__file__).parent / "news"
|
||||
@@ -2195,19 +2442,34 @@ async def proxy_mojang_version(version_id: str, request: Request):
|
||||
# Сначала получаем манифест, чтобы найти URL версии
|
||||
manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
||||
|
||||
cache_key = f"version_url_{version_id}"
|
||||
version_url = proxy_cache.get(cache_key)
|
||||
|
||||
# Cache the final version JSON, not just the version URL. piston-meta is
|
||||
# slow/unreliable from some regions, so full serialization of the version
|
||||
# JSON (plus a warm-up) keeps installs from stalling on install time.
|
||||
cache_key = f"version_json_{version_id}"
|
||||
cached = proxy_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
logger.info(f"Proxy served from cache: Mojang version {version_id}")
|
||||
return JSONResponse(content=cached)
|
||||
|
||||
version_url = proxy_cache.get(f"version_url_{version_id}")
|
||||
|
||||
if not version_url:
|
||||
try:
|
||||
response = await proxy_client.get(manifest_url)
|
||||
response.raise_for_status()
|
||||
manifest = response.json()
|
||||
# Reuse the manifest already cached by /proxy/mojang/version_manifest
|
||||
manifest = proxy_cache.get(manifest_url)
|
||||
if manifest is None:
|
||||
response = await proxy_client.get(manifest_url)
|
||||
response.raise_for_status()
|
||||
manifest = response.json()
|
||||
proxy_cache[manifest_url] = manifest
|
||||
logger.info("Proxy success: Mojang manifest")
|
||||
else:
|
||||
logger.info("Proxy served manifest from cache")
|
||||
|
||||
for version in manifest.get("versions", []):
|
||||
if version.get("id") == version_id:
|
||||
version_url = version.get("url")
|
||||
proxy_cache[cache_key] = version_url
|
||||
proxy_cache[f"version_url_{version_id}"] = version_url
|
||||
break
|
||||
|
||||
if not version_url:
|
||||
@@ -2222,6 +2484,7 @@ async def proxy_mojang_version(version_id: str, request: Request):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
proxy_cache[cache_key] = data
|
||||
logger.info(f"Proxy success: Mojang version {version_id}")
|
||||
return JSONResponse(content=data)
|
||||
|
||||
|
||||
@@ -159,6 +159,9 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
# 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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 107 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 |
@@ -0,0 +1,582 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zern — Awaken from the cold</title>
|
||||
<meta name="description" content="Zern — a custom Minecraft launcher. Forge, NeoForge, Fabric. Download and play on ZernMC.">
|
||||
<link rel="icon" type="image/svg+xml" href="/img/zernmc.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="nav" id="nav">
|
||||
<div class="nav-inner">
|
||||
<a class="nav-brand" href="#top">
|
||||
<img src="/img/zernmc.svg" alt="Zern" width="34" height="34">
|
||||
<span>Zern</span>
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#server" data-i18n="nav.server">Сервер</a></li>
|
||||
<li><a href="#highlights" data-i18n="nav.highlights">Хайлайты</a></li>
|
||||
<li><a href="#theme" data-i18n="nav.season">Сезон</a></li>
|
||||
<li><a href="#features" data-i18n="nav.features">Возможности</a></li>
|
||||
<li><a href="#showcase" data-i18n="nav.showcase">Лаунчер</a></li>
|
||||
<li><a href="#download" data-i18n="nav.download">Скачать</a></li>
|
||||
<li><a href="#news" data-i18n="nav.news">Новости</a></li>
|
||||
</ul>
|
||||
<div class="nav-right">
|
||||
<button class="lang-toggle" id="langToggle" type="button" aria-label="Switch language">
|
||||
<span class="lang-toggle-label" id="langToggleLabel">EN</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost" id="auth-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.login">Войти</button>
|
||||
<div id="user-menu" class="hidden" style="display:flex;align-items:center;gap:8px">
|
||||
<span id="user-name" style="font-weight:600;font-size:13px;color:var(--text)"></span>
|
||||
<button class="btn btn-ghost" id="cabinet-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.cabinet">Кабинет</button>
|
||||
<button class="btn btn-ghost" id="logout-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.logout">Выйти</button>
|
||||
</div>
|
||||
<a class="nav-cta" href="#download" data-i18n="nav.play">Играть сейчас</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="hero" id="top">
|
||||
<div class="hero-grid" aria-hidden="true"></div>
|
||||
<div class="hero-glow" aria-hidden="true"></div>
|
||||
|
||||
<div class="hero-diamond hero-diamond--1" aria-hidden="true"></div>
|
||||
<div class="hero-diamond hero-diamond--2" aria-hidden="true"></div>
|
||||
<div class="hero-diamond hero-diamond--3" aria-hidden="true"></div>
|
||||
|
||||
<div class="hero-inner" data-parallax="0.15">
|
||||
<p class="hero-kicker" data-i18n="hero.kicker">КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР</p>
|
||||
<h1 class="hero-title" data-i18n-html="hero.title">Очнись от<br><span class="hero-title-accent">холода.</span></h1>
|
||||
<p class="hero-sub" data-i18n="hero.sub">Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" href="#download">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
|
||||
<span data-i18n="hero.download">Скачать лаунчер</span>
|
||||
</a>
|
||||
<a class="btn btn-ghost" href="#features" data-i18n="hero.explore">Обзор</a>
|
||||
</div>
|
||||
<p class="hero-meta">v<span id="hero-version">—</span> · <span id="hero-size">—</span> · Windows</p>
|
||||
</div>
|
||||
|
||||
<div class="scroll-hint" data-parallax="0.3">
|
||||
<span data-i18n="hero.scroll">Листай вниз</span>
|
||||
<div class="scroll-hint-line"><i></i></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<section class="section server" id="server">
|
||||
<div class="section-head">
|
||||
<span class="section-index">01 / 07</span>
|
||||
<h2 class="section-title" data-i18n-html="server.title">Сервер <span class="accent">жив.</span></h2>
|
||||
<p class="section-sub" data-i18n="server.sub">ZernMC — это кастомный Minecraft-сервер со своим лором, самописными плагинами и живым комьюнити. Вот что происходит прямо сейчас.</p>
|
||||
</div>
|
||||
|
||||
<div class="server-stats">
|
||||
<div class="server-card">
|
||||
<div class="server-card-label" data-i18n="server.status">Статус</div>
|
||||
<div class="server-card-value" id="srv-status"><span class="status-dot status-dot--loading"></span> <span data-i18n="server.checking">Проверяем…</span></div>
|
||||
<div class="server-card-sub" id="srv-version">1.20.1 / ОБТ</div>
|
||||
</div>
|
||||
<div class="server-card">
|
||||
<div class="server-card-label" data-i18n="server.players">Игроков онлайн</div>
|
||||
<div class="server-card-value" id="srv-players">— / —</div>
|
||||
<div class="server-card-sub">mc.zernmc.ru</div>
|
||||
</div>
|
||||
<div class="server-card">
|
||||
<div class="server-card-label" data-i18n="server.mode">Режим</div>
|
||||
<div class="server-card-value">Survival <span class="server-season">· <span data-i18n="server.season">Сезон Prologue</span></span></div>
|
||||
<div class="server-card-sub">1.21.1 NeoForge · Create: Aeronautics</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section highlights" id="highlights">
|
||||
<div class="section-head">
|
||||
<span class="section-index">02 / 07</span>
|
||||
<h2 class="section-title" data-i18n-html="highlights.title">Хайлайты <span class="accent">сервера.</span></h2>
|
||||
<p class="section-sub" data-i18n="highlights.sub">Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.</p>
|
||||
</div>
|
||||
|
||||
<div class="carousel">
|
||||
<div class="carousel-track" id="carousel"></div>
|
||||
<button class="carousel-btn carousel-btn--prev" id="carouselPrev" data-i18n-aria="carousel.prev">‹</button>
|
||||
<button class="carousel-btn carousel-btn--next" id="carouselNext" data-i18n-aria="carousel.next">›</button>
|
||||
<div class="carousel-dots" id="carouselDots"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section features" id="features">
|
||||
<div class="section-head">
|
||||
<span class="section-index">03 / 07</span>
|
||||
<h2 class="section-title" data-i18n-html="features.title">Всё, что <span class="accent">тебе нужно.</span></h2>
|
||||
<p class="section-sub" data-i18n="features.sub">Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<article class="feature-card">
|
||||
<span class="feature-num">01</span>
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
</div>
|
||||
<h3 data-i18n="features.forge.title">Forge, NeoForge, Fabric</h3>
|
||||
<p data-i18n="features.forge.sub">Все основные модлоадеры подхватываются автоматически. Библиотеки предзагружаются и проверяются, так что установка работает — даже на медленном интернете.</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<span class="feature-num">02</span>
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
|
||||
</div>
|
||||
<h3 data-i18n="features.ui.title">Тёмный, быстрый нативный UI</h3>
|
||||
<p data-i18n="features.ui.sub">Лёгкий интерфейс на JavaFX с чистым тёмным оформлением. Быстро запускается, мгновенно реагирует и не мешает.</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<span class="feature-num">03</span>
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 18H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M9 22v-4"/><path d="M15 22v-4"/></svg>
|
||||
</div>
|
||||
<h3 data-i18n="features.proxy.title">Умные зеркала и прокси</h3>
|
||||
<p data-i18n="features.proxy.sub">Загрузки идут через быстрые зеркала с автоматическим фолбэком. Заблокировали или зарезали скорость? Прокси подхватит — ты больше не зависнешь на 80%.</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<span class="feature-num">04</span>
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 0 0-9 14l2 5 5-2A10 10 0 1 0 12 2z"/><path d="M8.5 12h.01"/><path d="M12 12h.01"/><path d="M15.5 12h.01"/></svg>
|
||||
</div>
|
||||
<h3 data-i18n="features.news.title">Новости и статус сервера</h3>
|
||||
<p data-i18n="features.news.sub">Чейнджлоги, ивенты и обновления приходят прямо в лаунчер. Ты всегда знаешь, что нового, ещё до нажатия «Играть».</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<span class="feature-num">05</span>
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</div>
|
||||
<h3 data-i18n="features.pack.title">Установка сборки в один клик</h3>
|
||||
<p data-i18n="features.pack.sub">Выбери сборку и жми «Установить». Манифесты версий, ванильные ассеты и библиотеки подтягиваются и проверяются автоматически. Просто.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section showcase" id="showcase">
|
||||
<div class="section-head section-head--center">
|
||||
<span class="section-index">04 / 07</span>
|
||||
<h2 class="section-title" data-i18n-html="showcase.title">Показываем <span class="accent">лаунчер</span></h2>
|
||||
<p class="section-sub" data-i18n="showcase.sub">Никаких статичных скриншотов — живой тур по интерфейсу. Нужно просто наблюдать: лаунчер сам открывает свои экраны.</p>
|
||||
</div>
|
||||
|
||||
<div class="launcher-preview" id="launcherPreview" role="img" aria-label="ZernMC Launcher preview, auto-rotating screens" data-i18n-aria="preview.aria">
|
||||
<div class="launcher-window">
|
||||
<div class="launcher-titlebar">
|
||||
<div class="win-dots" aria-hidden="true"><span></span><span></span><span></span></div>
|
||||
<span class="launcher-titlebar-name">
|
||||
<svg class="win-icon" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
|
||||
ZernMC Launcher <span id="preview-version">—</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="launcher-preview-body" id="launcherPreviewBody">
|
||||
|
||||
<div class="preview-scene scene-login is-active" data-key="login">
|
||||
<div class="scene-login-inner">
|
||||
<div class="pv-brand">
|
||||
<svg width="40" height="40" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
|
||||
</div>
|
||||
<h3 class="pv-title" data-i18n="preview.signIn">Вход</h3>
|
||||
<div class="pv-field"><label data-i18n="preview.username">Логин</label><div class="pv-input"></div></div>
|
||||
<div class="pv-field"><label data-i18n="preview.password">Пароль</label><div class="pv-input"></div></div>
|
||||
<div class="pv-btn" data-i18n="preview.signIn">Вход</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-scene scene-packs" data-key="packs">
|
||||
<div class="pv-sidebar">
|
||||
<div class="pv-sb-brand">
|
||||
<svg width="24" height="24" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
|
||||
<span>ZernMC<em>v<span class="pv-sb-ver">2.3.1</span></em></span>
|
||||
</div>
|
||||
<div class="pv-sb-nav">
|
||||
<span class="pv-nav-item is-active" title="Packs">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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="preview.nav.packs">Сборки</span>
|
||||
</span>
|
||||
<span class="pv-nav-item" title="News">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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="preview.nav.news">Новости</span>
|
||||
</span>
|
||||
<span class="pv-nav-item" title="Friends">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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="preview.nav.friends">Друзья</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pv-sb-section">
|
||||
<span class="pv-sb-section-title" data-i18n="preview.serverPacks">Серверные сборки</span>
|
||||
<div class="pv-pack-item">
|
||||
<span class="pv-pack-name">Zern-OBT</span>
|
||||
<span class="pv-pack-meta">1.20.1 · forge</span>
|
||||
</div>
|
||||
<div class="pv-pack-item is-active">
|
||||
<span class="pv-pack-name">Zern-Prologue</span>
|
||||
<span class="pv-pack-meta">1.21.1 · neoforge</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pv-sb-section">
|
||||
<span class="pv-sb-section-title" data-i18n="preview.localPacks">Локальные сборки</span>
|
||||
<div class="pv-pack-item">
|
||||
<span class="pv-pack-name">Vanilla</span>
|
||||
<span class="pv-pack-meta">1.20.1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pv-sb-user">
|
||||
<span class="pv-avatar">Z</span>
|
||||
<span class="pv-user-meta"><em>zernplayer</em><i class="pv-badge">FREE</i></span>
|
||||
<svg class="pv-sb-action" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
||||
<svg class="pv-sb-action" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pv-main">
|
||||
<div class="pv-main-head">
|
||||
<div>
|
||||
<h3>Zern-Prologue</h3>
|
||||
<div class="pv-tags"><span class="pv-tag pv-tag-mc">1.21.1</span><span class="pv-tag">neoforge</span></div>
|
||||
</div>
|
||||
<div class="pv-stats">
|
||||
<span><b>47.4</b><i data-i18n="preview.stat.loader">Загрузчик</i></span>
|
||||
<span><b>130</b><i data-i18n="preview.stat.files">Файлы</i></span>
|
||||
<span><b>1.8 ГБ</b><i data-i18n="preview.stat.size">Размер</i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pv-gallery" aria-hidden="true">
|
||||
<span class="pv-gallery-item pv-gallery-item--1"></span>
|
||||
<span class="pv-gallery-item pv-gallery-item--2"></span>
|
||||
<span class="pv-gallery-item pv-gallery-item--3"></span>
|
||||
</div>
|
||||
<div class="pv-playbar">
|
||||
<span>Zern-Prologue · <em data-i18n="preview.ready">Готов к запуску</em></span>
|
||||
<span class="pv-play">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
<i data-i18n="preview.play">Играть</i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-scene scene-news" data-key="news">
|
||||
<div class="pv-panel">
|
||||
<h3 class="pv-view-title" data-i18n="preview.nav.news">Новости</h3>
|
||||
<div class="pv-news-item">
|
||||
<span class="pv-news-tag pv-tag-update" data-i18n="preview.news.update">Обновление</span>
|
||||
<b data-i18n="preview.news1.title">Сезон Prologue запущен</b>
|
||||
<p data-i18n="preview.news1.body">Небо принадлежит дирижаблям. Добро пожаловать в Prologue — 1.21.1 NeoForge, Create: Aeronautics.</p>
|
||||
</div>
|
||||
<div class="pv-news-item">
|
||||
<span class="pv-news-tag pv-tag-event" data-i18n="preview.news.event">Событие</span>
|
||||
<b data-i18n="preview.news2.title">Война в Заре</b>
|
||||
<p data-i18n="preview.news2.body">«Феникс» атакует, «Ласточка» защищает. Жители выбирают сторону — или бунт.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-scene scene-settings" data-key="settings">
|
||||
<div class="pv-panel">
|
||||
<h3 class="pv-view-title" data-i18n="preview.nav.settings">Настройки</h3>
|
||||
<div class="pv-setting-row">
|
||||
<div class="pv-setting-label" data-i18n="preview.settings.ram">Выделено ОЗУ</div>
|
||||
<div class="pv-setting-control">
|
||||
<div class="pv-range" aria-hidden="true"><span class="pv-range-fill"></span><span class="pv-range-knob"></span></div>
|
||||
<span class="pv-range-value">4 ГБ</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pv-setting-row">
|
||||
<div class="pv-setting-label" data-i18n="preview.settings.resolution">Разрешение</div>
|
||||
<div class="pv-setting-control"><span class="pv-chip">1280</span><span class="pv-times">×</span><span class="pv-chip">720</span></div>
|
||||
</div>
|
||||
<div class="pv-setting-row">
|
||||
<div class="pv-setting-label" data-i18n="preview.settings.language">Язык</div>
|
||||
<div class="pv-setting-control"><span class="pv-chip pv-chip--wide">Русский</span></div>
|
||||
</div>
|
||||
<div class="pv-setting-row">
|
||||
<div class="pv-setting-label" data-i18n="preview.settings.server">Сервер</div>
|
||||
<div class="pv-setting-control"><span class="pv-badge pv-badge-online"><i></i><span data-i18n="preview.online">Онлайн</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-scene scene-install" data-key="install">
|
||||
<div class="scene-install-inner">
|
||||
<div class="pv-brand">
|
||||
<svg width="40" height="40" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
|
||||
</div>
|
||||
<h3 class="pv-title" data-i18n="preview.install.title">Установка сборки…</h3>
|
||||
<div class="pv-progress" aria-hidden="true"><span class="pv-progress-fill"></span></div>
|
||||
<p class="pv-stage" data-i18n="preview.install.stage">Загрузка модов 130/130 · 1.8 ГБ</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-dots" id="previewDots" aria-hidden="true"></div>
|
||||
<p class="preview-caption" id="previewCaption" data-i18n="preview.login">Вход</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section theme" id="theme">
|
||||
<div class="section-head section-head--center">
|
||||
<span class="section-index">05 / 07</span>
|
||||
<h2 class="section-title" data-i18n-html="theme.title">Что внутри <span class="accent">сезона.</span></h2>
|
||||
<p class="section-sub" data-i18n="theme.sub">Prologue — модовый сезон на базе Create: Aeronautics, война и выживание. ОБТ — полигон, где всё это обкатывается.</p>
|
||||
</div>
|
||||
|
||||
<div class="theme-grid">
|
||||
<article class="theme-card">
|
||||
<span class="theme-tag" data-i18n="theme.zarya.tag">ОБТ · 1.20.1 Forge</span>
|
||||
<h3 data-i18n="theme.zarya.title">Война в Заре</h3>
|
||||
<p data-i18n="theme.zarya.sub">Двухдневный ивент в городе Заря: группировка «Феникс» атакует, «Ласточка» обороняется, а жители выбирают сторону — или поднимают бунт. Военные правила.</p>
|
||||
</article>
|
||||
<article class="theme-card">
|
||||
<span class="theme-tag" data-i18n="theme.obt.tag">ОБТ · тестовая стадия</span>
|
||||
<h3 data-i18n="theme.obt.title">Открытый бета-сервер</h3>
|
||||
<p data-i18n="theme.obt.sub">Бета — это ивентовый сервер: городские бои, сценарий атомного реактора и правила, которые лягут в основу настоящего сезона.</p>
|
||||
</article>
|
||||
<article class="theme-card">
|
||||
<span class="theme-tag" data-i18n="theme.air.tag">Сезон · 1.21.1 NeoForge</span>
|
||||
<h3 data-i18n="theme.air.title">Корабли в небе</h3>
|
||||
<p data-i18n="theme.air.sub">Create: Aeronautics даёт дирижабли, субмарины и летающие механизмы. Поезда, дизели, большие пушки и атомный реактор — осваивай.</p>
|
||||
</article>
|
||||
<article class="theme-card">
|
||||
<span class="theme-tag" data-i18n="theme.year.tag">Сезон · Prologue</span>
|
||||
<h3 data-i18n="theme.year.title">Марафон на год</h3>
|
||||
<p data-i18n="theme.year.sub">Сезон — это марафон, а не спринт. Начни эту главу с первого дня и строй её вместе с комьюнити.</p>
|
||||
</article>
|
||||
<article class="theme-card">
|
||||
<span class="theme-tag" data-i18n="theme.lore.tag">Сервер</span>
|
||||
<h3 data-i18n="theme.lore.title">Свой лор и плагины</h3>
|
||||
<p data-i18n="theme.lore.sub">Самописные плагины, ивенты под историю сервера и системы, которые из коробки работают с лаунчером.</p>
|
||||
</article>
|
||||
<article class="theme-card">
|
||||
<span class="theme-tag" data-i18n="theme.community.tag">Комьюнити</span>
|
||||
<h3 data-i18n="theme.community.title">Голос и друзья</h3>
|
||||
<p data-i18n="theme.community.sub">Simple Voice Chat в игре, система друзей в лаунчере и Telegram-комьюнити, которое реально рулит — решения принимаются голосованием игроков.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section download" id="download">
|
||||
<div class="download-inner">
|
||||
<div class="section-head section-head--center">
|
||||
<span class="section-index">06 / 07</span>
|
||||
<h2 class="section-title" data-i18n-html="download.title">Скачать <span class="accent">Zern.</span></h2>
|
||||
<p class="section-sub" data-i18n="download.sub">Скачай лаунчер для Windows и заходи на сервер ZernMC.</p>
|
||||
</div>
|
||||
|
||||
<div class="download-card">
|
||||
<div class="download-card-top">
|
||||
<img src="/img/zernmc.svg" alt="" width="64" height="64">
|
||||
<div>
|
||||
<h3 class="download-name">Zern Launcher <span id="download-version">—</span></h3>
|
||||
<p class="download-desc">Windows · <span id="download-size">—</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<a class="btn btn-primary btn-block" id="download-online-btn" href="/launcher/download/online" download>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
|
||||
<span>Online Setup (5.8М)</span>
|
||||
</a>
|
||||
<a class="btn btn-ghost btn-block" id="download-offline-btn" href="/launcher/download/offline-setup">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span>Offline ZIP (98М)</span>
|
||||
</a>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-secondary);margin-top:10px;text-align:center">Online — выбери папку (по умолчанию C:\ZernMC) + ярлык ZernMC Launcher на рабочем столе · Offline — выбери куда распаковать · Данные в %USERPROFILE%\.zernmc</p>
|
||||
<a class="btn btn-primary btn-block" id="download-btn" href="/launcher/download/latest" style="display:none">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
|
||||
<span data-i18n="download.latest">Скачать актуальную версию</span>
|
||||
</a>
|
||||
<div class="download-mirrors" id="mirrors">
|
||||
<span class="mirrors-label" data-i18n="download.mirrors">Зеркала</span>
|
||||
</div>
|
||||
|
||||
<!-- Git widget (launcher repo) -->
|
||||
<div class="github-widget" id="github-widget" style="margin-top:20px;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;color:var(--text);text-decoration:none">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.58 2 12.26c0 4.51 2.87 8.33 6.84 9.68.5.09.68-.22.68-.48v-1.7c-2.78.62-3.37-1.21-3.37-1.21-.45-1.18-1.1-1.5-1.1-1.5-.9-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.9 1.58 2.36 1.12 2.94.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.27 2.75 1.05a9.2 9.2 0 0 1 5 0c1.9-1.32 2.74-1.05 2.74-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.07.36.32.68.94.68 1.9v2.82c0 .26.18.58.69.48A10.02 10.02 0 0 0 22 12.26C22 6.58 17.52 2 12 2z"/></svg>
|
||||
sasheg/launcher
|
||||
<span style="margin-left:auto;font-size:12px;color:var(--text-secondary);font-weight:500">git.swe.zernmc.ru</span>
|
||||
</a>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--text-secondary)" id="github-stats">
|
||||
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> ветка <strong id="gh-branch" style="color:var(--text)">main</strong></span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <span id="gh-version">v1.1.1.2</span></span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11v2"/><path d="M17 11v2"/></svg> <span id="gh-commit">19a1633</span></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" class="btn btn-ghost" style="flex:1;justify-content:center"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg> Открыть в Gitea</a>
|
||||
</div>
|
||||
<!-- Languages bar like GitHub -->
|
||||
<div style="margin-top:4px">
|
||||
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--border);gap:1px" id="lang-bar" title="Java 49.4% · Python 25.4% · JavaScript 12.7% · CSS 7.3% · HTML 3.5% · Go 1.7%">
|
||||
<div style="flex:49.4;background:#b07219" title="Java 49.4%"></div>
|
||||
<div style="flex:25.4;background:#3572A5" title="Python 25.4%"></div>
|
||||
<div style="flex:12.7;background:#f1e05a" title="JavaScript 12.7%"></div>
|
||||
<div style="flex:7.3;background:#563d7c" title="CSS 7.3%"></div>
|
||||
<div style="flex:3.5;background:#e34c26" title="HTML 3.5%"></div>
|
||||
<div style="flex:1.7;background:#00ADD8" title="Go 1.7%"></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;font-size:12px;color:var(--text-secondary)" id="lang-legend">
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#b07219;display:inline-block"></i> Java 49.4%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#3572A5;display:inline-block"></i> Python 25.4%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#f1e05a;display:inline-block"></i> JavaScript 12.7%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#563d7c;display:inline-block"></i> CSS 7.3%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#e34c26;display:inline-block"></i> HTML 3.5%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#00ADD8;display:inline-block"></i> Go 1.7%</span>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-muted);margin-top:6px">32 120 строк · Java 15 858 · Python 8 172 · JS 4 083 · CSS 2 345 · HTML 1 110 · Go 552</p>
|
||||
</div>
|
||||
<code style="font-size:12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--text-secondary);overflow:auto">git clone ssh://git@git.swe.zernmc.ru:2222/sasheg/launcher.git</code>
|
||||
</div>
|
||||
|
||||
<!-- Manual Zern Prologue pack -->
|
||||
<div class="manual-pack-card" style="margin-top:20px;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:48px;height:48px;border-radius:12px;background:rgba(233,69,96,0.12);display:flex;align-items:center;justify-content:center;color:var(--accent);flex-shrink:0">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style="font-size:15px;font-weight:700">Zern Prologue — ручная установка</h4>
|
||||
<p style="font-size:12px;color:var(--text-secondary);margin-top:2px">1.21.1 NeoForge <strong style="color:var(--text)">21.1.228</strong> · Create: Aeronautics · без лаунчера</p>
|
||||
</div>
|
||||
<span style="margin-left:auto;font-size:11px;color:var(--accent);background:rgba(233,69,96,0.12);padding:4px 8px;border-radius:999px;white-space:nowrap">v4 · 1.8 ГБ</span>
|
||||
</div>
|
||||
<p style="font-size:13px;color:var(--text-secondary);line-height:1.5">Скачай архив модпака и распакуй вручную. Подходит если лаунчер не качает или хочешь поставить на Prism / MultiMC / ATLauncher.</p>
|
||||
<div style="background:rgba(251,191,36,0.08);border:1px solid rgba(251,191,36,0.2);border-radius:8px;padding:8px 10px;font-size:12px;color:#fbbf24;display:flex;gap:8px;align-items:center"><span style="font-size:14px">⚠️</span> <span><strong>Важно:</strong> используй только <strong>NeoForge 21.1.228</strong> для 1.21.1 — на новейшей (21.5+) сборка крашится, мир не загрузится.</span></div>
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-family:var(--mono);font-size:12px;color:var(--text-secondary)">
|
||||
%USERPROFILE%\.zernmc\instances\ZernPrologue
|
||||
<span style="color:var(--text-muted)"> · ~/.zernmc/instances/ZernPrologue на Linux</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a href="/pack/ZernPrologue/zip" class="btn btn-primary" style="flex:1;justify-content:center;min-width:180px">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Скачать Prologue.zip
|
||||
</a>
|
||||
<button class="btn btn-ghost" onclick="navigator.clipboard.writeText('%USERPROFILE%\\.zernmc\\instances');var t=document.createElement('div');t.textContent='Скопировано';t.style.cssText='position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--bg-elevated);border:1px solid var(--border);padding:8px 16px;border-radius:8px;font-size:13px;z-index:9999';document.body.appendChild(t);setTimeout(()=>t.remove(),1500)" style="flex:0 0 auto">Копировать путь</button>
|
||||
</div>
|
||||
<details style="font-size:12px;color:var(--text-secondary)"><summary style="cursor:pointer;color:var(--text)">Инструкция</summary>
|
||||
<ol style="margin:8px 0 0 18px;display:flex;flex-direction:column;gap:4px">
|
||||
<li>Нажми «Скачать Prologue.zip» (требует проходку — войди в лаунчере хотя бы раз, иначе 403).</li>
|
||||
<li>Распаковать в папку выше (создай если нет). Если там уже есть Prologue — замени.</li>
|
||||
<li>Для Prism/MultiMC: Импорт → «Import from zip» или «Add instance → Import from folder» укажи распакованную папку, Java 21.</li>
|
||||
<li>Запусти через ZernMC лаунчер или свой — вход на <strong>mc.zernmc.ru</strong></li>
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="download-note" data-i18n="download.note">Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section news" id="news">
|
||||
<div class="section-head">
|
||||
<span class="section-index">07 / 07</span>
|
||||
<h2 class="section-title" data-i18n-html="news.title">Что <span class="accent">нового.</span></h2>
|
||||
<p class="section-sub" data-i18n="news.sub">Последние чейнджлоги и обновления команды Zern.</p>
|
||||
</div>
|
||||
|
||||
<div class="news-grid" id="news-grid">
|
||||
<div class="news-skeleton"></div>
|
||||
<div class="news-skeleton"></div>
|
||||
<div class="news-skeleton"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<img src="/img/zernmc.svg" alt="Zern" width="28" height="28">
|
||||
<span>ZernMC</span>
|
||||
</div>
|
||||
<p class="footer-note">© <span id="year">2026</span> ZernMC. <span data-i18n="footer.note">Не аффилирован с Mojang или Microsoft. Minecraft — торговая марка Mojang AB.</span></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Auth modal (launcher style) -->
|
||||
<div id="auth-modal" class="modal-backdrop hidden" style="z-index:200">
|
||||
<div class="modal" style="max-width:380px">
|
||||
<div class="modal-head">
|
||||
<h3 id="auth-title" data-i18n="auth.title">Вход</h3>
|
||||
<button class="modal-close" id="auth-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px">
|
||||
<div style="display:flex;gap:8px;margin-bottom:4px">
|
||||
<button class="btn btn-ghost" id="auth-tab-login" style="flex:1;background:var(--accent);color:#fff" data-i18n="auth.login">Вход</button>
|
||||
<button class="btn btn-ghost" id="auth-tab-register" style="flex:1" data-i18n="auth.register">Регистрация</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="auth-username" data-i18n="auth.username">Логин</label>
|
||||
<input type="text" id="auth-username" placeholder="a-z, 0-9, _" autocomplete="username" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="auth-password" data-i18n="auth.password">Пароль</label>
|
||||
<input type="password" id="auth-password" placeholder="••••••••" autocomplete="current-password" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
|
||||
</div>
|
||||
<div class="field hidden" id="auth-confirm-field">
|
||||
<label for="auth-confirm" data-i18n="auth.confirm">Повтор пароля</label>
|
||||
<input type="password" id="auth-confirm" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
|
||||
</div>
|
||||
<p id="auth-error" class="hidden" style="color:#f87171;font-size:13px;background:rgba(248,113,113,0.1);padding:10px;border-radius:4px;text-align:center"></p>
|
||||
<button class="btn btn-primary" id="auth-submit" style="width:100%">Войти</button>
|
||||
<p style="font-size:12px;color:var(--text-muted);text-align:center">Нажимая, вы соглашаетесь с правилами ZernMC</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cabinet (personal area) -->
|
||||
<section class="section hidden" id="cabinet">
|
||||
<div class="section-head">
|
||||
<span class="section-index">08 / 08</span>
|
||||
<h2 class="section-title" data-i18n-html="cabinet.title">Личный <span class="accent">кабинет.</span></h2>
|
||||
<p class="section-sub" data-i18n="cabinet.sub">Ваш профиль и доступные сборки для скачивания (требует проходку).</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" id="cab-tab-profile" data-i18n="cabinet.profile">Профиль</button>
|
||||
<button class="btn btn-ghost" id="cab-tab-packs" data-i18n="cabinet.packs">Сборки</button>
|
||||
</div>
|
||||
<div id="cabinet-profile" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg, #e94560, #ff6b6b);display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff" id="cab-avatar">Z</div>
|
||||
<div>
|
||||
<div style="font-weight:700" id="cab-username">—</div>
|
||||
<div style="font-size:12px;color:var(--text-secondary)"><span id="cab-role">—</span> · <span id="cab-pass">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4>UUID</h4><p id="cab-uuid" style="font-family:var(--mono);font-size:11px">—</p></div></div>
|
||||
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4 data-i18n="cabinet.pass">Проходка</h4><p id="cab-pass2">—</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cabinet-packs" class="hidden" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||
<h3 style="font-size:15px;font-weight:700" data-i18n="cabinet.available">Доступные сборки</h3>
|
||||
<span id="cab-packs-count" style="font-size:12px;color:var(--text-muted)">—</span>
|
||||
</div>
|
||||
<div id="cab-packs-list" style="display:flex;flex-direction:column;gap:8px"><p style="color:var(--text-muted);font-size:13px">Загрузка...</p></div>
|
||||
<p id="cab-packs-error" class="hidden" style="color:#f87171;font-size:13px"></p>
|
||||
<p style="font-size:12px;color:var(--text-muted)">Скачивание требует активной проходки. Кнопка скачает zip для ручной установки в <code>%USERPROFILE%\.zernmc\instances</code></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,10 @@
|
||||
TODO / ideas backlog
|
||||
====================
|
||||
|
||||
1.1.1.2 hotfix DONE — WebKit "Reload" + onboarding persist
|
||||
- Поправлено JFXLauncher.java:512 — document.addEventListener('contextmenu', e=> e.preventDefault(), true) глобально (Reload больше не появляется)
|
||||
- Onboarding fix: localStorage volatile в WebView → теперь persist в Config (~/.zernmc/launcher.properties onboardedVersion/completedOnboardBlocks) через /api/settings GET/POST, JS sync localStorage<->Config. Прохождение без "Не показывать" теперь не всплывает снова. Версия 1.1.1.2
|
||||
|
||||
Java Agent + IPC (low priority, overkill now)
|
||||
- Заменить прямой ProcessBuilder.start() на прокси-класс (-javaagent или -cp прокси)
|
||||
- Прокси класс: подключается к localhost RPC, ждёт "launch", потом reflection вызывает реальный main
|
||||
|
||||