Compare commits
165 Commits
ui
..
cdc81e4e8d
| Author | SHA1 | Date | |
|---|---|---|---|
| cdc81e4e8d | |||
| 4f5bd5387a | |||
| dcd5e91ebe | |||
| 553aad41f9 | |||
| f706e8393c | |||
| 26e0280d84 | |||
| 421fb04ab5 | |||
| 7d30caba74 | |||
| a87a871e43 | |||
| c51d441743 | |||
| 7235017493 | |||
| 4acbdedf70 | |||
| e962adbb58 | |||
| 22b056614b | |||
| 6024df5093 | |||
| 1307d10e81 | |||
| cffa6519ca | |||
| ce8cf32ddd | |||
| 72c58aced3 | |||
| ffc491c333 | |||
| 2846e3edd1 | |||
| 7ae81b3c91 | |||
| 375b98586d | |||
| 348969e79c | |||
| 15532bf341 | |||
| 424cf9bc25 | |||
| ef7d5edef3 | |||
| d9c527b8db | |||
| f32ac1ef98 | |||
| 6763d0144a | |||
| 37ec2bc342 | |||
| 6a0c59f032 | |||
| 599e9d5e67 | |||
| 929d5a4ad6 | |||
| 9a853a6134 | |||
| 6368c32a83 | |||
| d7ba06f24c | |||
| dcb3412a58 | |||
| c2150aa7d2 | |||
| 03a1a1533e | |||
| 03c908c80c | |||
| b9bd642605 | |||
| f58db7f941 | |||
| 0a388c90fd | |||
| 6cbf8a6549 | |||
| d4b6ea081e | |||
| 96e68dab89 | |||
| e6a6392565 | |||
| 1efd283234 | |||
| 546652f44c | |||
| 5c8e93fd95 | |||
| 1d069811e3 | |||
| 201269efea | |||
| 0d61ad1107 | |||
| e49e630afe | |||
| 0a2b80ed06 | |||
| b493b3278b | |||
| ec7ef01760 | |||
| 166dbf8935 | |||
| 7014c4a455 | |||
| d956bce921 | |||
| a765d064c4 | |||
| 1d5241075b | |||
| 2c670b1103 | |||
| 389280f7f1 | |||
| ee1e4fa8d2 | |||
| e17b1d073a | |||
| a8f3ca5049 | |||
| 59480217aa | |||
| 4697b16ab4 | |||
| 099df80cc6 | |||
| 74cd5ffdf3 | |||
| 01668dd3bf | |||
| b2dbbac6ca | |||
| e32a057684 | |||
| d4dc35aac3 | |||
| 1e7231af57 | |||
| fd6e292d6e | |||
| 1e876ffe28 | |||
| 2d515108f0 | |||
| 13c9f67f6e | |||
| 659265c2f0 | |||
| d8f189558a | |||
| 6f56012e3a | |||
| 3a0570e7da | |||
| 985abf7440 | |||
| ec551ab2e3 | |||
| e5948b5337 | |||
| 5a826c8511 | |||
| ce12854e1b | |||
| e566703332 | |||
| aaa19df5e4 | |||
| 0ee8077787 | |||
| fba944b4b8 | |||
| d39b40053a | |||
| 1199ca9e21 | |||
| 50080d890f | |||
| f6fbb66cdc | |||
| d7a928cce4 | |||
| 3bd3d1d0e8 | |||
| df9fa7b867 | |||
| 81fbe028e8 | |||
| 513c07666b | |||
| 04f97c3c80 | |||
| f40cf7afed | |||
| 0cef411125 | |||
| 523f659269 | |||
| 04620d76c4 | |||
| d0b4e187c8 | |||
| f2d3de82f7 | |||
| b4431702dc | |||
| cd2cf44d9c | |||
| 8939e24e69 | |||
| c0310ed573 | |||
| c96b502ad4 | |||
| bfcffdd88d | |||
| 331fc9a863 | |||
| e347c042d5 | |||
| bb564e6e9b | |||
| 6f53002266 | |||
| 9688509df5 | |||
| efc4b086d1 | |||
| 2cdc438411 | |||
| b60e414d37 | |||
| 10ec8625b9 | |||
| f24cc078c5 | |||
| adde40d921 | |||
| 6bf6c1634a | |||
| 98462ba4a3 | |||
| 11ec84fe24 | |||
| 8b56652a73 | |||
| d7a6eb760e | |||
| c6dd215e9b | |||
| a3f9871d6e | |||
| 2b6cb6b3ad | |||
| cca6ef3eca | |||
| 8733e359e6 | |||
| 89c0057759 | |||
| bf26baaf93 | |||
| 13a43a01ef | |||
| 296f564b39 | |||
| de703a4ddd | |||
| a501329956 | |||
| 5516aeb12f | |||
| 7b48ae2ab6 | |||
| 9bee361ea4 | |||
| 8cbad9af96 | |||
| 33cb7830e3 | |||
| 920330bbab | |||
| c03d7a788f | |||
| b47793b618 | |||
| 94968e8e77 | |||
| 0b4af1353d | |||
| 4edbe7e910 | |||
| 3d8313f7d2 | |||
| e21fd922ab | |||
| b29222af68 | |||
| ac3ce1800f | |||
| 2babe53e99 | |||
| 369f8c2f9d | |||
| c0a85658f4 | |||
| 7568e34d91 | |||
| 7670edbff7 | |||
| cf4a5c74e5 | |||
| 1a5336a996 |
@@ -8,50 +8,32 @@
|
||||
- Написан полностью с нуля, без использования готовых решений
|
||||
- Удобная загрузка и обновление сборок прямо с сервера ZernMC
|
||||
- Чистый и относительно читаемый код как клиента, так и сервера
|
||||
- Графический интерфейс на **JavaFX** (по умолчанию) + **TUI** (режим `--cli`)
|
||||
- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.), обход блокировок через выбор домена и SNI
|
||||
- Поддержка загрузчиков **Fabric**, **Forge**, **NeoForge** и ванильных версий (установка через встроенный JRE)
|
||||
- Ротационные логи в стиле Minecraft: `latest.log` + архивы `.log.gz`
|
||||
- Инкрементальное обновление JavaFX и автообновление лаунчера
|
||||
- Подпись exe самоподписным сертификатом Zern.cc (jsign/Authenticode)
|
||||
- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.)
|
||||
- Возможность запуска клиента даже без графического интерфейса (TUI-режим)
|
||||
- Полное отсутствие рекламы и телеметрии
|
||||
- Не собирает никаких данных о пользователе
|
||||
- Работает «из коробки»: достаточно распаковать архив и запустить `zernmc.exe`
|
||||
- Работает «из коробки»: достаточно распаковать архив и запустить `ZernMCLauncher.exe`
|
||||
- Есть система аккаунтов и проходок
|
||||
|
||||
## Состав дистрибутива
|
||||
|
||||
**Онлайн-установщик `ZernMC-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`.
|
||||
|
||||
## Чего пока нет в лаунчере
|
||||
|
||||
- Готовых пресетов оптимизации JVM (настраиваются Java и выделенная память)
|
||||
- Поддержки Quilt, LabyMod и других лоадеров
|
||||
- Графического интерфейса (GUI) — только TUI
|
||||
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
|
||||
- Поддержки **Forge** (в разработке)
|
||||
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
|
||||
- Раздела новостей об обновлениях Minecraft и лаунчера
|
||||
- Выбора готовых пресетов оптимизации JVM
|
||||
- Кастомных модов (UI, спавнеры, DPI, карточки)
|
||||
- Сайта для лаунчера и сервера (текущий требует переделки)
|
||||
- Трекинга наигранного времени (обновление раз в час вместо минуты)
|
||||
- Сайта для лаунчера и сервера
|
||||
- Трекинга наигранного времени
|
||||
|
||||
## Что планируется доработать в ближайшее время
|
||||
|
||||
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
|
||||
- **GUI мод** — привести в единый стиль с главным меню
|
||||
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
|
||||
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпиленной проверкой, уведомления админу в Telegram с технической информацией
|
||||
- **Сайт** — полноценный сайт для лаунчера и сервера
|
||||
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
|
||||
- **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
|
||||
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
|
||||
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
|
||||
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
|
||||
@@ -68,43 +50,35 @@ Bootstrap встроен в `.exe`.
|
||||
|
||||
### Управление в лаунчере (TUI)
|
||||
|
||||
Консольные версии (`zernmc-cli.exe`, а также режим `--cli`) используют **текстовый интерфейс (TUI)**:
|
||||
Лаунчер использует **текстовый интерфейс (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).
|
||||
|
||||
## Как скачать и запустить
|
||||
|
||||
**Скачать лаунчер (выбери один):**
|
||||
- **Онлайн-установщик (рекомендуется)** — `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.
|
||||
Пока автообновления и GitHub Releases не настроены, скачать последнюю версию можно по ссылкам:
|
||||
|
||||
**Зеркала (оба варианта):**
|
||||
- https://api.zern.cc/launcher/download/jre — JRE для онлайн
|
||||
- https://api.zernmc.ru/launcher/download/latest / https://api.zernmc.online/launcher/download/latest — ZIP
|
||||
**Скачать лаунчер:**
|
||||
- https://api.zernmc.ru/launcher/download/latest
|
||||
- https://api.zernmc.online/launcher/download/latest
|
||||
|
||||
**Инструкция (оба):**
|
||||
1. Скачай online exe **или** offline zip
|
||||
2. Запусти установщик → выбери папку (по умолчанию `C:\ZernMC`, можно `D:\Games\ZernMC`) → ярлык
|
||||
3. Запусти `zernmc.exe` (или `zernmc-cli.exe`) из папки установки
|
||||
4. Войди в аккаунт и нажми **«Начать игру»**
|
||||
**Инструкция:**
|
||||
1. Скачайте zip-архив
|
||||
2. Распакуйте в удобную папку
|
||||
3. Запустите `ZernMCLauncher.exe`
|
||||
4. Нажмите **«Начать игру»**
|
||||
|
||||
### Установка первой сборки
|
||||
|
||||
@@ -112,31 +86,28 @@ Bootstrap встроен в `.exe`.
|
||||
1. Выберите **«Установить новую сборку»**
|
||||
2. Выберите тип сборки:
|
||||
- **ZernMC** — готовые сборки с нашего сервера
|
||||
- **Модовый загрузчик** — Fabric / Forge / NeoForge
|
||||
- **Модовый загрузчик** — Fabric / Forge / Quilt и т.д.
|
||||
- **Ваниль** — чистая версия от Mojang
|
||||
|
||||
**Рекомендация:** Не используйте кириллицу в названии сборки! (science 1.0.0-1.0.5)
|
||||
|
||||
## Возможные проблемы и решения
|
||||
|
||||
### «Error: Invalid or corrupt jarfile» при запуске exe
|
||||
Перекачайте архив целиком из https://api.zernmc.ru/launcher/download/latest и распакуйте заново — старые версии (до 1.0.16.17) были повреждены подписью и не запускаются.
|
||||
|
||||
### Проблемы с загрузкой ассетов
|
||||
Иногда лаунчер неправильно определяет Asset Index.
|
||||
Иногда лаунчер неправильно определяет Asset Index.
|
||||
**Решение:**
|
||||
1. Запустите версию и посмотрите в логах, какой `.json` файл он пытается загрузить.
|
||||
2. Скопируйте правильный файл из `assets/indexes/` и переименуйте его в нужное имя.
|
||||
|
||||
### Forge / NeoForge
|
||||
Работает через установщики лоадеров. Если у вас нет внешнего доступа к Maven-репозиториям — используйте прокси-режим.
|
||||
### Forge версии
|
||||
Запуск Forge пока работает нестабильно. Рекомендуется дождаться исправления генерации команды запуска.
|
||||
|
||||
### Прокси-режим
|
||||
Если нет доступа к Fabric/Forge Maven или серверам Mojang — лаунчер пытается использовать наш сервер как прокси. Если и к нему нет доступа — загрузка может не работать.
|
||||
|
||||
## Как сообщить об ошибке (Issue)
|
||||
|
||||
1. Скопируйте весь текст ошибки из логов (`latest.log`)
|
||||
1. Скопируйте весь текст ошибки из логов (лаунчер не очищает экран, поэтому просто скролльте вверх)
|
||||
2. Укажите, что вы делали перед ошибкой
|
||||
3. Укажите версию лаунчера
|
||||
4. Создайте Issue на GitHub
|
||||
@@ -147,21 +118,25 @@ Bootstrap встроен в `.exe`.
|
||||
git clone https://github.com/SashegDev/launcher
|
||||
```
|
||||
Откройте `launcher/launcher/pom.xml`
|
||||
отредактируйте пути для билда, так как сейчас - он рассчитан на разработку только на своём сервере
|
||||
отредактируете пути для билда, так как сейчас - он расчитан на разработку только на моём сервере
|
||||
```bash
|
||||
mvn clean package
|
||||
```
|
||||
можете начинать!
|
||||
|
||||
**Сборка exe на Linux требует:** `jsign` (`~/tools/jsign/jsign.jar`), файлы сертификата в `/root/cert/codesign/`, встроенный JRE в `~/launcher/jre/jre21`.
|
||||
|
||||
### Благодарность
|
||||
- Glitch135 — за бета-тест лаунчера и фидбеки
|
||||
Glitch135 - За бета-тест лаунчера и фидбеки
|
||||
|
||||
### помощь
|
||||
Я был бы рад если нашлись люди кто мог бы помочь в разработке лаунчера и добавления к нему хотя бы базового UI чтоб было намногл удобнее работать с ним
|
||||
|
||||
|
||||
|
||||
Буду рад помощи в разработке!
|
||||
Особенно приветствуется помощь с:
|
||||
|
||||
- Переходом на нормальный графический интерфейс (GUI)
|
||||
- Улучшением генерации команды запуска
|
||||
- Добавлением поддержки Quilt / LabyMod
|
||||
- Добавлением поддержки Forge / NeoForge / Quilt
|
||||
- Стабилизацией серверной части
|
||||
- Вообще улучшением клиента
|
||||
- вообще улучшением клиента
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
module zernmc-installer
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac h1:/QqP+ajFMma4hNWQyBDVaQQhz9Z1kDyXScNWMO3owx0=
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
|
||||
@@ -1,552 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sqweek/dialog"
|
||||
)
|
||||
|
||||
var (
|
||||
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
|
||||
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
|
||||
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from adjacent zip)")
|
||||
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
|
||||
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultInstallDir = "C:\\ZernMC"
|
||||
serverBase = "https://api.zern.cc"
|
||||
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Offline flag overrides online
|
||||
isOnline := *onlineFlag && !*offlineFlag
|
||||
|
||||
dir := *installDirFlag
|
||||
if dir == "" {
|
||||
dir = askInstallDir()
|
||||
}
|
||||
if dir == "" {
|
||||
fmt.Println("Installation cancelled")
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline)
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fatal("Cannot create directory: %v", err)
|
||||
}
|
||||
|
||||
createShortcut := *shortcutFlag
|
||||
if !isFlagSet("shortcut") {
|
||||
// ask via dialog if not explicitly flagged
|
||||
createShortcut = askShortcut()
|
||||
}
|
||||
|
||||
if isOnline {
|
||||
if err := installOnline(dir, createShortcut); err != nil {
|
||||
fatal("Online install failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
zipPath := *offlineZipFlag
|
||||
if zipPath == "" {
|
||||
// try to find adjacent offline zip
|
||||
zipPath = findAdjacentOfflineZip()
|
||||
if zipPath == "" {
|
||||
// ask user
|
||||
zipPath, _ = dialog.File().Title("Select ZernMC-Offline.zip").Filter("ZIP (*.zip)", "zip").Load()
|
||||
}
|
||||
}
|
||||
if zipPath == "" {
|
||||
fatal("Offline zip not specified")
|
||||
}
|
||||
if err := installOffline(dir, zipPath, createShortcut); err != nil {
|
||||
fatal("Offline install failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nInstallation complete!")
|
||||
fmt.Printf("Launcher: %s\\zernmc.exe\n", dir)
|
||||
fmt.Printf("Data dir: %s\\.zernmc (local files, instances)\n", os.Getenv("USERPROFILE"))
|
||||
if createShortcut {
|
||||
fmt.Println("Shortcut on desktop: ZernMC Launcher.lnk")
|
||||
}
|
||||
fmt.Println("Uninstall: run", filepath.Join(dir, "uninstall.exe"), "or delete folder")
|
||||
}
|
||||
|
||||
func isFlagSet(name string) bool {
|
||||
found := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func askInstallDir() string {
|
||||
// Try GUI dialog first (Windows)
|
||||
if runtime.GOOS == "windows" {
|
||||
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
|
||||
if err == nil && dir != "" {
|
||||
// dialog.Directory returns chosen dir; append ZernMC if not already
|
||||
if !strings.Contains(strings.ToLower(dir), "zernmc") {
|
||||
dir = filepath.Join(dir, "ZernMC")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
// fallback to default if dialog fails/cancelled with empty
|
||||
fmt.Printf("Dialog cancelled or error (%v), using default %s\n", err, defaultInstallDir)
|
||||
}
|
||||
// CLI fallback
|
||||
fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
input = defaultInstallDir
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func askShortcut() bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
// simple yes/no dialog
|
||||
// sqweek/dialog Message does not return bool, so use question
|
||||
// fallback to true by default if dialog unavailable
|
||||
yes := dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
|
||||
return yes
|
||||
}
|
||||
|
||||
func fatal(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
// also show dialog on Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error()
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func installOnline(dir string, createShortcut bool) error {
|
||||
fmt.Println("Online installation — downloading JRE + launcher files...")
|
||||
// Step 1: download JRE zip if missing
|
||||
jreDir := filepath.Join(dir, "lib", "jre21")
|
||||
javaExe := filepath.Join(jreDir, "bin", "java.exe")
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
fmt.Println("Downloading JRE 21 (47 MB)...")
|
||||
jreURL := serverBase + "/launcher/download/jre"
|
||||
tmpZip := filepath.Join(os.TempDir(), jreZipName)
|
||||
if err := downloadFile(jreURL, tmpZip); err != nil {
|
||||
// fallback to direct zip from builds if server endpoint not ready
|
||||
// try alternative mirrors
|
||||
mirrors := []string{
|
||||
"https://api.zernmc.ru/launcher/download/jre",
|
||||
"https://api.zernmc.online/launcher/download/jre",
|
||||
}
|
||||
var lastErr error
|
||||
for _, m := range mirrors {
|
||||
fmt.Printf("Retry mirror %s\n", m)
|
||||
if err := downloadFile(m, tmpZip); err == nil {
|
||||
lastErr = nil
|
||||
break
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("JRE download failed: %w", lastErr)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
|
||||
if err := unzip(tmpZip, dir); err != nil {
|
||||
// try alt: unzip should contain jre21/ prefix or lib/jre21?
|
||||
// our zip contains jre21/ at root, need to move to lib/jre21
|
||||
return fmt.Errorf("unzip JRE: %w", err)
|
||||
}
|
||||
// handle both zip layouts: some zips contain jre21/, some lib/jre21/
|
||||
// if tmp unzip created dir/jre21, move to lib/jre21
|
||||
candidate := filepath.Join(dir, "jre21")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
os.MkdirAll(filepath.Join(dir, "lib"), 0755)
|
||||
os.RemoveAll(jreDir)
|
||||
if err := os.Rename(candidate, jreDir); err != nil {
|
||||
// fallback copy
|
||||
if err := copyDir(candidate, jreDir); err != nil {
|
||||
return err
|
||||
}
|
||||
os.RemoveAll(candidate)
|
||||
}
|
||||
}
|
||||
os.Remove(tmpZip)
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
return fmt.Errorf("JRE unpack failed, %s not found", javaExe)
|
||||
}
|
||||
fmt.Println("JRE ready")
|
||||
} else {
|
||||
fmt.Println("JRE already present, skipping")
|
||||
}
|
||||
|
||||
// Step 2: fetch meta and download launcher files
|
||||
fmt.Println("Fetching launcher meta...")
|
||||
meta, err := fetchMeta()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch meta: %w", err)
|
||||
}
|
||||
fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
|
||||
|
||||
for i, f := range meta.Files {
|
||||
rel := f.Path
|
||||
// skip JRE entries (should not be in meta, but just in case)
|
||||
if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") {
|
||||
continue
|
||||
}
|
||||
dest := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
need := true
|
||||
if fi, err := os.Stat(dest); err == nil && !fi.IsDir() {
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash == strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
need = false
|
||||
}
|
||||
}
|
||||
if !need {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
|
||||
// try mirrors on failure
|
||||
if err := downloadFile(url, dest); err != nil {
|
||||
// try versioned fallback (some servers use builds/ vs versions/)
|
||||
alt := fmt.Sprintf("%s/launcher/download/jar", serverBase) // not generic
|
||||
_ = alt
|
||||
return fmt.Errorf("download %s: %w", rel, err)
|
||||
}
|
||||
// verify hash
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash != strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
fmt.Printf(" WARNING hash mismatch for %s\n", rel)
|
||||
}
|
||||
}
|
||||
|
||||
// write build.version
|
||||
if err := os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err != nil {
|
||||
fmt.Printf("warning write build.version: %v\n", err)
|
||||
}
|
||||
// also ensure bin/zernmclauncher.jar etc are present
|
||||
fmt.Println("Launcher files ready")
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installOffline(dir, zipPath string, createShortcut bool) error {
|
||||
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
|
||||
if _, err := os.Stat(zipPath); err != nil {
|
||||
return fmt.Errorf("offline zip not found: %s", zipPath)
|
||||
}
|
||||
fmt.Println("Unpacking (98 MB, ~200 MB unpacked)...")
|
||||
if err := unzip(zipPath, dir); err != nil {
|
||||
return fmt.Errorf("unzip: %w", err)
|
||||
}
|
||||
// zip contains zernmc.exe, lib/, bin/, assets/ at root — already correct relative to dir
|
||||
// if zip contained top-level folder, handle it
|
||||
// we unzip directly to dir, so should be fine
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
fmt.Println("Offline unpack complete")
|
||||
fmt.Printf("Data will be in %s\\.zernmc\n", os.Getenv("USERPROFILE"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
// support resume? simple GET
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// ensure parent
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
total := resp.ContentLength
|
||||
var written int64
|
||||
buf := make([]byte, 64*1024)
|
||||
start := time.Now()
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
written += int64(n)
|
||||
if total > 0 {
|
||||
pct := float64(written) / float64(total) * 100
|
||||
elapsed := time.Since(start).Seconds()
|
||||
speed := float64(written) / 1024 / 1024 / (elapsed + 0.001)
|
||||
fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
|
||||
} else {
|
||||
fmt.Printf("\r %.1f MB ", float64(written)/1024/1024)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
out.Close()
|
||||
// verify tmp exists
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
// fallback copy
|
||||
if err2 := copyFile(tmp, dest); err2 != nil {
|
||||
return err2
|
||||
}
|
||||
os.Remove(tmp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzip(src, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
for _, f := range r.File {
|
||||
fpath := filepath.Join(dest, f.Name)
|
||||
// ZipSlip protection
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("illegal file path: %s", fpath)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, f.Mode())
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(path, src)
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
} `json:"files"`
|
||||
}
|
||||
|
||||
func fetchMeta() (*Meta, error) {
|
||||
// try /launcher/meta/{version} flow: first get version, then meta
|
||||
resp, err := http.Get(serverBase + "/launcher/version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var verResp struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver := verResp.Version
|
||||
if ver == "" {
|
||||
ver = "1.1.1"
|
||||
}
|
||||
metaResp, err := http.Get(serverBase + "/launcher/meta/" + ver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer metaResp.Body.Close()
|
||||
if metaResp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("meta %d", metaResp.StatusCode)
|
||||
}
|
||||
var meta Meta
|
||||
if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.Version == "" {
|
||||
meta.Version = ver
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func findAdjacentOfflineZip() string {
|
||||
exe, _ := os.Executable()
|
||||
dir := filepath.Dir(exe)
|
||||
// look for ZernMC-Offline*.zip or ZernMC-win*.zip nearby
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
matches, _ = filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
// also check current dir
|
||||
matches, _ = filepath.Glob("ZernMC-Offline*.zip")
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func createDesktopShortcut(installDir string) error {
|
||||
if runtime.GOOS != "windows" {
|
||||
return nil
|
||||
}
|
||||
desktop := filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
|
||||
if _, err := os.Stat(desktop); err != nil {
|
||||
desktop = filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop")
|
||||
}
|
||||
if _, err := os.Stat(desktop); err != nil {
|
||||
return fmt.Errorf("desktop not found")
|
||||
}
|
||||
lnk := filepath.Join(desktop, "ZernMC Launcher.lnk")
|
||||
target := filepath.Join(installDir, "zernmc.exe")
|
||||
ps := fmt.Sprintf(`$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WorkingDirectory = '%s'; $Shortcut.IconLocation = '%s'; $Shortcut.Save()`, lnk, target, installDir, target)
|
||||
tmp := filepath.Join(os.TempDir(), "zernmc_shortcut.ps1")
|
||||
if err := os.WriteFile(tmp, []byte(ps), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func createUninstaller(dir string) error {
|
||||
// create uninstall.bat and copy self as uninstall.exe
|
||||
exe, _ := os.Executable()
|
||||
uninstExe := filepath.Join(dir, "uninstall.exe")
|
||||
if exe != "" && exe != uninstExe {
|
||||
_ = copyFile(exe, uninstExe)
|
||||
}
|
||||
bat := filepath.Join(dir, "uninstall.bat")
|
||||
content := fmt.Sprintf(`@echo off
|
||||
echo Uninstalling ZernMC from %s
|
||||
timeout /t 2 >nul
|
||||
del "%%USERPROFILE%%\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
del "%%USERPROFILE%%\OneDrive\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
echo Data in %%USERPROFILE%%\.zernmc will be kept.
|
||||
echo To remove completely, delete %%USERPROFILE%%\.zernmc manually.
|
||||
`, dir)
|
||||
_ = os.WriteFile(bat, []byte(content), 0644)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCmd(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -47,43 +47,13 @@ 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 = resolveBaseDir();
|
||||
baseDir = Paths.get("").toAbsolutePath();
|
||||
binDir = baseDir.resolve("bin");
|
||||
Files.createDirectories(binDir);
|
||||
logDir = baseDir.resolve("logs");
|
||||
Files.createDirectories(logDir);
|
||||
javafxPath = baseDir.resolve("lib").resolve("javafx");
|
||||
rotateLogs();
|
||||
|
||||
log("=== ZernMC Launcher ===");
|
||||
|
||||
@@ -401,50 +371,11 @@ 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("latest.log"), entry + "\n",
|
||||
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (Exception ignored) {}
|
||||
if (ui != null) ui.setStatus(msg);
|
||||
@@ -950,7 +881,7 @@ public class Bootstrap {
|
||||
// Exit
|
||||
info.put("exit_code", exitCode);
|
||||
info.put("stderr", stderr != null ? stderr : "");
|
||||
info.put("launcher_log", readFileSafe(logDir.resolve("latest.log"), 200));
|
||||
info.put("launcher_log", readFileSafe(logDir.resolve("launcher.log"), 200));
|
||||
|
||||
// System
|
||||
Map<String, Object> system = new HashMap<>();
|
||||
@@ -1052,13 +983,6 @@ 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.");
|
||||
|
||||
@@ -156,13 +156,9 @@
|
||||
<configuration>
|
||||
<outfile>../../server/builds/zernmc.exe</outfile>
|
||||
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
|
||||
<icon>${project.basedir}/src/main/icons/zernmc.ico</icon>
|
||||
<headerType>gui</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<classPath>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<addDependencies>false</addDependencies>
|
||||
</classPath>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<cmdLine>--jfx</cmdLine>
|
||||
<jre>
|
||||
<path>lib/jre21</path>
|
||||
@@ -172,12 +168,10 @@
|
||||
<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>
|
||||
@@ -196,11 +190,7 @@
|
||||
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
|
||||
<headerType>console</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<classPath>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<addDependencies>false</addDependencies>
|
||||
</classPath>
|
||||
<cmdLine>--cli</cmdLine>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<jre>
|
||||
<path>lib/jre21</path>
|
||||
<minVersion>21</minVersion>
|
||||
@@ -209,54 +199,15 @@
|
||||
<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>
|
||||
|
||||
@@ -328,118 +279,22 @@
|
||||
</fileset>
|
||||
</copy>
|
||||
|
||||
|
||||
<!-- Подпись экзешников самоподписным сертификатом Zern.cc (publisher: ZernMC Launcher) -->
|
||||
<loadfile property="pfxpass" srcFile="/root/cert/codesign/.pfxpass">
|
||||
<filterchain>
|
||||
<striplinebreaks/>
|
||||
</filterchain>
|
||||
</loadfile>
|
||||
|
||||
<exec executable="java" failonerror="true">
|
||||
<arg value="-jar"/>
|
||||
<arg value="${user.home}/tools/jsign/jsign.jar"/>
|
||||
<arg value="--keystore"/>
|
||||
<arg value="/root/cert/codesign/zerncc.pfx"/>
|
||||
<arg value="--storepass"/>
|
||||
<arg value="${pfxpass}"/>
|
||||
<arg value="--alias"/>
|
||||
<arg value="1"/>
|
||||
<arg value="--tsaurl"/>
|
||||
<arg value="http://timestamp.digicert.com"/>
|
||||
<arg value="--alg"/>
|
||||
<arg value="SHA-256"/>
|
||||
<arg value="../../server/builds/zernmc.exe"/>
|
||||
</exec>
|
||||
|
||||
<exec executable="java" failonerror="true">
|
||||
<arg value="-jar"/>
|
||||
<arg value="${user.home}/tools/jsign/jsign.jar"/>
|
||||
<arg value="--keystore"/>
|
||||
<arg value="/root/cert/codesign/zerncc.pfx"/>
|
||||
<arg value="--storepass"/>
|
||||
<arg value="${pfxpass}"/>
|
||||
<arg value="--alias"/>
|
||||
<arg value="1"/>
|
||||
<arg value="--tsaurl"/>
|
||||
<arg value="http://timestamp.digicert.com"/>
|
||||
<arg value="--alg"/>
|
||||
<arg value="SHA-256"/>
|
||||
<arg value="../../server/builds/zernmc-cli.exe"/>
|
||||
</exec>
|
||||
|
||||
<exec executable="java" failonerror="true">
|
||||
<arg value="-jar"/>
|
||||
<arg value="${user.home}/tools/jsign/jsign.jar"/>
|
||||
<arg value="--keystore"/>
|
||||
<arg value="/root/cert/codesign/zerncc.pfx"/>
|
||||
<arg value="--storepass"/>
|
||||
<arg value="${pfxpass}"/>
|
||||
<arg value="--alias"/>
|
||||
<arg value="1"/>
|
||||
<arg value="--tsaurl"/>
|
||||
<arg value="http://timestamp.digicert.com"/>
|
||||
<arg value="--alg"/>
|
||||
<arg value="SHA-256"/>
|
||||
<arg value="../../server/builds/zernmc-cli-jfx.exe"/>
|
||||
</exec>
|
||||
|
||||
<!-- Создаём README -->
|
||||
<echo file="../../server/builds/README.txt">
|
||||
ZernMC Launcher
|
||||
|
||||
Files:
|
||||
- zernmc.exe - Main launcher with GUI (default)
|
||||
- zernmc-cli.exe - CLI version (console, architecture-independent)
|
||||
- zernmc-cli-jfx.exe - CLI + JFX UI (console visible + JavaFX frontend)
|
||||
- zernmc.exe - Main launcher with GUI
|
||||
|
||||
How to use:
|
||||
Just run zernmc.exe (or zernmc-cli.exe / zernmc-cli-jfx.exe for CLI)
|
||||
Just run zernmc.exe
|
||||
</echo>
|
||||
|
||||
<!-- Создаём один архив со всем -->
|
||||
<zip destfile="../../server/builds/ZernMC-win-${revision}.${hotfix}.zip"
|
||||
basedir="../../server/builds"
|
||||
includes="zernmc.exe,zernmc-cli.exe,zernmc-cli-jfx.exe,bin/**,assets/**,lib/**,README.txt"
|
||||
includes="zernmc.exe,bin/**,assets/**,lib/**,README.txt"
|
||||
excludes="build.version,*.jar"/>
|
||||
|
||||
<!-- Go online/offline инсталляторы (нативные, без JRE) -->
|
||||
<exec executable="bash" failonerror="false">
|
||||
<arg value="-c"/>
|
||||
<arg value="if command -v go >/dev/null 2>&1; then echo 'Building Go installers...'; mkdir -p ../../server/builds; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags='-s -w' -o ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../installer 2>&1 || echo 'Go build skipped (no go)'; cp ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe 2>/dev/null || true; fi"/>
|
||||
</exec>
|
||||
|
||||
<!-- Подпись Go инсталляторов -->
|
||||
<exec executable="java" failonerror="false">
|
||||
<arg value="-jar"/>
|
||||
<arg value="${user.home}/tools/jsign/jsign.jar"/>
|
||||
<arg value="--keystore"/>
|
||||
<arg value="/root/cert/codesign/zerncc.pfx"/>
|
||||
<arg value="--storepass"/>
|
||||
<arg value="${pfxpass}"/>
|
||||
<arg value="--alias"/>
|
||||
<arg value="1"/>
|
||||
<arg value="--tsaurl"/>
|
||||
<arg value="http://timestamp.digicert.com"/>
|
||||
<arg value="--alg"/>
|
||||
<arg value="SHA-256"/>
|
||||
<arg value="../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe"/>
|
||||
</exec>
|
||||
<exec executable="java" failonerror="false">
|
||||
<arg value="-jar"/>
|
||||
<arg value="${user.home}/tools/jsign/jsign.jar"/>
|
||||
<arg value="--keystore"/>
|
||||
<arg value="/root/cert/codesign/zerncc.pfx"/>
|
||||
<arg value="--storepass"/>
|
||||
<arg value="${pfxpass}"/>
|
||||
<arg value="--alias"/>
|
||||
<arg value="1"/>
|
||||
<arg value="--tsaurl"/>
|
||||
<arg value="http://timestamp.digicert.com"/>
|
||||
<arg value="--alg"/>
|
||||
<arg value="SHA-256"/>
|
||||
<arg value="../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe"/>
|
||||
</exec>
|
||||
</target>
|
||||
</configuration>
|
||||
</execution>
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#e94560"/>
|
||||
<stop offset="100%" stop-color="#ff6b6b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="64" fill="url(#g)"/>
|
||||
<path d="M82 128 L128 82 L174 128 L128 174 Z" fill="white" opacity="0.9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 423 B |
|
Before 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("latest.log"), entry + "\n",
|
||||
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@@ -48,16 +48,7 @@ public class ForgeInstaller {
|
||||
LauncherLogger.info("Downloading Forge Installer...");
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
// Step 4: Pre-download the libraries the installer subprocess would fetch from
|
||||
// maven.minecraftforge.net directly. On blocked networks that subprocess cannot
|
||||
// reach the maven host, so we seed every library (version.json + install_profile.json)
|
||||
// through the launcher smart proxy first. The installer reuses files whose checksums
|
||||
// validate ("File exists: Checksum validated"), so no direct downloads are needed.
|
||||
LauncherLogger.info("Prefetching Forge libraries via smart proxy...");
|
||||
int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath());
|
||||
LauncherLogger.info("Prefetched " + prefetched + " Forge libraries");
|
||||
|
||||
// Step 5: Run Forge Installer and show its output
|
||||
// Step 4: Run Forge Installer and show its output
|
||||
LauncherLogger.info("Running Forge Installer...");
|
||||
LauncherLogger.info("This may take a few minutes. Please wait...");
|
||||
|
||||
@@ -114,13 +105,6 @@ 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",
|
||||
@@ -191,8 +175,19 @@ public class ForgeInstaller {
|
||||
if (attempt < maxRetries) {
|
||||
LauncherLogger.warn("Install error. Retrying in 5 seconds...");
|
||||
Thread.sleep(5000);
|
||||
// Prefetch at the top of the next attempt already re-seeds any library that
|
||||
// was left partial or corrupted (SHA-1 mismatch -> delete + re-download via proxy).
|
||||
|
||||
// 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 */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LauncherLogger.error("Forge Installer exited with error code: " + exitCode);
|
||||
|
||||
|
||||
@@ -49,10 +49,6 @@ 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...");
|
||||
|
||||
|
||||
@@ -206,77 +206,6 @@ 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");
|
||||
@@ -447,7 +376,7 @@ public class JFXLauncher extends Application {
|
||||
startServer();
|
||||
|
||||
WebView webView = new WebView();
|
||||
webView.setContextMenuEnabled(true);
|
||||
webView.setContextMenuEnabled(false);
|
||||
WebEngine engine = webView.getEngine();
|
||||
engine.setJavaScriptEnabled(true);
|
||||
|
||||
@@ -508,9 +437,6 @@ 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);"
|
||||
);
|
||||
}
|
||||
@@ -519,11 +445,6 @@ 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();
|
||||
@@ -597,7 +518,6 @@ 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);
|
||||
@@ -610,12 +530,6 @@ 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);
|
||||
|
||||
@@ -647,43 +561,21 @@ 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(target.toFile());
|
||||
sendJson(exchange, Map.of("success", true, "file", target.toString()));
|
||||
Desktop.getDesktop().open(gameLogFile.toFile());
|
||||
sendJson(exchange, Map.of("success", true));
|
||||
} else {
|
||||
sendJson(exchange, Map.of("success", false, "error", "Desktop not supported"));
|
||||
}
|
||||
} else {
|
||||
sendJson(exchange, Map.of("success", false, "error", "No log file found"));
|
||||
sendJson(exchange, Map.of("success", false, "error", "No game 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());
|
||||
@@ -1259,55 +1151,14 @@ public class JFXLauncher extends Application {
|
||||
Path dir = instance.getPath();
|
||||
Map<String, Object> info = new HashMap<>();
|
||||
|
||||
List<Map<String, Object>> modsList = new ArrayList<>();
|
||||
int modsCount = 0;
|
||||
Path modsDir = dir.resolve("mods");
|
||||
if (Files.exists(modsDir)) {
|
||||
try (var files = Files.list(modsDir)) {
|
||||
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"))));
|
||||
modsCount = (int) files.filter(p -> p.toString().endsWith(".jar")).count();
|
||||
}
|
||||
}
|
||||
info.put("modsCount", modsList.size());
|
||||
info.put("mods", modsList);
|
||||
info.put("modsCount", modsCount);
|
||||
|
||||
List<String> screenshots = new ArrayList<>();
|
||||
Path screenshotsDir = dir.resolve("screenshots");
|
||||
@@ -1377,12 +1228,6 @@ 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());
|
||||
@@ -1399,8 +1244,6 @@ 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());
|
||||
@@ -1843,148 +1686,6 @@ 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)) { sendJson(exchange, Map.of("success", false, "error", "File not found")); return; }
|
||||
Path dst;
|
||||
if (enable) {
|
||||
if (file.endsWith(".disabled")) dst = modsDir.resolve(file.substring(0, file.length()-9));
|
||||
else { sendJson(exchange, Map.of("success", true)); return; }
|
||||
} else {
|
||||
if (file.endsWith(".jar")) dst = modsDir.resolve(file + ".disabled");
|
||||
else { sendJson(exchange, Map.of("success", true)); return; }
|
||||
}
|
||||
Files.move(src, dst);
|
||||
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) {
|
||||
|
||||
@@ -22,8 +22,6 @@ 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();
|
||||
@@ -63,8 +61,6 @@ 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());
|
||||
@@ -83,8 +79,6 @@ 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");
|
||||
@@ -272,9 +266,4 @@ 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(); }
|
||||
}
|
||||
|
||||
@@ -60,13 +60,6 @@ public final class DomainSelector {
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
@@ -53,10 +53,11 @@ public class LauncherLogger {
|
||||
try {
|
||||
Path logsDir = Paths.get("").toAbsolutePath().resolve("logs");
|
||||
Files.createDirectories(logsDir);
|
||||
rotateLogs(logsDir);
|
||||
logFile = logsDir.resolve("latest.log");
|
||||
logFile = logsDir.resolve("launcher.log");
|
||||
|
||||
Files.writeString(logFile, sessionHeader(), StandardOpenOption.CREATE);
|
||||
Files.writeString(logFile,
|
||||
"=== Launcher Log " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " ===\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
|
||||
System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
@@ -65,62 +66,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -51,24 +51,6 @@ public class ZHttpClient {
|
||||
|
||||
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);
|
||||
private static final AtomicBoolean healthThreadStarted = new AtomicBoolean(false);
|
||||
@@ -77,38 +59,6 @@ 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;
|
||||
}
|
||||
@@ -468,15 +418,11 @@ 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 = proxyBase + "/proxy/download?url=" + encodedUrl;
|
||||
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(proxyUrl))
|
||||
@@ -496,10 +442,8 @@ public class ZHttpClient {
|
||||
|
||||
} catch (Exception e) {
|
||||
if (attempt == maxRetries || !isRetryableError(e)) {
|
||||
throw new IOException("Failed to fetch data directly or via proxy (" + proxyBase + "): " + e.getMessage(), e);
|
||||
throw new IOException("Failed to fetch data directly or via proxy: " + 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; }
|
||||
}
|
||||
}
|
||||
@@ -508,11 +452,6 @@ 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()
|
||||
@@ -530,14 +469,12 @@ public class ZHttpClient {
|
||||
|
||||
HttpRequest request = requestBuilder.build();
|
||||
|
||||
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 45);
|
||||
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 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++;
|
||||
@@ -549,19 +486,11 @@ 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 = proxyBase + "/proxy/download?url=" + encodedUrl;
|
||||
|
||||
try {
|
||||
Files.deleteIfExists(tmp);
|
||||
} catch (IOException ignored) {}
|
||||
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(proxyUrl))
|
||||
@@ -570,45 +499,24 @@ public class ZHttpClient {
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 300);
|
||||
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 300);
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("Proxy download failed: HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
moveIntoPlace(tmp, target);
|
||||
proxySuccessCount++;
|
||||
return;
|
||||
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
Files.deleteIfExists(tmp);
|
||||
} catch (IOException ignored) {}
|
||||
if (attempt == maxRetries || !isRetryableError(e)) {
|
||||
throw new IOException("Proxy download failed (" + proxyBase + "): " + e.getMessage(), e);
|
||||
throw new IOException("Proxy download failed: " + 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
|
||||
@@ -809,83 +717,6 @@ public class ZHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -141,30 +141,24 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section section-collapsible" id="server-packs-section">
|
||||
<div class="section-header" id="server-packs-header" title="Toggle">
|
||||
<span class="section-title-wrap">
|
||||
<svg class="section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
|
||||
</span>
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
|
||||
<button class="btn-icon" id="add-server-pack-btn" title="Install server pack">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pack-list-collapse"><div id="server-packs-list" class="pack-list"></div></div>
|
||||
<div id="server-packs-list" class="pack-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section section-collapsible" id="local-packs-section">
|
||||
<div class="section-header" id="local-packs-header" title="Toggle">
|
||||
<span class="section-title-wrap">
|
||||
<svg class="section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
|
||||
</span>
|
||||
<div class="sidebar-section" id="local-packs-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
|
||||
<button class="btn-icon" id="add-pack-btn" title="Add pack">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pack-list-collapse"><div id="local-packs-list" class="pack-list"></div></div>
|
||||
<div id="local-packs-list" class="pack-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -245,16 +239,6 @@
|
||||
<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>
|
||||
|
||||
@@ -529,15 +513,6 @@
|
||||
<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>
|
||||
@@ -569,7 +544,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.openLogFile()"><span data-i18n="logViewer.openFile">Open File</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="modal-close" id="close-log-viewer-btn" onclick="app.closeLogViewer()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -589,7 +564,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></button>
|
||||
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span> <span class="tag-wip">WIP</span></button>
|
||||
</div>
|
||||
|
||||
<div id="tab-zernmc" class="modal-tab-content active">
|
||||
@@ -609,28 +584,11 @@
|
||||
</div>
|
||||
|
||||
<div id="tab-custom" class="modal-tab-content">
|
||||
<div class="field">
|
||||
<label data-i18n="install.custom.name">Pack Name</label>
|
||||
<input type="text" id="custom-instance-name" placeholder="MyPack" maxlength="32">
|
||||
<div class="disabled-tab">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<h3 data-i18n="install.custom.unavailable">Not available yet</h3>
|
||||
<p data-i18n="install.custom.desc">Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.</p>
|
||||
</div>
|
||||
<div 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">
|
||||
|
||||
@@ -58,18 +58,12 @@ const LOCALES = {
|
||||
'logViewer.noLogs': 'No logs yet. Launch the game to see logs here.',
|
||||
'logViewer.copy': 'Copy', 'logViewer.copied': 'Copied!',
|
||||
'logViewer.openFile': 'Open File', 'logViewer.close': 'Close',
|
||||
'logViewer.opened': 'File opened',
|
||||
'logViewer.openFailed': 'Could not open the log file',
|
||||
'logViewer.waiting': 'Waiting for game output...',
|
||||
'install.title': 'Install Pack',
|
||||
'install.tab.serverPack': 'Server Pack', 'install.tab.custom': 'Custom',
|
||||
'install.serverPack.label': 'Server Pack',
|
||||
'install.localName.label': 'Local Name',
|
||||
'install.downloadBtn': 'Download & Install',
|
||||
'install.custom.name': 'Pack Name',
|
||||
'install.custom.mcVersion': 'Minecraft Version',
|
||||
'install.custom.loader': 'Loader',
|
||||
'install.custom.loaderVersion': 'Loader Version',
|
||||
'install.custom.unavailable': 'Not available yet',
|
||||
'install.custom.desc': 'Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.',
|
||||
'install.progress.installing': 'Installing...',
|
||||
@@ -168,18 +162,6 @@ const LOCALES = {
|
||||
'whitelist.done': 'Mods installed!',
|
||||
'whitelist.selectNone': 'Select mods first',
|
||||
'whitelist.installError': 'Failed to install mod',
|
||||
'mods.title': 'Mods',
|
||||
'mods.loading': 'Loading mods...',
|
||||
'mods.empty': 'No mods installed',
|
||||
'mods.search': 'Search mods...',
|
||||
'mods.enabled': 'Enabled',
|
||||
'mods.disabled': 'Disabled',
|
||||
'mods.delete': 'Delete',
|
||||
'mods.enable': 'Enable',
|
||||
'mods.disable': 'Disable',
|
||||
'mods.noResults': 'No mods found',
|
||||
'mods.deleteConfirm': 'Delete {name}?',
|
||||
'mods.count': '{count} mods',
|
||||
'nav.admin': 'Admin',
|
||||
'admin.title': 'Admin Panel',
|
||||
'admin.clients': 'Clients',
|
||||
@@ -227,42 +209,6 @@ const LOCALES = {
|
||||
'install.preset.label': 'Preset (optional)',
|
||||
'install.preset.default': 'Default',
|
||||
'pack.disabled': 'Disabled',
|
||||
'ctx.openFolder': 'Open in files',
|
||||
'ctx.settings': 'Settings',
|
||||
'ctx.delete': 'Delete',
|
||||
'ctx.deleteConfirm': 'Delete pack {name}? This will delete all files.',
|
||||
'ctx.openLogs': 'Open logs',
|
||||
'ctx.name': 'Name',
|
||||
'ctx.loader': 'Loader',
|
||||
'ctx.loaderVersion': 'Loader version',
|
||||
'ctx.save': 'Save',
|
||||
'ctx.cancel': 'Cancel',
|
||||
'ctx.filemanager': 'File manager',
|
||||
'onboard.title.install': 'Where to install packs',
|
||||
'onboard.desc.install': 'Click + in sidebar or Install pack — choose server or custom.',
|
||||
'onboard.title.pass': 'Where to activate pass',
|
||||
'onboard.desc.pass': 'Pass unlocks server packs. Activate in Settings → Activate pass.',
|
||||
'onboard.title.settings': 'Where settings are',
|
||||
'onboard.desc.settings': 'Gear at bottom — RAM, resolution, JVM, Java, language, network.',
|
||||
'onboard.title.manage': 'How to manage a pack',
|
||||
'onboard.desc.manage': 'Click to open card. Right-click for Open in files / Settings / Delete. Card has Play, Update, delete.',
|
||||
'onboard.title.mods': 'Mods in pack',
|
||||
'onboard.desc.mods': 'Server packs: toggle only additional mods. Custom: toggle/delete any mods, search on top.',
|
||||
'onboard.title.logs': 'Where game logs are',
|
||||
'onboard.desc.logs': 'Settings → Game Log — live output. Or right-click → Open in files → logs folder.',
|
||||
'onboard.next': 'Next',
|
||||
'onboard.back': 'Back',
|
||||
'onboard.skip': 'Skip',
|
||||
'onboard.done': 'Got it',
|
||||
'onboard.progress': 'Step {current} of {total}',
|
||||
'onboard.showAgain': 'Show tutorial again',
|
||||
'onboard.neverShow': "Don't show again",
|
||||
'onboard.confirmTitle': 'Skip tutorial?',
|
||||
'onboard.confirmDesc': 'Are you sure you want to skip the tutorial?',
|
||||
'onboard.yes': 'Yes',
|
||||
'onboard.no': 'No',
|
||||
'onboard.infoTitle': 'Tutorial',
|
||||
'onboard.infoDesc': 'You can replay the tutorial in Settings.',
|
||||
},
|
||||
ru: {
|
||||
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
|
||||
@@ -321,18 +267,12 @@ const LOCALES = {
|
||||
'logViewer.noLogs': 'Логов пока нет. Запустите игру, чтобы увидеть логи.',
|
||||
'logViewer.copy': 'Копировать', 'logViewer.copied': 'Скопировано!',
|
||||
'logViewer.openFile': 'Открыть файл', 'logViewer.close': 'Закрыть',
|
||||
'logViewer.opened': 'Файл открыт',
|
||||
'logViewer.openFailed': 'Не удалось открыть файл логов',
|
||||
'logViewer.waiting': 'Ожидание вывода игры...',
|
||||
'install.title': 'Установить сборку',
|
||||
'install.tab.serverPack': 'Серверная сборка', 'install.tab.custom': 'Своя',
|
||||
'install.serverPack.label': 'Серверная сборка',
|
||||
'install.localName.label': 'Локальное имя',
|
||||
'install.downloadBtn': 'Скачать и установить',
|
||||
'install.custom.name': 'Название сборки',
|
||||
'install.custom.mcVersion': 'Версия Minecraft',
|
||||
'install.custom.loader': 'Загрузчик',
|
||||
'install.custom.loaderVersion': 'Версия загрузчика',
|
||||
'install.custom.unavailable': 'Пока недоступно',
|
||||
'install.custom.desc': 'Установка своей сборки отключена в этой версии. Используйте вкладку "Серверная сборка".',
|
||||
'install.progress.installing': 'Установка...',
|
||||
@@ -431,18 +371,6 @@ const LOCALES = {
|
||||
'whitelist.done': 'Моды установлены!',
|
||||
'whitelist.selectNone': 'Выберите моды',
|
||||
'whitelist.installError': 'Ошибка установки мода',
|
||||
'mods.title': 'Моды',
|
||||
'mods.loading': 'Загрузка модов...',
|
||||
'mods.empty': 'Модов не установлено',
|
||||
'mods.search': 'Поиск модов...',
|
||||
'mods.enabled': 'Включен',
|
||||
'mods.disabled': 'Отключен',
|
||||
'mods.delete': 'Удалить',
|
||||
'mods.enable': 'Включить',
|
||||
'mods.disable': 'Выключить',
|
||||
'mods.noResults': 'Ничего не найдено',
|
||||
'mods.deleteConfirm': 'Удалить {name}?',
|
||||
'mods.count': '{count} модов',
|
||||
'nav.admin': 'Админка',
|
||||
'admin.title': 'Панель администратора',
|
||||
'admin.clients': 'Клиенты',
|
||||
@@ -490,42 +418,6 @@ const LOCALES = {
|
||||
'install.preset.label': 'Пресет (опционально)',
|
||||
'install.preset.default': 'Стандартный',
|
||||
'pack.disabled': 'Отключена',
|
||||
'ctx.openFolder': 'Открыть в файлах',
|
||||
'ctx.settings': 'Настройки',
|
||||
'ctx.delete': 'Удалить',
|
||||
'ctx.deleteConfirm': 'Удалить сборку {name}? Это удалит все файлы.',
|
||||
'ctx.openLogs': 'Открыть логи',
|
||||
'ctx.name': 'Название',
|
||||
'ctx.loader': 'Загрузчик',
|
||||
'ctx.loaderVersion': 'Версия загрузчика',
|
||||
'ctx.save': 'Сохранить',
|
||||
'ctx.cancel': 'Отмена',
|
||||
'ctx.filemanager': 'Файловый менеджер',
|
||||
'onboard.title.install': 'Где устанавливать сборки',
|
||||
'onboard.desc.install': 'Нажми + в боковой панели или кнопку «Установить сборку» — выбери серверную или свою кастомную.',
|
||||
'onboard.title.pass': 'Где активировать проходку',
|
||||
'onboard.desc.pass': 'Проходка даёт доступ к серверным сборкам. Активируй её в Настройках → Активировать проходку или через серверные сборки.',
|
||||
'onboard.title.settings': 'Где настройки',
|
||||
'onboard.desc.settings': 'Шестерёнка внизу — память, разрешение, JVM, Java, язык, сеть. Настрой под своё железо.',
|
||||
'onboard.title.manage': 'Как управлять сборкой',
|
||||
'onboard.desc.manage': 'Клик — открыть карточку. ПКМ — открыть в файлах / настройки / удалить. В карточке — Играть, Обновить, удалить, описание.',
|
||||
'onboard.title.mods': 'Моды в сборке',
|
||||
'onboard.desc.mods': 'В серверных — управляй только дополнительными модами. В своих — включай/выключай и удаляй любые моды, поиск сверху.',
|
||||
'onboard.title.logs': 'Где логи игры',
|
||||
'onboard.desc.logs': 'Настройки → Лог игры — смотри вывод в реальном времени. Или ПКМ → Открыть в файлах → папка logs.',
|
||||
'onboard.next': 'Далее',
|
||||
'onboard.back': 'Назад',
|
||||
'onboard.skip': 'Пропустить',
|
||||
'onboard.done': 'Понятно',
|
||||
'onboard.progress': 'Шаг {current} из {total}',
|
||||
'onboard.showAgain': 'Показать обучение снова',
|
||||
'onboard.neverShow': 'Не показывать снова',
|
||||
'onboard.confirmTitle': 'Точно пропустить туториал?',
|
||||
'onboard.confirmDesc': 'Вы уверены, что хотите пропустить обучение?',
|
||||
'onboard.yes': 'Да',
|
||||
'onboard.no': 'Нет',
|
||||
'onboard.infoTitle': 'Туториал',
|
||||
'onboard.infoDesc': 'Туториал можно будет пройти в настройках.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -568,7 +460,6 @@ class ZernMCLauncher {
|
||||
this._playtimeAccumulated = 0;
|
||||
this._playtimeStartTime = null;
|
||||
this._currentPlaytimePack = null;
|
||||
this._openSection = undefined;
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -583,163 +474,52 @@ class ZernMCLauncher {
|
||||
_localeData = LOCALES[lang] || LOCALES.en;
|
||||
applyLocale();
|
||||
var sel = document.getElementById('locale-select');
|
||||
if (sel) {
|
||||
sel.value = lang;
|
||||
if (sel.dataset.enhanced) this.refreshCustomSelect(sel);
|
||||
}
|
||||
if (sel) sel.value = lang;
|
||||
}
|
||||
|
||||
// ==================== BACKGROUND — hollow diamonds ====================
|
||||
// ==================== BACKGROUND ====================
|
||||
initBg() {
|
||||
const c = document.getElementById('bg-canvas');
|
||||
const ctx = c.getContext('2d');
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
let mode = 'login'; // login=blue, register=orange
|
||||
let modeLerp = 0; // 0=login, 1=register
|
||||
let exitProgress = 0; // 0..1 fly-out
|
||||
let exitActive = false;
|
||||
let rafId = 0;
|
||||
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
let t = 0, mode = 'login', modePulse = 0;
|
||||
|
||||
const PALETTES = {
|
||||
login: {
|
||||
far: { r: 59, g: 130, b: 246, a: 0.18 },
|
||||
mid: { r: 59, g: 130, b: 246, a: 0.32 },
|
||||
near: { r: 96, g: 165, b: 255, a: 0.42 }
|
||||
},
|
||||
register: {
|
||||
far: { r: 249, g: 115, b: 22, a: 0.18 },
|
||||
mid: { r: 249, g: 115, b: 22, a: 0.34 },
|
||||
near: { r: 255, g: 162, b: 77, a: 0.44 }
|
||||
}
|
||||
};
|
||||
this.setWaveMode = m => { mode = m; modePulse = 1; };
|
||||
|
||||
const diamonds = [];
|
||||
const COUNT = 44;
|
||||
const seed = () => Math.random();
|
||||
const lerp = (a,b,t) => a + (b-a)*t;
|
||||
const lerpColor = (ca, cb, t) => ({
|
||||
r: lerp(ca.r, cb.r, t),
|
||||
g: lerp(ca.g, cb.g, t),
|
||||
b: lerp(ca.b, cb.b, t),
|
||||
a: lerp(ca.a, cb.a, t)
|
||||
});
|
||||
|
||||
const rebuild = () => {
|
||||
diamonds.length = 0;
|
||||
const w = c.width / dpr, h = c.height / dpr;
|
||||
// distribute with jitter, keep margins so diamonds not clipped
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const layerRoll = seed();
|
||||
let layer, size;
|
||||
if (layerRoll < 0.35) { layer = 'far'; size = 12 + seed()*6; }
|
||||
else if (layerRoll < 0.70) { layer = 'mid'; size = 22 + seed()*10; }
|
||||
else { layer = 'near'; size = 36 + seed()*16; }
|
||||
// avoid center where login form sits (roughly 360x420 centered)
|
||||
let x, y, attempts = 0;
|
||||
do {
|
||||
x = seed() * (w - size*1.4) + size*0.7;
|
||||
y = seed() * (h - size*1.4) + size*0.7;
|
||||
attempts++;
|
||||
} while (attempts < 5 && Math.abs(x - w/2) < 190 && Math.abs(y - h/2) < 220);
|
||||
diamonds.push({
|
||||
x, y, ox: x, oy: y,
|
||||
size,
|
||||
layer,
|
||||
rot: 45 * Math.PI/180,
|
||||
driftX: (seed()-0.5)*0.6,
|
||||
driftY: 0.15 + seed()*0.45,
|
||||
phase: seed()*Math.PI*2,
|
||||
depthMul: layer==='far'?0.15 : layer==='mid'?0.45 : 1.0
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
c.width = w * dpr; c.height = h * dpr;
|
||||
c.style.width = w + 'px'; c.style.height = h + 'px';
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||
rebuild();
|
||||
};
|
||||
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; };
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// public API
|
||||
this.setDiamondMode = (m) => { mode = m==='register'?'register':'login'; };
|
||||
// keep compat
|
||||
this.setWaveMode = this.setDiamondMode;
|
||||
|
||||
this.animateDiamondExit = () => {
|
||||
if (reduceMotion || exitActive) return Promise.resolve();
|
||||
exitActive = true;
|
||||
exitProgress = 0;
|
||||
return new Promise(res => {
|
||||
const start = performance.now();
|
||||
const dur = 900;
|
||||
const ease = t => t<0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2; // easeInOutCubic
|
||||
const step = (now) => {
|
||||
const p = Math.min(1, (now - start)/dur);
|
||||
exitProgress = ease(p);
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
else { exitActive = false; res(); }
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
};
|
||||
|
||||
let tick = 0;
|
||||
const draw = () => {
|
||||
const w = c.width / dpr, h = c.height / dpr;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// lerp palette between modes
|
||||
const target = mode === 'register' ? 1 : 0;
|
||||
modeLerp += (target - modeLerp) * 0.06;
|
||||
const palFar = lerpColor(PALETTES.login.far, PALETTES.register.far, modeLerp);
|
||||
const palMid = lerpColor(PALETTES.login.mid, PALETTES.register.mid, modeLerp);
|
||||
const palNear = lerpColor(PALETTES.login.near, PALETTES.register.near, modeLerp);
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
const w = c.width, h = c.height;
|
||||
modePulse += (0 - modePulse) * 0.03;
|
||||
|
||||
// exit parallax offset (vh in px)
|
||||
const farExit = reduceMotion ? 0 : exitProgress * (h*0.40);
|
||||
const midExit = reduceMotion ? 0 : exitProgress * (h*0.70);
|
||||
const nearExit = reduceMotion ? 0 : exitProgress * (h*1.10);
|
||||
const baseAlpha = 0.04 + modePulse * 0.03;
|
||||
const amp = 20 + modePulse * 10;
|
||||
const freq = 0.008 + modePulse * 0.003;
|
||||
const speed = 0.008;
|
||||
const lines = 5;
|
||||
|
||||
for (const d of diamonds) {
|
||||
const pal = d.layer==='far'? palFar : d.layer==='mid'? palMid : palNear;
|
||||
// idle drift
|
||||
const t = tick * 0.001 + d.phase;
|
||||
const dx = Math.sin(t * 0.6 + d.ox*0.01) * d.driftX;
|
||||
const dyBase = reduceMotion ? 0 : (tick * 0.015 * d.depthMul * d.driftY);
|
||||
// exit offset + breathing scale
|
||||
const exitOff = d.layer==='far'? farExit : d.layer==='mid'? midExit : nearExit;
|
||||
const breath = reduceMotion ? 1 : 1 + Math.sin(t*0.8)*0.03;
|
||||
const x = d.ox + dx;
|
||||
let y = (d.oy + dyBase - exitOff) % (h + d.size*2);
|
||||
if (y < -d.size) y += h + d.size*2;
|
||||
const s = d.size * breath * (exitActive && d.layer==='near' ? (1 + exitProgress*0.06) : 1);
|
||||
const alpha = pal.a * (exitActive ? (1 - exitProgress*0.7) : 1);
|
||||
if (alpha < 0.02) continue;
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(d.rot);
|
||||
ctx.strokeStyle = `rgba(${Math.round(pal.r)},${Math.round(pal.g)},${Math.round(pal.b)},${alpha})`;
|
||||
ctx.lineWidth = d.layer==='far'? 1 : d.layer==='mid'? 1.1 : 1.4;
|
||||
// hollow diamond: centered square
|
||||
ctx.strokeRect(-s/2, -s/2, s, s);
|
||||
ctx.restore();
|
||||
for (let i = 0; i < lines; i++) {
|
||||
const yOff = (h / (lines + 1)) * (i + 1);
|
||||
const alpha = baseAlpha * (1 - i * 0.12);
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x <= w; x += 2) {
|
||||
const wave = Math.sin(x * freq + t * speed + i * 1.8) * amp;
|
||||
const wave2 = Math.sin(x * freq * 0.5 + t * speed * 0.7 + i * 2.5) * amp * 0.5;
|
||||
const y = yOff + wave + wave2;
|
||||
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = `rgba(233, 69, 96, ${alpha})`;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
tick += 16;
|
||||
rafId = requestAnimationFrame(draw);
|
||||
t++;
|
||||
};
|
||||
|
||||
// pause when hidden
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) cancelAnimationFrame(rafId);
|
||||
else draw();
|
||||
});
|
||||
|
||||
const anim = () => { draw(); requestAnimationFrame(anim); };
|
||||
resize();
|
||||
draw();
|
||||
anim();
|
||||
}
|
||||
|
||||
// ==================== API ====================
|
||||
@@ -843,22 +623,6 @@ class ZernMCLauncher {
|
||||
|
||||
if (r.success) {
|
||||
this.state.account = r.data;
|
||||
// hollow diamond exit — parallax fly-out
|
||||
if (this.animateDiamondExit) await this.animateDiamondExit();
|
||||
const loginScreen = document.getElementById('login-screen');
|
||||
const loginContainer = document.getElementById('login-container');
|
||||
if (loginContainer && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
loginContainer.style.transition = 'transform 0.9s cubic-bezier(0.16,1,0.3,1), opacity 0.9s ease, filter 0.9s ease';
|
||||
loginContainer.style.transform = 'translateY(-110vh) scale(0.92)';
|
||||
loginContainer.style.opacity = '0';
|
||||
loginContainer.style.filter = 'blur(6px)';
|
||||
await new Promise(res => setTimeout(res, 900));
|
||||
loginContainer.style.transition = '';
|
||||
loginContainer.style.transform = '';
|
||||
loginContainer.style.opacity = '';
|
||||
loginContainer.style.filter = '';
|
||||
}
|
||||
if (loginScreen) loginScreen.classList.add('hidden');
|
||||
this.enterMain();
|
||||
const key = this._registerMode ? 'toast.accountCreated' : 'toast.welcome';
|
||||
this.toast(tr(key, null, {username: r.data.username}), 'success');
|
||||
@@ -867,21 +631,6 @@ class ZernMCLauncher {
|
||||
var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
|
||||
if (reg.success) {
|
||||
this.state.account = reg.data;
|
||||
if (this.animateDiamondExit) await this.animateDiamondExit();
|
||||
const loginScreen2 = document.getElementById('login-screen');
|
||||
const loginContainer2 = document.getElementById('login-container');
|
||||
if (loginContainer2 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
loginContainer2.style.transition = 'transform 0.9s cubic-bezier(0.16,1,0.3,1), opacity 0.9s ease, filter 0.9s ease';
|
||||
loginContainer2.style.transform = 'translateY(-110vh) scale(0.92)';
|
||||
loginContainer2.style.opacity = '0';
|
||||
loginContainer2.style.filter = 'blur(6px)';
|
||||
await new Promise(res => setTimeout(res, 900));
|
||||
loginContainer2.style.transition = '';
|
||||
loginContainer2.style.transform = '';
|
||||
loginContainer2.style.opacity = '';
|
||||
loginContainer2.style.filter = '';
|
||||
}
|
||||
if (loginScreen2) loginScreen2.classList.add('hidden');
|
||||
this.enterMain();
|
||||
this.toast(tr('toast.accountCreated', null, {username: reg.data.username}), 'success');
|
||||
return;
|
||||
@@ -971,8 +720,7 @@ class ZernMCLauncher {
|
||||
container.style.transform = '';
|
||||
container.style.opacity = '';
|
||||
|
||||
if (this.setDiamondMode) this.setDiamondMode(isReg ? 'register' : 'login');
|
||||
else if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
|
||||
if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
|
||||
}, 150);
|
||||
}
|
||||
|
||||
@@ -983,8 +731,6 @@ class ZernMCLauncher {
|
||||
}
|
||||
|
||||
showLogin() {
|
||||
const c = document.getElementById('login-container');
|
||||
if (c) { c.style.transition=''; c.style.transform=''; c.style.opacity=''; c.style.filter=''; }
|
||||
document.getElementById('login-screen').classList.remove('hidden');
|
||||
document.getElementById('main-screen').classList.add('hidden');
|
||||
}
|
||||
@@ -1017,7 +763,6 @@ class ZernMCLauncher {
|
||||
this.startFriendStatusHeartbeat();
|
||||
this.enhanceSelects();
|
||||
this.updateAdminNav();
|
||||
setTimeout(()=> this.checkOnboarding(), 600);
|
||||
}
|
||||
|
||||
async loadServerPacksList() {
|
||||
@@ -1148,20 +893,6 @@ class ZernMCLauncher {
|
||||
|
||||
document.getElementById('add-pack-btn').addEventListener('click', () => this.showInstallModal());
|
||||
document.getElementById('add-server-pack-btn').addEventListener('click', () => this.showInstallModal());
|
||||
|
||||
// Accordion sidebar sections: max one pack list open at a time
|
||||
const serverHeader = document.getElementById('server-packs-header');
|
||||
const localHeader = document.getElementById('local-packs-header');
|
||||
[serverHeader, localHeader].forEach(header => {
|
||||
if (!header) return;
|
||||
header.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.btn-icon')) return;
|
||||
const name = header.id === 'server-packs-header' ? 'server' : 'local';
|
||||
this._openSection = this._openSection === name ? null : name;
|
||||
this.syncOpenSections();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('close-modal-btn').addEventListener('click', () => this.hideInstallModal());
|
||||
document.querySelectorAll('.modal-tab').forEach(t => {
|
||||
t.addEventListener('click', () => this.switchInstallTab(t.dataset.tab));
|
||||
@@ -1313,13 +1044,6 @@ class ZernMCLauncher {
|
||||
return inst && inst.isServerPack && this.state.updateMap && this.state.updateMap[inst.name] === true;
|
||||
}
|
||||
|
||||
syncOpenSections() {
|
||||
const serverSection = document.getElementById('server-packs-section');
|
||||
const localSection = document.getElementById('local-packs-section');
|
||||
if (serverSection) serverSection.classList.toggle('collapsed', this._openSection !== 'server');
|
||||
if (localSection) localSection.classList.toggle('collapsed', this._openSection !== 'local');
|
||||
}
|
||||
|
||||
renderSidebar() {
|
||||
const serverList = document.getElementById('server-packs-list');
|
||||
const localList = document.getElementById('local-packs-list');
|
||||
@@ -1354,7 +1078,6 @@ class ZernMCLauncher {
|
||||
`;
|
||||
if (!isDisabled) {
|
||||
el.addEventListener('click', () => this.selectPack(inst));
|
||||
el.addEventListener('contextmenu', (e) => { e.preventDefault(); this.showCtxMenu(inst, e.clientX, e.clientY); });
|
||||
}
|
||||
targetList.appendChild(el);
|
||||
});
|
||||
@@ -1362,13 +1085,6 @@ class ZernMCLauncher {
|
||||
if (localSection && localList.children.length > 0) {
|
||||
localSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Default: open the section that has packs (server list first)
|
||||
if (this._openSection === undefined) {
|
||||
this._openSection = serverList.children.length > 0 ? 'server'
|
||||
: (localList.children.length > 0 ? 'local' : null);
|
||||
}
|
||||
this.syncOpenSections();
|
||||
}
|
||||
|
||||
selectPack(inst) {
|
||||
@@ -1510,9 +1226,6 @@ class ZernMCLauncher {
|
||||
} else {
|
||||
whitelistSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Local mods for any pack (server = readOnly except whitelist)
|
||||
this.loadLocalMods(inst.name, inst.isServerPack);
|
||||
}
|
||||
|
||||
async loadPackInfo(name) {
|
||||
@@ -1581,83 +1294,6 @@ class ZernMCLauncher {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<span data-i18n="whitelist.install">Install Selected</span>';
|
||||
this.toast(t('whitelist.done'), 'success');
|
||||
// refresh local mods
|
||||
this.loadLocalMods(instanceName, true);
|
||||
}
|
||||
|
||||
async loadLocalMods(instanceName, isServerPack) {
|
||||
const section = document.getElementById('local-mods-section');
|
||||
const listEl = document.getElementById('local-mods-list');
|
||||
const countEl = document.getElementById('mods-count');
|
||||
if (!section || !listEl) return;
|
||||
section.classList.remove('hidden');
|
||||
listEl.innerHTML = '<div class="mods-loading">' + t('mods.loading') + '</div>';
|
||||
const r = await this.req('/pack-info?name=' + encodeURIComponent(instanceName));
|
||||
if (!r.success || !r.data) {
|
||||
listEl.innerHTML = '<div class="mods-loading">' + t('mods.empty') + '</div>';
|
||||
return;
|
||||
}
|
||||
const mods = r.data.mods || [];
|
||||
// cache for filtering
|
||||
this._localModsCache = mods;
|
||||
this._localModsIsServer = !!isServerPack;
|
||||
this._localModsInstance = instanceName;
|
||||
if (countEl) countEl.textContent = tr('mods.count', null, {count: String(mods.length)});
|
||||
this.renderLocalMods(mods, isServerPack);
|
||||
}
|
||||
|
||||
renderLocalMods(mods, isServerPack) {
|
||||
const listEl = document.getElementById('local-mods-list');
|
||||
if (!listEl) return;
|
||||
if (!mods || mods.length === 0) {
|
||||
listEl.innerHTML = '<div class="mods-loading">' + t('mods.empty') + '</div>';
|
||||
return;
|
||||
}
|
||||
const q = (document.getElementById('mods-search')?.value || '').toLowerCase();
|
||||
const filtered = q ? mods.filter(m => (m.displayName||m.fileName||'').toLowerCase().includes(q) || (m.modId||'').toLowerCase().includes(q)) : mods;
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div class="mods-loading">' + t('mods.noResults') + '</div>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
filtered.forEach(m => {
|
||||
const sizeStr = m.size > 1048576 ? (m.size/1048576).toFixed(1)+' MB' : Math.ceil(m.size/1024)+' KB';
|
||||
const enabled = m.enabled !== false;
|
||||
const badge = enabled ? t('mods.enabled') : t('mods.disabled');
|
||||
const badgeCls = enabled ? 'mod-badge-enabled' : 'mod-badge-disabled';
|
||||
const canToggle = !isServerPack; // server: read-only for local mods
|
||||
const canDelete = !isServerPack; // server: only whitelist mods can be managed
|
||||
html += '<div class="mod-item' + (enabled?'':' mod-disabled') + '">'
|
||||
+ '<div class="mod-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 12h8M12 8v8"/></svg></div>'
|
||||
+ '<div class="mod-info"><div class="mod-name">' + this.esc(m.displayName || m.fileName) + '</div><div class="mod-meta">' + this.esc(m.modId || m.fileName) + ' · ' + this.esc(m.version || '?') + ' · ' + sizeStr + '</div></div>'
|
||||
+ '<span class="mod-badge ' + badgeCls + '">' + badge + '</span>'
|
||||
+ (canToggle ? '<button class="btn-secondary btn-sm" onclick="app.toggleLocalMod(\'' + this.esc(m.fileName).replace(/'/g,"\\'") + '\',' + !enabled + ')">' + (enabled? t('mods.disable'):t('mods.enable')) + '</button>' : '')
|
||||
+ (canDelete ? '<button class="btn-secondary btn-sm btn-danger" onclick="app.deleteLocalMod(\'' + this.esc(m.fileName).replace(/'/g,"\\'") + '\')">' + t('mods.delete') + '</button>' : '')
|
||||
+ '</div>';
|
||||
});
|
||||
listEl.innerHTML = html;
|
||||
}
|
||||
|
||||
filterLocalMods() {
|
||||
if (!this._localModsCache) return;
|
||||
this.renderLocalMods(this._localModsCache, this._localModsIsServer);
|
||||
}
|
||||
|
||||
async toggleLocalMod(fileName, enable) {
|
||||
const inst = this._localModsInstance;
|
||||
if (!inst) return;
|
||||
const r = await this.req('/mods/toggle', { method:'POST', body: JSON.stringify({instance: inst, file: fileName, enable: enable}) });
|
||||
if (r.success) { this.toast(enable? t('mods.enabled'):t('mods.disabled'), 'success'); this.loadLocalMods(inst, this._localModsIsServer); }
|
||||
else this.toast(r.error||'Error', 'error');
|
||||
}
|
||||
|
||||
async deleteLocalMod(fileName) {
|
||||
const inst = this._localModsInstance;
|
||||
if (!inst) return;
|
||||
if (!confirm(tr('mods.deleteConfirm', null, {name: fileName}))) return;
|
||||
const r = await this.req('/mods/delete', { method:'POST', body: JSON.stringify({instance: inst, file: fileName}) });
|
||||
if (r.success) { this.toast(t('mods.delete')+': '+fileName, 'success'); this.loadLocalMods(inst, this._localModsIsServer); }
|
||||
else this.toast(r.error||'Error', 'error');
|
||||
}
|
||||
|
||||
// ==================== ADMIN ====================
|
||||
@@ -2812,7 +2448,6 @@ class ZernMCLauncher {
|
||||
openLogViewer() {
|
||||
var overlay = document.getElementById('log-viewer-overlay');
|
||||
if (!overlay) return;
|
||||
this._logRenderedLines = null;
|
||||
overlay.classList.remove('hidden');
|
||||
this._pollLogViewer();
|
||||
this._logPollInterval = setInterval(this._pollLogViewer.bind(this), 1500);
|
||||
@@ -2840,32 +2475,13 @@ class ZernMCLauncher {
|
||||
var content = document.getElementById('log-viewer-content');
|
||||
if (!content) return;
|
||||
if (!raw || !raw.trim()) {
|
||||
this._logRenderedLines = null;
|
||||
content.innerHTML = '<div class="log-line log-empty">' + t('logViewer.noLogs') + '</div>';
|
||||
return;
|
||||
}
|
||||
var lines = raw.split('\n').filter(function(l) { return l.trim(); });
|
||||
var cleaned = lines.map(function(l) { return l.replace(/§[0-9a-fklmnor]/g, ''); });
|
||||
|
||||
var stick = content.scrollTop + content.clientHeight >= content.scrollHeight - 40;
|
||||
|
||||
var prev = this._logRenderedLines;
|
||||
var appendFrom = 0;
|
||||
if (prev && prev.length > 0 && lines.length >= prev.length) {
|
||||
var same = true;
|
||||
for (var i = 0; i < prev.length; i++) {
|
||||
if (cleaned[i] !== prev[i]) { same = false; break; }
|
||||
}
|
||||
if (same) appendFrom = prev.length;
|
||||
}
|
||||
|
||||
this._logRenderedLines = cleaned;
|
||||
if (appendFrom > 0) {
|
||||
content.insertAdjacentHTML('beforeend', lines.slice(appendFrom).map(this._renderLogLine.bind(this)).join(''));
|
||||
} else {
|
||||
content.innerHTML = lines.map(this._renderLogLine.bind(this)).join('');
|
||||
}
|
||||
if (stick) content.scrollTop = content.scrollHeight;
|
||||
var html = lines.map(this._renderLogLine.bind(this)).join('');
|
||||
content.innerHTML = html;
|
||||
content.scrollTop = content.scrollHeight;
|
||||
}
|
||||
|
||||
_renderLogLine(line) {
|
||||
@@ -2879,18 +2495,10 @@ class ZernMCLauncher {
|
||||
return '<div class="' + cls + '">' + this.esc(text) + '</div>';
|
||||
}
|
||||
|
||||
async copyLogs() {
|
||||
copyLogs() {
|
||||
var content = document.getElementById('log-viewer-content');
|
||||
if (!content) return;
|
||||
var text = content.textContent;
|
||||
try {
|
||||
var r = await this.req('/clipboard', { method: 'POST', body: JSON.stringify({ text: text }) });
|
||||
if (r && r.success) {
|
||||
this.toast(t('logViewer.copied'), 'success');
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
// Fallback for plain-browser use (outside the launcher)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
app.toast(t('logViewer.copied'), 'success');
|
||||
@@ -2910,326 +2518,12 @@ class ZernMCLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
async openLogFile() {
|
||||
try {
|
||||
var r = await this.req('/open-log-file', { method: 'POST' });
|
||||
if (r && r.success) {
|
||||
this.toast(t('logViewer.opened'), 'success');
|
||||
} else {
|
||||
this.toast((r && r.error) || t('logViewer.openFailed'), 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
this.toast(t('logViewer.openFailed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
replayOnboarding() {
|
||||
localStorage.removeItem('zern_onboardBlocks');
|
||||
localStorage.removeItem('zern_onboardVersion');
|
||||
// force show all blocks
|
||||
const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
|
||||
const BLOCKS = [
|
||||
{id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
|
||||
{id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
|
||||
{id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
|
||||
{id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
|
||||
{id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
|
||||
{id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
|
||||
];
|
||||
this.showOnboarding(BLOCKS, curVer, []);
|
||||
}
|
||||
|
||||
esc(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ==================== CTX MENU (PKM) ====================
|
||||
async showCtxMenu(inst, x, y) {
|
||||
this.hideCtxMenu();
|
||||
const isServer = !!inst.isServerPack;
|
||||
// fetch fm name once
|
||||
let fmName = this._fmName;
|
||||
if (!fmName) {
|
||||
try { const r = await this.req('/filemanager'); if (r.success) fmName = r.data?.name || 'File Manager'; } catch(e) {}
|
||||
this._fmName = fmName || 'File Manager';
|
||||
fmName = this._fmName;
|
||||
}
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'ctx-menu';
|
||||
menu.className = 'ctx-menu';
|
||||
menu.innerHTML = `
|
||||
<button class="ctx-item" data-act="open"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> ${t('ctx.openFolder')} <span style="margin-left:auto;opacity:0.5;font-size:11px">${this.esc(fmName)}</span></button>
|
||||
<button class="ctx-item" data-act="settings"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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> ${t('ctx.settings')}</button>
|
||||
<div class="ctx-sep"></div>
|
||||
<button class="ctx-item danger" data-act="delete"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> ${t('ctx.delete')}</button>
|
||||
`;
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.id = 'ctx-backdrop';
|
||||
backdrop.className = 'ctx-backdrop';
|
||||
backdrop.addEventListener('click', () => this.hideCtxMenu());
|
||||
backdrop.addEventListener('contextmenu', (e)=>{ e.preventDefault(); this.hideCtxMenu(); });
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.appendChild(menu);
|
||||
// position with flip
|
||||
const pad = 8;
|
||||
let mx = x, my = y;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
if (mx + rect.width + pad > window.innerWidth) mx = window.innerWidth - rect.width - pad;
|
||||
if (my + rect.height + pad > window.innerHeight) my = window.innerHeight - rect.height - pad;
|
||||
menu.style.left = mx + 'px';
|
||||
menu.style.top = my + 'px';
|
||||
menu.querySelector('[data-act="open"]').addEventListener('click', () => { this.hideCtxMenu(); this.openPackFolder(inst.name); });
|
||||
menu.querySelector('[data-act="settings"]').addEventListener('click', () => { this.hideCtxMenu(); this.openPackSettings(inst); });
|
||||
menu.querySelector('[data-act="delete"]').addEventListener('click', () => { this.hideCtxMenu(); this.deletePack(inst.name); });
|
||||
const onKey = (e)=>{ if(e.key==='Escape') this.hideCtxMenu(); };
|
||||
document.addEventListener('keydown', onKey, {once:true});
|
||||
this._ctxKeyHandler = onKey;
|
||||
}
|
||||
hideCtxMenu() {
|
||||
document.getElementById('ctx-menu')?.remove();
|
||||
document.getElementById('ctx-backdrop')?.remove();
|
||||
if (this._ctxKeyHandler) { document.removeEventListener('keydown', this._ctxKeyHandler); this._ctxKeyHandler=null; }
|
||||
}
|
||||
async openPackFolder(name) {
|
||||
const r = await this.req('/pack/open-folder', { method:'POST', body: JSON.stringify({name}) });
|
||||
if (!r.success) this.toast(r.error||'Failed to open folder','error');
|
||||
}
|
||||
openPackSettings(inst) {
|
||||
const isServer = !!inst.isServerPack;
|
||||
let modal = document.getElementById('pack-settings-modal');
|
||||
if (modal) modal.remove();
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'pack-settings-modal';
|
||||
modal.className = 'modal-backdrop';
|
||||
modal.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-head"><h3>${t('ctx.settings')} — ${this.esc(inst.name)}</h3><button class="modal-close" id="ps-close">×</button></div>
|
||||
<div class="modal-body pack-settings-form">
|
||||
<div class="field"><label>${t('ctx.name')}</label><input type="text" id="ps-name" value="${this.esc(inst.name)}" ${isServer?'disabled':''}></div>
|
||||
<div class="field"><label>${t('ctx.loader')}</label>
|
||||
<select id="ps-loader" ${isServer?'disabled':''}>
|
||||
<option value="vanilla" ${inst.loaderType==='vanilla'?'selected':''}>Vanilla</option>
|
||||
<option value="fabric" ${inst.loaderType==='fabric'?'selected':''}>Fabric</option>
|
||||
<option value="forge" ${inst.loaderType==='forge'?'selected':''}>Forge</option>
|
||||
<option value="neoforge" ${inst.loaderType==='neoforge'?'selected':''}>NeoForge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field ${inst.loaderType==='vanilla'?'hidden':''}" id="ps-loader-ver-field"><label>${t('ctx.loaderVersion')}</label><input type="text" id="ps-loader-ver" value="${this.esc(inst.loaderVersion||'')}" placeholder="0.16.0" ${isServer?'disabled':''}></div>
|
||||
${isServer?'<p style="font-size:12px;color:var(--text-muted)">'+t('mods.disabled')+' — server pack is read-only</p>':''}
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px">
|
||||
<button class="btn-secondary" id="ps-cancel">${t('ctx.cancel')}</button>
|
||||
<button class="btn-primary" id="ps-save" ${isServer?'disabled':''}>${t('ctx.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
modal.addEventListener('click', e=>{ if(e.target===modal) modal.remove(); });
|
||||
document.getElementById('ps-close').addEventListener('click', ()=>modal.remove());
|
||||
document.getElementById('ps-cancel').addEventListener('click', ()=>modal.remove());
|
||||
const loaderSel = document.getElementById('ps-loader');
|
||||
loaderSel?.addEventListener('change', e=>{
|
||||
const f=document.getElementById('ps-loader-ver-field');
|
||||
if(e.target.value==='vanilla') f.classList.add('hidden'); else f.classList.remove('hidden');
|
||||
});
|
||||
document.getElementById('ps-save').addEventListener('click', async ()=>{
|
||||
const newName = document.getElementById('ps-name').value.trim();
|
||||
const loader = document.getElementById('ps-loader').value;
|
||||
const loaderVer = document.getElementById('ps-loader-ver').value.trim();
|
||||
const body = {name: inst.name};
|
||||
if (newName && newName!==inst.name) body.newName=newName;
|
||||
if (loader) body.loader=loader;
|
||||
if (loaderVer) body.loaderVersion=loaderVer;
|
||||
const r = await this.req('/pack/settings', {method:'POST', body: JSON.stringify(body)});
|
||||
if (r.success) { modal.remove(); this.toast(t('ctx.save')+' ✓','success'); await this.loadInstances(); if(newName) this.state.selectedPack=null; }
|
||||
else this.toast(r.error||'Error','error');
|
||||
});
|
||||
}
|
||||
async deletePack(name) {
|
||||
if (!confirm(tr('ctx.deleteConfirm', null, {name}))) return;
|
||||
const r = await this.req('/pack/delete', {method:'POST', body: JSON.stringify({name})});
|
||||
if (r.success) { this.toast(t('ctx.delete')+': '+name,'success'); if(this.state.selectedPack?.name===name) this.showEmptyState(); await this.loadInstances(); }
|
||||
else this.toast(r.error||'Error','error');
|
||||
}
|
||||
|
||||
// ==================== ONBOARDING ====================
|
||||
async checkOnboarding() {
|
||||
// show only on first login or version diff, highlight-only — persist via Config + localStorage
|
||||
const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
|
||||
let storedVer = localStorage.getItem('zern_onboardVersion') || '';
|
||||
let completed = (localStorage.getItem('zern_onboardBlocks') || '').split(',').filter(Boolean);
|
||||
try {
|
||||
const r = await this.req('/settings');
|
||||
if (r.success && r.data) {
|
||||
const srvVer = r.data.onboardedVersion || '';
|
||||
const srvBlocks = (r.data.completedOnboardBlocks || '').split(',').filter(Boolean);
|
||||
if (!storedVer && srvVer) { storedVer = srvVer; localStorage.setItem('zern_onboardVersion', srvVer); }
|
||||
if (completed.length===0 && srvBlocks.length) { completed = srvBlocks; localStorage.setItem('zern_onboardBlocks', srvBlocks.join(',')); }
|
||||
// if local newer, sync to server
|
||||
if (storedVer && !srvVer) { this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: storedVer, completedOnboardBlocks: completed.join(',')})}); }
|
||||
}
|
||||
} catch(e){}
|
||||
const firstLogin = !storedVer; // no onboard yet
|
||||
const BLOCKS = [
|
||||
{id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
|
||||
{id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
|
||||
{id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
|
||||
{id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
|
||||
{id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
|
||||
{id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
|
||||
];
|
||||
let toShow;
|
||||
if (firstLogin) toShow = BLOCKS;
|
||||
else {
|
||||
// version diff: show only blocks added after storedVer
|
||||
const isNewer = (a,b)=> a.localeCompare(b, undefined, {numeric:true})>0;
|
||||
toShow = BLOCKS.filter(b=> !completed.includes(b.id) && isNewer(b.addedIn, storedVer));
|
||||
if (toShow.length===0 && storedVer !== curVer) {
|
||||
// version bump but all blocks already completed -> no show
|
||||
localStorage.setItem('zern_onboardVersion', curVer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (toShow.length===0) return;
|
||||
this.showOnboarding(toShow, curVer, completed);
|
||||
}
|
||||
|
||||
showOnboarding(blocks, curVer, completed) {
|
||||
let idx = 0;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'onboard-overlay';
|
||||
overlay.id = 'onboard-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'onboard-card';
|
||||
card.id = 'onboard-card';
|
||||
document.body.appendChild(card);
|
||||
const spot = document.createElement('div');
|
||||
spot.className = 'onboard-spot';
|
||||
spot.id = 'onboard-spot';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'onboard-spot-label';
|
||||
label.id = 'onboard-spot-label';
|
||||
document.body.appendChild(spot);
|
||||
document.body.appendChild(label);
|
||||
|
||||
const resolveTarget = (b) => {
|
||||
const sels = Array.isArray(b.sel) ? b.sel : [b.sel];
|
||||
for (const s of sels) {
|
||||
for (const el of document.querySelectorAll(s)) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
const visible = r.width>0 && r.height>0 && style.display!=='none' && style.visibility!=='hidden' && el.offsetParent!==null;
|
||||
if (!visible) continue;
|
||||
if (el.closest('.hidden')) continue;
|
||||
// check collapsed accordion
|
||||
if (el.closest('.pack-list-collapse') && el.closest('.section-collapsible.collapsed')) continue;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const ensureView = async (b) => {
|
||||
if (b.requiresView==='settings' || b.id==='pass' || b.id==='logs') {
|
||||
this.switchView('settings');
|
||||
await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
|
||||
} else if (b.id==='install' || b.id==='manage' || b.id==='mods') {
|
||||
this.switchView('packs');
|
||||
await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
|
||||
}
|
||||
};
|
||||
const persist = (vers, blocksArr) => {
|
||||
const v = vers || curVer;
|
||||
const b = blocksArr.join(',');
|
||||
localStorage.setItem('zern_onboardBlocks', b);
|
||||
localStorage.setItem('zern_onboardVersion', v);
|
||||
// persist to backend Config so WebView localStorage volatility doesn't resurrect tutorial
|
||||
this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: v, completedOnboardBlocks: b})}).catch(()=>{});
|
||||
};
|
||||
const goNext = async () => {
|
||||
if (document.getElementById('onboard-never')?.checked) {
|
||||
const all = [...new Set(blocks.map(x=>x.id).concat(completed))];
|
||||
persist(curVer, all);
|
||||
closeAll(); return;
|
||||
}
|
||||
if (idx===blocks.length-1) {
|
||||
const newCompleted = [...new Set([...completed, ...blocks.map(x=>x.id)])];
|
||||
persist(curVer, newCompleted);
|
||||
closeAll();
|
||||
} else { idx++; await render(); }
|
||||
};
|
||||
const goBack = async () => { if(idx>0){ idx--; await render(); } };
|
||||
const showConfirm = (onYes) => {
|
||||
let m = document.createElement('div');
|
||||
m.className='modal-backdrop'; m.style.zIndex='410';
|
||||
m.innerHTML=`<div class="modal modal-sm"><div class="modal-head"><h3>${t('onboard.confirmTitle')||'Точно пропустить туториал?'}</h3><button class="modal-close" id="c-close">×</button></div><div class="modal-body"><p style="font-size:13px;color:var(--text-secondary)">${t('onboard.confirmDesc')||'Вы уверены?'}</p><div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px"><button class="btn-secondary btn-sm" id="c-no">${t('onboard.no')||'Нет'}</button><button class="btn-primary btn-sm" id="c-yes">${t('onboard.yes')||'Да'}</button></div></div></div>`;
|
||||
document.body.appendChild(m);
|
||||
const close=()=>m.remove();
|
||||
m.querySelector('#c-close').onclick=close;
|
||||
m.querySelector('#c-no').onclick=close;
|
||||
m.addEventListener('click', e=>{ if(e.target===m) close(); });
|
||||
m.querySelector('#c-yes').onclick=()=>{ close(); onYes(); };
|
||||
};
|
||||
const showInfo = () => {
|
||||
let m=document.createElement('div'); m.className='modal-backdrop'; m.style.zIndex='411';
|
||||
m.innerHTML=`<div class="modal modal-sm"><div class="modal-head"><h3>${t('onboard.infoTitle')||'Туториал'}</h3><button class="modal-close" id="i-close">×</button></div><div class="modal-body"><p style="font-size:13px;color:var(--text-secondary)">${t('onboard.infoDesc')||'Туториал можно будет пройти в настройках.'}</p><div style="display:flex;justify-content:flex-end;margin-top:16px"><button class="btn-primary btn-sm" id="i-ok">OK</button></div></div></div>`;
|
||||
document.body.appendChild(m);
|
||||
const close=()=>m.remove();
|
||||
m.querySelector('#i-close').onclick=close;
|
||||
m.querySelector('#i-ok').onclick=close;
|
||||
m.addEventListener('click', e=>{ if(e.target===m) close(); });
|
||||
};
|
||||
const requestClose = () => {
|
||||
showConfirm(()=>{ closeAll(); showInfo(); });
|
||||
};
|
||||
const render = async () => {
|
||||
const b = blocks[idx];
|
||||
await ensureView(b);
|
||||
card.innerHTML = `
|
||||
<div class="onboard-progress">${tr('onboard.progress', null, {current:String(idx+1), total:String(blocks.length)})}</div>
|
||||
<h3>${t(b.titleKey)}</h3>
|
||||
<p>${t(b.descKey)}</p>
|
||||
<div class="onboard-actions">
|
||||
<button class="btn-secondary btn-sm" id="onboard-skip">${t('onboard.skip')}</button>
|
||||
${idx>0?'<button class="btn-secondary btn-sm" id="onboard-back">'+t('onboard.back')+'</button>':''}
|
||||
<button class="btn-primary btn-sm" id="onboard-next">${idx===blocks.length-1? t('onboard.done'): t('onboard.next')}</button>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:6px;margin-top:12px;font-size:12px;color:var(--text-muted)"><input type="checkbox" id="onboard-never"> ${t('onboard.neverShow')}</label>
|
||||
`;
|
||||
const el = resolveTarget(b);
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
spot.style.left = (r.left-4)+'px';
|
||||
spot.style.top = (r.top-4)+'px';
|
||||
spot.style.width = (r.width+8)+'px';
|
||||
spot.style.height = (r.height+8)+'px';
|
||||
spot.style.display='block';
|
||||
label.textContent = t(b.titleKey);
|
||||
label.style.left = (r.left + r.width/2)+'px';
|
||||
label.style.top = (r.top)+'px';
|
||||
label.style.display='block';
|
||||
el.scrollIntoView({block:'center', behavior:'smooth'});
|
||||
} else {
|
||||
spot.style.display='none'; label.style.display='none';
|
||||
}
|
||||
document.getElementById('onboard-skip').addEventListener('click', requestClose);
|
||||
document.getElementById('onboard-next').addEventListener('click', goNext);
|
||||
const backBtn=document.getElementById('onboard-back');
|
||||
if(backBtn) backBtn.addEventListener('click', goBack);
|
||||
};
|
||||
const closeAll = ()=>{
|
||||
overlay.remove(); card.remove(); spot.remove(); label.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
};
|
||||
spot.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
|
||||
label.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
|
||||
const escHandler = (e)=>{ if(e.key==='Escape') requestClose(); };
|
||||
document.addEventListener('keydown', escHandler);
|
||||
overlay.addEventListener('click', (e)=>{ if(e.target===overlay) requestClose(); });
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function renderFriendItem(f) {
|
||||
|
||||
@@ -29,11 +29,6 @@
|
||||
|
||||
*, *::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 {
|
||||
@@ -47,11 +42,8 @@ body {
|
||||
|
||||
#bg-canvas {
|
||||
position: fixed; inset: 0; width: 100%; height: 100%;
|
||||
z-index: 0; opacity: 0.14; pointer-events: none;
|
||||
z-index: 0; opacity: 0.08; 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; }
|
||||
|
||||
@@ -227,38 +219,12 @@ body {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 8px; padding: 0 4px;
|
||||
}
|
||||
.section-collapsible .section-header { cursor: pointer; user-select: none; }
|
||||
.section-collapsible .section-header:hover .section-title { color: var(--text-secondary); }
|
||||
.section-title-wrap {
|
||||
display: flex; align-items: center; gap: 6px; min-width: 0;
|
||||
}
|
||||
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); transition: var(--transition); }
|
||||
.section-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform .2s var(--ease, ease); }
|
||||
.section-collapsible { display: flex; flex-direction: column; min-height: 0; }
|
||||
.section-collapsible:not(.collapsed) { flex: 1 1 auto; }
|
||||
.section-collapsible:not(.collapsed) .section-chevron { transform: rotate(0deg); }
|
||||
.section-collapsible.collapsed .section-chevron { transform: rotate(-90deg); }
|
||||
.section-collapsible.collapsed .section-header { margin-bottom: 0; }
|
||||
.section-collapsible.collapsed .section-title { color: var(--text-muted); }
|
||||
|
||||
.pack-list-collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows .3s var(--ease, ease);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-collapsible:not(.collapsed) .pack-list-collapse {
|
||||
grid-template-rows: 1fr;
|
||||
flex: 1;
|
||||
}
|
||||
.pack-list-collapse > .pack-list {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
|
||||
|
||||
.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;
|
||||
@@ -336,9 +302,6 @@ 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;
|
||||
@@ -991,45 +954,3 @@ body {
|
||||
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; }
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>1.1.1</revision>
|
||||
<hotfix>2</hotfix>
|
||||
<revision>1.0.16</revision>
|
||||
<hotfix>13</hotfix>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
@@ -12,8 +12,7 @@ import json
|
||||
import structlog
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
|
||||
# Disable httpx debug logging
|
||||
@@ -47,15 +46,6 @@ BUILDS_DIR = Path("builds")
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
||||
|
||||
# Website (landing) static files
|
||||
SITE_DIR = Path(__file__).parent / "site"
|
||||
|
||||
# SkinRestorer skins (shared with TG bot)
|
||||
SKINS_DIR = Path("/root/python/site/skins")
|
||||
|
||||
# Minecraft server status (same host the TG bot pings)
|
||||
MC_STATUS_HOST = os.environ.get("MC_STATUS_HOST", "mc.zernmc.ru")
|
||||
|
||||
# Mirror configuration
|
||||
LAUNCHER_MIRRORS = {
|
||||
"main": "https://api.zern.cc", # primary
|
||||
@@ -848,7 +838,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", "/skin/"])
|
||||
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"])
|
||||
if skip_cache:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
@@ -963,17 +953,6 @@ 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
|
||||
@@ -1011,10 +990,8 @@ HttpToolsProtocol.data_received = patched_data_received
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""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"))
|
||||
"""Root endpoint"""
|
||||
logger.info("Root endpoint accessed")
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "ZernMC Launcher Server is running",
|
||||
@@ -1023,83 +1000,6 @@ 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"""
|
||||
@@ -1798,21 +1698,6 @@ 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")
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_launcher_zip(filename: str, request: Request = None):
|
||||
"""Download specific launcher ZIP archive"""
|
||||
|
||||
@@ -159,9 +159,6 @@ 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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 107 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#e94560"/>
|
||||
<stop offset="100%" stop-color="#ff6b6b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="64" fill="url(#g)"/>
|
||||
<path d="M82 128 L128 82 L174 128 L128 174 Z" fill="white" opacity="0.9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 423 B |
@@ -1,475 +0,0 @@
|
||||
<!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>
|
||||
<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/jre" 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/latest">
|
||||
<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)">ui</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">bcd10cc</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>
|
||||
</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>
|
||||
|
||||
<script src="/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,778 +0,0 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const API = ''; // same origin (root -> launcher server)
|
||||
|
||||
const I18N = {
|
||||
ru: {
|
||||
'nav.server': 'Сервер',
|
||||
'nav.highlights': 'Хайлайты',
|
||||
'nav.season': 'Сезон',
|
||||
'nav.features': 'Возможности',
|
||||
'nav.showcase': 'Лаунчер',
|
||||
'nav.download': 'Скачать',
|
||||
'nav.news': 'Новости',
|
||||
'nav.play': 'Играть сейчас',
|
||||
'section.of': 'Раздел {n} из {m}',
|
||||
'hero.kicker': 'КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР',
|
||||
'hero.title': 'Очнись от<br><span class="hero-title-accent">холода.</span>',
|
||||
'hero.sub': 'Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.',
|
||||
'hero.download': 'Скачать лаунчер',
|
||||
'hero.explore': 'Обзор',
|
||||
'hero.scroll': 'Листай вниз',
|
||||
'server.title': 'Сервер <span class="accent">жив.</span>',
|
||||
'server.sub': 'ZernMC — это кастомный Minecraft-сервер со своим лором, самописными плагинами и живым комьюнити. Вот что происходит прямо сейчас.',
|
||||
'server.status': 'Статус',
|
||||
'server.checking': 'Проверяем…',
|
||||
'server.players': 'Игроков онлайн',
|
||||
'server.mode': 'Режим',
|
||||
'server.season': 'Сезон Prologue',
|
||||
'server.online': 'Онлайн',
|
||||
'server.offline': 'Оффлайн',
|
||||
'server.unavailable': 'Недоступно',
|
||||
'highlights.title': 'Хайлайты <span class="accent">сервера.</span>',
|
||||
'highlights.sub': 'Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.',
|
||||
'carousel.prev': 'Назад',
|
||||
'carousel.next': 'Вперёд',
|
||||
'carousel.cap1': 'Zern',
|
||||
'carousel.cap3': 'Это честно не я',
|
||||
'carousel.cap5': 'Спавн ТЦ (старое)',
|
||||
'carousel.cap6': 'Спавн склад (старое)',
|
||||
'carousel.alt': 'Хайлайт Zern',
|
||||
'features.title': 'Всё, что <span class="accent">тебе нужно.</span>',
|
||||
'features.sub': 'Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.',
|
||||
'features.forge.title': 'Forge, NeoForge, Fabric',
|
||||
'features.forge.sub': 'Все основные модлоадеры подхватываются автоматически. Библиотеки предзагружаются и проверяются, так что установка работает — даже на медленном интернете.',
|
||||
'features.ui.title': 'Тёмный, быстрый нативный UI',
|
||||
'features.ui.sub': 'Лёгкий интерфейс на JavaFX с чистым тёмным оформлением. Быстро запускается, мгновенно реагирует и не мешает.',
|
||||
'features.proxy.title': 'Умные зеркала и прокси',
|
||||
'features.proxy.sub': 'Загрузки идут через быстрые зеркала с автоматическим фолбэком. Заблокировали или зарезали скорость? Прокси подхватит — ты больше не зависнешь на 80%.',
|
||||
'features.news.title': 'Новости и статус сервера',
|
||||
'features.news.sub': 'Чейнджлоги, ивенты и обновления приходят прямо в лаунчер. Ты всегда знаешь, что нового, ещё до нажатия «Играть».',
|
||||
'features.pack.title': 'Установка сборки в один клик',
|
||||
'features.pack.sub': 'Выбери сборку и жми «Установить». Манифесты версий, ванильные ассеты и библиотеки подтягиваются и проверяются автоматически. Просто.',
|
||||
'theme.title': 'Что внутри <span class="accent">сезона.</span>',
|
||||
'theme.sub': 'Prologue — модовый сезон на базе Create: Aeronautics, война и выживание. ОБТ — полигон, где всё это обкатывается.',
|
||||
'theme.zarya.tag': 'ОБТ · 1.20.1 Forge',
|
||||
'theme.zarya.title': 'Война в Заре',
|
||||
'theme.zarya.sub': 'Двухдневный ивент в городе Заря: группировка «Феникс» атакует, «Ласточка» обороняется, а жители выбирают сторону — или поднимают бунт. Военные правила.',
|
||||
'theme.obt.tag': 'ОБТ · тестовая стадия',
|
||||
'theme.obt.title': 'Открытый бета-сервер',
|
||||
'theme.obt.sub': 'Бета — это ивентовый сервер: городские бои, сценарий атомного реактора и правила, которые лягут в основу настоящего сезона.',
|
||||
'theme.air.tag': 'Сезон · 1.21.1 NeoForge',
|
||||
'theme.air.title': 'Корабли в небе',
|
||||
'theme.air.sub': 'Create: Aeronautics даёт дирижабли, субмарины и летающие механизмы. Поезда, дизели, большие пушки и атомный реактор — осваивай.',
|
||||
'theme.year.tag': 'Сезон · Prologue',
|
||||
'theme.year.title': 'Марафон на год',
|
||||
'theme.year.sub': 'Сезон — это марафон, а не спринт. Начни эту главу с первого дня и строй её вместе с комьюнити.',
|
||||
'theme.lore.tag': 'Сервер',
|
||||
'theme.lore.title': 'Свой лор и плагины',
|
||||
'theme.lore.sub': 'Самописные плагины, ивенты под историю сервера и системы, которые из коробки работают с лаунчером.',
|
||||
'theme.community.tag': 'Комьюнити',
|
||||
'theme.community.title': 'Голос и друзья',
|
||||
'theme.community.sub': 'Simple Voice Chat в игре, система друзей в лаунчере и Telegram-комьюнити, которое реально рулит — решения принимаются голосованием игроков.',
|
||||
'download.title': 'Скачать <span class="accent">Zern.</span>',
|
||||
'download.sub': 'Скачай лаунчер для Windows и заходи на сервер ZernMC.',
|
||||
'download.latest': 'Скачать актуальную версию',
|
||||
'download.mirrors': 'Зеркала',
|
||||
'download.note': 'Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.',
|
||||
'showcase.title': 'Показываем <span class="accent">лаунчер</span>',
|
||||
'showcase.sub': 'Никаких статичных скриншотов — живой тур по интерфейсу. Нужно просто наблюдать: лаунчер сам открывает свои экраны.',
|
||||
'preview.aria': 'ZernMC Launcher preview, auto-rotating screens',
|
||||
'preview.login': 'Вход',
|
||||
'preview.packs': 'Сборки',
|
||||
'preview.news': 'Новости',
|
||||
'preview.settings': 'Настройки',
|
||||
'preview.install': 'Установка',
|
||||
'preview.signIn': 'Вход',
|
||||
'preview.username': 'Логин',
|
||||
'preview.password': 'Пароль',
|
||||
'preview.nav.packs': 'Сборки',
|
||||
'preview.nav.news': 'Новости',
|
||||
'preview.nav.friends': 'Друзья',
|
||||
'preview.nav.settings': 'Настройки',
|
||||
'preview.serverPacks': 'Серверные сборки',
|
||||
'preview.localPacks': 'Локальные сборки',
|
||||
'preview.stat.loader': 'Загрузчик',
|
||||
'preview.stat.files': 'Файлы',
|
||||
'preview.stat.size': 'Размер',
|
||||
'preview.ready': 'Готов к запуску',
|
||||
'preview.play': 'Играть',
|
||||
'preview.news.update': 'Обновление',
|
||||
'preview.news.event': 'Событие',
|
||||
'preview.news1.title': 'Сезон Prologue запущен',
|
||||
'preview.news1.body': 'Небо принадлежит дирижаблям. Добро пожаловать в Prologue — 1.21.1 NeoForge, Create: Aeronautics.',
|
||||
'preview.news2.title': 'Война в Заре',
|
||||
'preview.news2.body': '«Феникс» атакует, «Ласточка» защищает. Жители выбирают сторону — или бунт.',
|
||||
'preview.settings.ram': 'Выделено ОЗУ',
|
||||
'preview.settings.resolution': 'Разрешение',
|
||||
'preview.settings.language': 'Язык',
|
||||
'preview.settings.server': 'Сервер',
|
||||
'preview.online': 'Онлайн',
|
||||
'preview.install.title': 'Установка сборки…',
|
||||
'preview.install.stage': 'Загрузка модов 130/130 · 1.8 ГБ',
|
||||
'news.title': 'Что <span class="accent">нового.</span>',
|
||||
'news.sub': 'Последние чейнджлоги и обновления команды Zern.',
|
||||
'news.empty': 'Новостей пока нет.',
|
||||
'news.error': 'Новости временно недоступны.',
|
||||
'news.update': 'Обновление',
|
||||
'footer.note': 'Не аффилирован с Mojang или Microsoft. Minecraft — торговая марка Mojang AB.'
|
||||
},
|
||||
en: {
|
||||
'nav.server': 'Server',
|
||||
'nav.highlights': 'Highlights',
|
||||
'nav.season': 'Season',
|
||||
'nav.features': 'Features',
|
||||
'nav.showcase': 'Launcher',
|
||||
'nav.download': 'Download',
|
||||
'nav.news': 'News',
|
||||
'nav.play': 'Play now',
|
||||
'section.of': 'Section {n} of {m}',
|
||||
'hero.kicker': 'CUSTOM MINECRAFT LAUNCHER',
|
||||
'hero.title': 'Awaken from<br><span class="hero-title-accent">the cold.</span>',
|
||||
'hero.sub': 'Zern brings your favourite modpacks to life. One launcher, every loader, no hassle — built for the ZernMC server and beyond.',
|
||||
'hero.download': 'Download launcher',
|
||||
'hero.explore': 'Explore',
|
||||
'hero.scroll': 'Scroll to explore',
|
||||
'server.title': 'The server is <span class="accent">alive.</span>',
|
||||
'server.sub': 'ZernMC is a custom Minecraft server with its own lore, hand-written plugins and a living community. This is what\'s happening right now.',
|
||||
'server.status': 'Status',
|
||||
'server.checking': 'Checking…',
|
||||
'server.players': 'Players online',
|
||||
'server.mode': 'Mode',
|
||||
'server.season': 'Season Prologue',
|
||||
'server.online': 'Online',
|
||||
'server.offline': 'Offline',
|
||||
'server.unavailable': 'Unavailable',
|
||||
'highlights.title': 'Server <span class="accent">highlights.</span>',
|
||||
'highlights.sub': 'Moments captured on the ZernMC server — wars, builds and chaos.',
|
||||
'carousel.prev': 'Previous',
|
||||
'carousel.next': 'Next',
|
||||
'carousel.cap1': 'Zern',
|
||||
'carousel.cap3': 'This is honestly not me',
|
||||
'carousel.cap5': 'Spawn mall (old)',
|
||||
'carousel.cap6': 'Spawn storage (old)',
|
||||
'carousel.alt': 'Zern highlight',
|
||||
'features.title': 'Everything <span class="accent">you need.</span>',
|
||||
'features.sub': 'A launcher that does the heavy lifting, so you can just play.',
|
||||
'features.forge.title': 'Forge, NeoForge, Fabric',
|
||||
'features.forge.sub': 'Every major mod loader handled automatically. Libraries are prefetched and verified, so installs just work — even on slow networks.',
|
||||
'features.ui.title': 'Dark, fast, native UI',
|
||||
'features.ui.sub': 'A lightweight JavaFX interface with a clean dark theme. Starts quick, responds instantly, and stays out of your way.',
|
||||
'features.proxy.title': 'Smart mirrors & proxy',
|
||||
'features.proxy.sub': 'Downloads route through fast mirrors with automatic failover. Blocked or throttled? The proxy takes over so you never stall at 80%.',
|
||||
'features.news.title': 'News & server status',
|
||||
'features.news.sub': 'Changelogs, events and updates stream straight into the launcher. You always know what\'s new before you hit play.',
|
||||
'features.pack.title': 'One-click pack install',
|
||||
'features.pack.sub': 'Pick a pack, hit install. Version manifests, vanilla assets and libraries are pulled and verified automatically. Simple.',
|
||||
'theme.title': 'What\'s inside <span class="accent">the season.</span>',
|
||||
'theme.sub': 'Prologue — a modded season built around Create: Aeronautics, war and survival. OBT is the testing ground for the whole idea.',
|
||||
'theme.zarya.tag': 'OBT · 1.20.1 Forge',
|
||||
'theme.zarya.title': 'War in Zarya',
|
||||
'theme.zarya.sub': 'Two-day event set in the city of Zarya: the Fenix group attacks, Lastochka defends, and civilians pick a side — or rise up. Military rules apply.',
|
||||
'theme.obt.tag': 'OBT · test stage',
|
||||
'theme.obt.title': 'Open beta server',
|
||||
'theme.obt.sub': 'The beta is an event server: city battles, an atomic-reactor city-life scenario and rule sets that will shape the real season.',
|
||||
'theme.air.tag': 'Season · 1.21.1 NeoForge',
|
||||
'theme.air.title': 'Ships in the sky',
|
||||
'theme.air.sub': 'Create: Aeronautics gives you airships, submarines and flying contraptions. Trains, diesel engines, big cannons and a nuclear reactor to master.',
|
||||
'theme.year.tag': 'Season · Prologue',
|
||||
'theme.year.title': 'A year-long run',
|
||||
'theme.year.sub': 'Each season is a marathon, not a sprint. Start this chapter from day one and shape it with the community.',
|
||||
'theme.lore.tag': 'Server',
|
||||
'theme.lore.title': 'Own lore & plugins',
|
||||
'theme.lore.sub': 'Hand-written plugins, events written for the server story, and systems the launcher integrates with out of the box.',
|
||||
'theme.community.tag': 'Community',
|
||||
'theme.community.title': 'Voice & friends',
|
||||
'theme.community.sub': 'Simple Voice Chat in-game, a friends system in the launcher and a Telegram community that runs the show — decisions are voted on by players.',
|
||||
'download.title': 'Get <span class="accent">Zern.</span>',
|
||||
'download.sub': 'Download the launcher for Windows and join the ZernMC server.',
|
||||
'download.latest': 'Download latest',
|
||||
'download.mirrors': 'Mirrors',
|
||||
'download.note': 'No paywalls, no ads. Just a launcher. System requirements: Windows 10+, 4 GB RAM.',
|
||||
'showcase.title': 'Meet the <span class="accent">launcher</span>',
|
||||
'showcase.sub': 'No static screenshots — a live tour of the UI. Just watch: the launcher shows its screens on its own.',
|
||||
'preview.aria': 'ZernMC Launcher preview, auto-rotating screens',
|
||||
'preview.login': 'Sign in',
|
||||
'preview.packs': 'Packs',
|
||||
'preview.news': 'News',
|
||||
'preview.settings': 'Settings',
|
||||
'preview.install': 'Installing',
|
||||
'preview.signIn': 'Sign In',
|
||||
'preview.username': 'Username',
|
||||
'preview.password': 'Password',
|
||||
'preview.nav.packs': 'Packs',
|
||||
'preview.nav.news': 'News',
|
||||
'preview.nav.friends': 'Friends',
|
||||
'preview.nav.settings': 'Settings',
|
||||
'preview.serverPacks': 'Server Packs',
|
||||
'preview.localPacks': 'Local Packs',
|
||||
'preview.stat.loader': 'Loader',
|
||||
'preview.stat.files': 'Files',
|
||||
'preview.stat.size': 'Size',
|
||||
'preview.ready': 'Ready to launch',
|
||||
'preview.play': 'Play',
|
||||
'preview.news.update': 'Update',
|
||||
'preview.news.event': 'Event',
|
||||
'preview.news1.title': 'Season Prologue has launched',
|
||||
'preview.news1.body': 'The sky belongs to airships. Welcome to Prologue — 1.21.1 NeoForge, Create: Aeronautics.',
|
||||
'preview.news2.title': 'War in Zarya',
|
||||
'preview.news2.body': 'Fenix attacks, Lastochka defends. Citizens pick a side — or rise up.',
|
||||
'preview.settings.ram': 'Allocated RAM',
|
||||
'preview.settings.resolution': 'Resolution',
|
||||
'preview.settings.language': 'Language',
|
||||
'preview.settings.server': 'Server',
|
||||
'preview.online': 'Online',
|
||||
'preview.install.title': 'Installing pack…',
|
||||
'preview.install.stage': 'Downloading mods 130/130 · 1.8 GB',
|
||||
'news.title': 'What\'s <span class="accent">new.</span>',
|
||||
'news.sub': 'Latest changelogs and updates from the Zern team.',
|
||||
'news.empty': 'No news yet.',
|
||||
'news.error': 'News is temporarily unavailable.',
|
||||
'news.update': 'Update',
|
||||
'footer.note': 'Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.'
|
||||
}
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'zern_lang';
|
||||
|
||||
function getLang() {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'ru' || saved === 'en') return saved;
|
||||
} catch (e) { /* ignore */ }
|
||||
return 'ru'; // default: Russian
|
||||
}
|
||||
|
||||
let currentLang = getLang();
|
||||
|
||||
function tr(key) {
|
||||
const dict = I18N[currentLang] || I18N.ru;
|
||||
return dict[key] != null ? dict[key] : key;
|
||||
}
|
||||
|
||||
function applyI18n() {
|
||||
document.documentElement.lang = currentLang;
|
||||
|
||||
const toggle = document.getElementById('langToggleLabel');
|
||||
if (toggle) toggle.textContent = currentLang === 'ru' ? 'RU' : 'EN';
|
||||
|
||||
// Section indices: add a tooltip explaining what "02 / 07" means.
|
||||
document.querySelectorAll('.section-index').forEach((el) => {
|
||||
const m = (el.textContent || '').trim().match(/^(\d+)\s*\/\s*(\d+)$/);
|
||||
if (m) el.setAttribute('title', tr('section.of').replace('{n}', m[1]).replace('{m}', m[2]));
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||
el.textContent = tr(el.dataset.i18n);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-html]').forEach((el) => {
|
||||
el.innerHTML = tr(el.dataset.i18nHtml);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
|
||||
el.setAttribute('aria-label', tr(el.dataset.i18nAria));
|
||||
});
|
||||
|
||||
// Carousel captions & alts are created dynamically with tr() at init
|
||||
document.querySelectorAll('.carousel-caption[data-caption-key]').forEach((el) => {
|
||||
el.textContent = tr(el.dataset.captionKey);
|
||||
});
|
||||
document.querySelectorAll('.carousel-slide img').forEach((img) => {
|
||||
const slide = img.closest('.carousel-slide');
|
||||
const cap = slide && slide.querySelector('.carousel-caption[data-caption-key]');
|
||||
if (cap) img.alt = tr(cap.dataset.captionKey);
|
||||
else img.alt = tr('carousel.alt');
|
||||
});
|
||||
|
||||
// Live-update dynamic strings
|
||||
const statusValue = document.getElementById('srv-status');
|
||||
if (statusValue && statusValue.dataset.state) {
|
||||
updateStatusUI(statusValue.dataset.state);
|
||||
}
|
||||
const caption = document.getElementById('previewCaption');
|
||||
if (caption && caption.dataset.screen) {
|
||||
const scene = SCENES.find((s) => s.key === caption.dataset.screen);
|
||||
caption.textContent = scene ? tr(scene.label) : '';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLang() {
|
||||
currentLang = currentLang === 'ru' ? 'en' : 'ru';
|
||||
try { localStorage.setItem(STORAGE_KEY, currentLang); } catch (e) { /* ignore */ }
|
||||
applyI18n();
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (!bytes) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let n = bytes;
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||
return n.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
async function loadLauncherInfo() {
|
||||
try {
|
||||
const res = await fetch(API + '/launcher/info');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const version = data.current_version || '—';
|
||||
|
||||
const heroVersion = document.getElementById('hero-version');
|
||||
const heroSize = document.getElementById('hero-size');
|
||||
const dlVersion = document.getElementById('download-version');
|
||||
const dlSize = document.getElementById('download-size');
|
||||
const dlBtn = document.getElementById('download-btn');
|
||||
const dlOnline = document.getElementById('download-online-btn');
|
||||
const dlOffline = document.getElementById('download-offline-btn');
|
||||
const pvVersion = document.getElementById('preview-version');
|
||||
|
||||
if (heroVersion) heroVersion.textContent = version;
|
||||
if (dlVersion) dlVersion.textContent = version;
|
||||
if (pvVersion) pvVersion.textContent = 'v' + version;
|
||||
|
||||
if (data.files && data.files.zips && data.files.zips.length) {
|
||||
const latest = data.files.zips[0];
|
||||
if (heroSize) heroSize.textContent = fmtSize(latest.size);
|
||||
if (dlSize) dlSize.textContent = fmtSize(latest.size);
|
||||
}
|
||||
|
||||
if (dlBtn && data.new_format && data.new_format.download_url) {
|
||||
dlBtn.href = API + data.new_format.download_url;
|
||||
}
|
||||
// Online/Offline hybrid (Go, 1.1.3)
|
||||
if (dlOnline) {
|
||||
// online setup is Go exe (~5.8M) — try to discover via /launcher/version or static name
|
||||
try {
|
||||
const v = version && version !== '—' ? version : '1.1.1.2';
|
||||
dlOnline.href = API + '/launcher/download/jre'; // JRE endpoint validated, exe name is ZernMC-Online-Setup-<ver>.exe
|
||||
dlOnline.setAttribute('data-version', v);
|
||||
// probe if online exe exists
|
||||
fetch(API + '/launcher/file/' + v + '/ZernMC-Online-Setup-' + v + '.exe', {method:'HEAD'}).then(r=>{
|
||||
if (r.ok) dlOnline.href = API + '/launcher/file/' + v + '/ZernMC-Online-Setup-' + v + '.exe';
|
||||
}).catch(()=>{});
|
||||
} catch(e){}
|
||||
}
|
||||
if (dlOffline && data.new_format && data.new_format.download_url) {
|
||||
dlOffline.href = API + data.new_format.download_url;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[site] failed to load launcher info', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMirrors() {
|
||||
try {
|
||||
const res = await fetch(API + '/launcher/mirrors');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const container = document.getElementById('mirrors');
|
||||
if (!container || !data.mirrors || !data.mirrors.length) return;
|
||||
|
||||
const tags = data.mirrors.slice(0, 5).map((m) => {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'mirror-tag';
|
||||
a.href = (m.url || '') + '/launcher/download/latest';
|
||||
a.textContent = m.name || m;
|
||||
return a;
|
||||
});
|
||||
tags.forEach((t) => container.appendChild(t));
|
||||
} catch (e) {
|
||||
console.warn('[site] failed to load mirrors', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerStatus() {
|
||||
const statusEl = document.getElementById('srv-status');
|
||||
const playersEl = document.getElementById('srv-players');
|
||||
const versionEl = document.getElementById('srv-version');
|
||||
if (!statusEl) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/api/status');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.online) {
|
||||
statusEl.dataset.state = 'online';
|
||||
if (playersEl) playersEl.textContent = `${data.players_online} / ${data.players_max}`;
|
||||
if (versionEl && data.version) versionEl.textContent = data.version;
|
||||
} else {
|
||||
statusEl.dataset.state = 'offline';
|
||||
if (playersEl) playersEl.textContent = '— / —';
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.dataset.state = 'unavailable';
|
||||
if (playersEl) playersEl.textContent = '— / —';
|
||||
}
|
||||
updateStatusUI(statusEl.dataset.state);
|
||||
}
|
||||
|
||||
function updateStatusUI(state) {
|
||||
const statusEl = document.getElementById('srv-status');
|
||||
const dot = document.querySelector('#srv-status .status-dot');
|
||||
if (!statusEl) return;
|
||||
|
||||
if (state === 'online') {
|
||||
if (dot) dot.className = 'status-dot status-dot--online';
|
||||
statusEl.innerHTML = '<span class="status-dot status-dot--online"></span><span class="status-text--online">' + tr('server.online') + '</span>';
|
||||
} else if (state === 'offline') {
|
||||
if (dot) dot.className = 'status-dot status-dot--offline';
|
||||
statusEl.innerHTML = '<span class="status-dot status-dot--offline"></span><span class="status-text--offline">' + tr('server.offline') + '</span>';
|
||||
} else {
|
||||
if (dot) dot.className = 'status-dot status-dot--offline';
|
||||
statusEl.innerHTML = '<span class="status-dot status-dot--offline"></span><span>' + tr('server.unavailable') + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
const HIGHLIGHTS = [
|
||||
{ file: '1.jpg', type: 'image', captionKey: 'carousel.cap1' },
|
||||
{ file: '2.mp4', type: 'video', captionKey: null },
|
||||
{ file: '3.jpg', type: 'image', captionKey: 'carousel.cap3' },
|
||||
{ file: '4.mp4', type: 'video', captionKey: null },
|
||||
{ file: '5.jpg', type: 'image', captionKey: 'carousel.cap5' },
|
||||
{ file: '6.jpg', type: 'image', captionKey: 'carousel.cap6' },
|
||||
{ file: '7.jpg', type: 'image', captionKey: null },
|
||||
{ file: '8.jpg', type: 'image', captionKey: null },
|
||||
{ file: '9.jpg', type: 'image', captionKey: null },
|
||||
{ file: '10.jpg', type: 'image', captionKey: null },
|
||||
{ file: '11.jpg', type: 'image', captionKey: null },
|
||||
{ file: '12.jpg', type: 'image', captionKey: null },
|
||||
{ file: '13.jpg', type: 'image', captionKey: null },
|
||||
{ file: '14.jpg', type: 'image', captionKey: null },
|
||||
{ file: '15.jpg', type: 'image', captionKey: null },
|
||||
];
|
||||
|
||||
function initCarousel() {
|
||||
const track = document.getElementById('carousel');
|
||||
const dotsWrap = document.getElementById('carouselDots');
|
||||
const prev = document.getElementById('carouselPrev');
|
||||
const next = document.getElementById('carouselNext');
|
||||
if (!track) return;
|
||||
|
||||
const AUTO_MS = 6500; // auto-advance when idle
|
||||
const MANUAL_MS = 22000; // hold much longer after manual navigation
|
||||
const videos = [];
|
||||
|
||||
HIGHLIGHTS.forEach((h, i) => {
|
||||
const slide = document.createElement('div');
|
||||
slide.className = 'carousel-slide';
|
||||
|
||||
if (h.type === 'video') {
|
||||
const vid = document.createElement('video');
|
||||
vid.src = '/highlights/' + h.file;
|
||||
vid.controls = true;
|
||||
vid.muted = true;
|
||||
vid.playsInline = true;
|
||||
slide.appendChild(vid);
|
||||
videos[i] = vid;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = '/highlights/' + h.file;
|
||||
img.alt = h.captionKey ? tr(h.captionKey) : tr('carousel.alt');
|
||||
img.loading = 'lazy';
|
||||
slide.appendChild(img);
|
||||
}
|
||||
|
||||
if (h.captionKey) {
|
||||
const cap = document.createElement('span');
|
||||
cap.className = 'carousel-caption';
|
||||
cap.dataset.captionKey = h.captionKey;
|
||||
cap.textContent = tr(h.captionKey);
|
||||
slide.appendChild(cap);
|
||||
}
|
||||
|
||||
track.appendChild(slide);
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
|
||||
dot.addEventListener('click', () => goto(i, true));
|
||||
dotsWrap.appendChild(dot);
|
||||
});
|
||||
|
||||
let index = 0;
|
||||
let timer = null;
|
||||
|
||||
function schedule(delay) {
|
||||
stop();
|
||||
timer = setInterval(() => nextSlide(false), delay);
|
||||
}
|
||||
|
||||
function isVideoAt(i) {
|
||||
const h = HIGHLIGHTS[i];
|
||||
return h && h.type === 'video';
|
||||
}
|
||||
|
||||
function goto(i, manual) {
|
||||
index = (i + HIGHLIGHTS.length) % HIGHLIGHTS.length;
|
||||
track.style.transform = 'translateX(-' + index * 100 + '%)';
|
||||
dotsWrap.querySelectorAll('.carousel-dot').forEach((d, k) => {
|
||||
d.classList.toggle('active', k === index);
|
||||
});
|
||||
|
||||
// Only the visible video plays; others pause to save bandwidth.
|
||||
videos.forEach((vid, k) => {
|
||||
if (vid) {
|
||||
if (k === index) {
|
||||
if (vid.paused) {
|
||||
vid.currentTime = 0;
|
||||
const p = vid.play();
|
||||
if (p && p.catch) p.catch(() => {});
|
||||
}
|
||||
} else {
|
||||
vid.pause();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isVideoAt(index)) {
|
||||
// A video slide always advances on its 'ended' event, never on a timer.
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
// Image slides: manual navigation holds much longer, idle auto-advance restores.
|
||||
schedule(manual ? MANUAL_MS : AUTO_MS);
|
||||
}
|
||||
|
||||
function nextSlide(manual) { goto(index + 1, !!manual); }
|
||||
function prevSlide() { goto(index - 1, true); }
|
||||
|
||||
function start() { goto(index, false); }
|
||||
function stop() {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
|
||||
// A video slide advances only once its playback actually finished.
|
||||
videos.forEach((vid) => {
|
||||
if (!vid) return;
|
||||
vid.addEventListener('ended', () => nextSlide(false));
|
||||
});
|
||||
|
||||
if (prev) prev.addEventListener('click', () => { prevSlide(); });
|
||||
if (next) next.addEventListener('click', () => { nextSlide(true); });
|
||||
track.addEventListener('mouseenter', stop);
|
||||
track.addEventListener('mouseleave', start);
|
||||
|
||||
goto(0, false);
|
||||
}
|
||||
|
||||
function stripMarkup(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/\[[^\]]*photo=[^\]]*\]/g, '')
|
||||
.replace(/\[[^\]]*\]/g, '')
|
||||
.replace(/\*\*\*/g, '')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/[*_~`#]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function loadNews() {
|
||||
const grid = document.getElementById('news-grid');
|
||||
if (!grid) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/news');
|
||||
if (!res.ok) throw new Error('news fetch failed');
|
||||
const data = await res.json();
|
||||
const news = (data.news || []).slice(0, 6);
|
||||
if (!news.length) { grid.innerHTML = '<p class="section-sub">' + tr('news.empty') + '</p>'; return; }
|
||||
|
||||
grid.innerHTML = '';
|
||||
news.forEach((n) => {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'news-card';
|
||||
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'news-tags';
|
||||
if (n.type) {
|
||||
const t = document.createElement('span');
|
||||
t.className = 'news-tag';
|
||||
t.textContent = n.type;
|
||||
tags.appendChild(t);
|
||||
}
|
||||
if (n.version) {
|
||||
const v = document.createElement('span');
|
||||
v.className = 'news-tag news-tag--version';
|
||||
v.textContent = n.version;
|
||||
tags.appendChild(v);
|
||||
}
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = n.title || tr('news.update');
|
||||
|
||||
const body = document.createElement('p');
|
||||
const text = stripMarkup(n.body);
|
||||
body.textContent = text.length > 220 ? text.slice(0, 220) + '…' : text;
|
||||
|
||||
card.appendChild(tags);
|
||||
card.appendChild(title);
|
||||
card.appendChild(body);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[site] failed to load news', e);
|
||||
grid.innerHTML = '<p class="section-sub">' + tr('news.error') + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function initNav() {
|
||||
const nav = document.getElementById('nav');
|
||||
const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 24);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
}
|
||||
|
||||
function initParallax() {
|
||||
const els = document.querySelectorAll('[data-parallax]');
|
||||
if (!els.length) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
const onScroll = () => {
|
||||
const y = window.scrollY;
|
||||
if (y > window.innerHeight) return;
|
||||
els.forEach((el) => {
|
||||
const speed = parseFloat(el.dataset.parallax);
|
||||
el.style.transform = 'translateY(' + (y * speed) + 'px)';
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
}
|
||||
|
||||
const SCENES = [
|
||||
{ key: 'login', label: 'preview.login' },
|
||||
{ key: 'packs', label: 'preview.packs' },
|
||||
{ key: 'news', label: 'preview.news' },
|
||||
{ key: 'settings', label: 'preview.settings' },
|
||||
{ key: 'install', label: 'preview.install' },
|
||||
];
|
||||
|
||||
let sceneIndex = 0;
|
||||
let sceneTimer = null;
|
||||
|
||||
function showScene(i) {
|
||||
const body = document.getElementById('launcherPreviewBody');
|
||||
const dots = document.getElementById('previewDots');
|
||||
const caption = document.getElementById('previewCaption');
|
||||
if (!body) return;
|
||||
|
||||
sceneIndex = (i + SCENES.length) % SCENES.length;
|
||||
body.querySelectorAll('.preview-scene').forEach((el, k) => {
|
||||
el.classList.toggle('is-active', k === sceneIndex);
|
||||
});
|
||||
if (dots) {
|
||||
dots.querySelectorAll('.preview-dot').forEach((d, k) => {
|
||||
d.classList.toggle('active', k === sceneIndex);
|
||||
});
|
||||
}
|
||||
if (caption) {
|
||||
caption.textContent = tr(SCENES[sceneIndex].label);
|
||||
caption.dataset.screen = SCENES[sceneIndex].key;
|
||||
}
|
||||
}
|
||||
|
||||
function nextScene() { showScene(sceneIndex + 1); }
|
||||
|
||||
function startPreview() {
|
||||
if (!document.getElementById('launcherPreviewBody')) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
if (sceneTimer) clearInterval(sceneTimer);
|
||||
sceneTimer = setInterval(nextScene, 4200);
|
||||
showScene(0);
|
||||
}
|
||||
|
||||
function initPreview() {
|
||||
const body = document.getElementById('launcherPreviewBody');
|
||||
const dots = document.getElementById('previewDots');
|
||||
if (!body || !dots) return;
|
||||
|
||||
SCENES.forEach((_, k) => {
|
||||
const d = document.createElement('span');
|
||||
d.className = 'preview-dot' + (k === 0 ? ' active' : '');
|
||||
dots.appendChild(d);
|
||||
});
|
||||
|
||||
startPreview();
|
||||
}
|
||||
|
||||
function initReveal() {
|
||||
const els = document.querySelectorAll('.section-head, .feature-card, .download-card, .news-card, .server-card, .theme-card, .carousel, .launcher-window');
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
els.forEach((el) => el.classList.add('visible'));
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
io.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.12 });
|
||||
els.forEach((el) => { el.classList.add('reveal'); io.observe(el); });
|
||||
}
|
||||
|
||||
function initYear() {
|
||||
const el = document.getElementById('year');
|
||||
if (el) el.textContent = new Date().getFullYear();
|
||||
}
|
||||
|
||||
async function loadGithubLanguages() {
|
||||
const bar = document.getElementById('lang-bar');
|
||||
const legend = document.getElementById('lang-legend');
|
||||
if (!bar || !legend) return;
|
||||
const colors = { Java:'#b07219', Python:'#3572A5', JavaScript:'#f1e05a', TypeScript:'#3178c6', CSS:'#563d7c', HTML:'#e34c26', Go:'#00ADD8', Rust:'#dea584', Shell:'#89e051', Dockerfile:'#384d54' };
|
||||
const tryFetch = async (url) => {
|
||||
const r = await fetch(url, {headers:{'Accept':'application/vnd.github.v3+json'}});
|
||||
if (!r.ok) throw new Error(url);
|
||||
return r.json();
|
||||
};
|
||||
try {
|
||||
let data = null;
|
||||
try { data = await tryFetch('https://git.swe.zernmc.ru/api/v1/repos/sasheg/launcher/languages'); } catch(e) {
|
||||
// Gitea fallback only, GitHub mirror removed
|
||||
}
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const total = Object.values(data).reduce((a,b)=>a+b,0);
|
||||
if (!total) return;
|
||||
const entries = Object.entries(data).map(([lang, bytes])=>({lang, bytes, pct: bytes/total*100})).sort((a,b)=>b.bytes-a.bytes).slice(0,6);
|
||||
// update bar
|
||||
bar.innerHTML = '';
|
||||
bar.title = entries.map(e=>`${e.lang} ${e.pct.toFixed(1)}%`).join(' · ');
|
||||
legend.innerHTML = '';
|
||||
entries.forEach(e=>{
|
||||
const seg = document.createElement('div');
|
||||
seg.style.flex = String(e.pct);
|
||||
seg.style.background = colors[e.lang] || 'var(--accent)';
|
||||
seg.title = `${e.lang} ${e.pct.toFixed(1)}%`;
|
||||
bar.appendChild(seg);
|
||||
const item = document.createElement('span');
|
||||
item.style.display='flex'; item.style.alignItems='center'; item.style.gap='6px';
|
||||
item.innerHTML = `<i style="width:10px;height:10px;border-radius:50%;background:${colors[e.lang]||'var(--accent)'};display:inline-block"></i> ${e.lang} ${e.pct.toFixed(1)}%`;
|
||||
legend.appendChild(item);
|
||||
});
|
||||
} catch(e) { /* keep static fallback */ }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
applyI18n();
|
||||
|
||||
const toggle = document.getElementById('langToggle');
|
||||
if (toggle) toggle.addEventListener('click', toggleLang);
|
||||
|
||||
loadLauncherInfo();
|
||||
loadMirrors();
|
||||
loadNews();
|
||||
loadServerStatus();
|
||||
setInterval(loadServerStatus, 15000);
|
||||
loadGithubLanguages();
|
||||
initCarousel();
|
||||
initPreview();
|
||||
initNav();
|
||||
initParallax();
|
||||
initReveal();
|
||||
initYear();
|
||||
});
|
||||
})();
|
||||
@@ -1,10 +1,6 @@
|
||||
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
|
||||
|
||||