77 Commits

Author SHA1 Message Date
SashegDev b493b3278b minor fixes 2026-06-07 16:36:50 +03:00
SashegDev ec7ef01760 иним чиним чиним чиним а так же новая система друзей и бутстраппера 2026-06-07 12:32:34 +00:00
SashegDev 166dbf8935 чиним cli + ui | Cli 99% готовность, UI примерно 70% 2026-05-24 18:38:16 +00:00
SashegDev 7014c4a455 fix: использовать java.exe вместо javaw.exe для отладки, inheritIO вместо ручного чтения 2026-05-11 12:21:29 +00:00
SashegDev d956bce921 fix: добавить UTF-8 параметры при запуске процессов и исправить обработку стрелок в ArrowMenu 2026-05-10 23:53:45 +00:00
SashegDev a765d064c4 чиним cli + ui..... ДА БЛЯ НУ СКОЛЬКО МОЖНО ТО А 2026-05-10 02:48:13 +00:00
SashegDev 1d5241075b ИНТЕРФЕЙС ФИКСЕСССС БЛЯЯЯ а так же фикс CLI 2026-05-10 01:46:38 +00:00
SashegDev 2c670b1103 попытка оптимизации и ДЖЛЫВОСШФРСЖДЛВОФЖДЛОВСМДЖЛФ ИНТЕРФЕЙС ФИКСЕСССС БЛЯЯЯ 2026-05-10 01:24:47 +00:00
SashegDev 389280f7f1 Fix: JFX launcher inherit console, no game output capture, SSE log optimization 2026-05-10 00:25:49 +00:00
SashegDev ee1e4fa8d2 Real-time log streaming via SSE 2026-05-10 00:05:10 +00:00
SashegDev e17b1d073a Launcher UI: MC/loader versions from server, split instances, console log sync, disable ZernMC for FREE 2026-05-09 23:55:08 +00:00
SashegDev a8f3ca5049 Launcher UI redesign + server mirror sync + file download optimization 2026-05-09 23:47:04 +00:00
SashegDev 59480217aa Server: generate meta.json for builds/ on startup for incremental updates 2026-05-08 18:51:15 +00:00
SashegDev 4697b16ab4 Bootstrap: incremental update via meta, server: fix file endpoint paths 2026-05-08 18:45:42 +00:00
SashegDev 099df80cc6 Pass launcher.server system property from Bootstrap to JFXLauncher 2026-05-08 18:38:18 +00:00
SashegDev 74cd5ffdf3 Assets: try meta download first, fallback to JAR extract 2026-05-08 18:37:24 +00:00
SashegDev 01668dd3bf Extract UI assets from JAR on first launch 2026-05-08 18:33:55 +00:00
SashegDev b2dbbac6ca Fix: NPE in AuthManager, game logs display in UI 2026-05-08 17:58:18 +00:00
SashegDev e32a057684 Fix: use vanilla classpath for modloaders (fabric/forge/neoforge), add JS debug logging 2026-05-08 17:50:33 +00:00
SashegDev d4dc35aac3 Debug: classpath for modloaders, game logs in UI 2026-05-08 17:43:12 +00:00
SashegDev 1e7231af57 Debug: add stdout/stderr capture, log game logs to console 2026-05-08 17:36:49 +00:00
SashegDev fd6e292d6e Add game log file writing, debug modloader launch 2026-05-08 17:23:38 +00:00
SashegDev 1e876ffe28 Clean up debug logging 2026-05-08 15:49:33 +00:00
SashegDev 2d515108f0 Debug: log server version response 2026-05-08 15:45:23 +00:00
SashegDev 13c9f67f6e Simplify: read version only from JAR manifest, remove .version file 2026-05-08 15:09:59 +00:00
SashegDev 659265c2f0 Fix version reading - fallback to JAR manifest, fix server version URL 2026-05-08 14:51:53 +00:00
SashegDev d8f189558a Fix Bootstrap to use bin/ directory properly
- Read version from bin/.version file (reliable, no JAR locking)
- Save version to bin/.version when downloading JAR
- Use getLauncherJar() for all JAR path references
- Create binDir in main()
- Remove build.version dependency completely
2026-05-08 13:17:30 +00:00
SashegDev 6f56012e3a Fix version reading from JAR manifest
- Read version from bin/.version file (reliable, no JAR locking issues)
- Save version to bin/.version when downloading JAR
- Remove complex JAR/ZIP reading code
- Use simple file-based version storage
2026-05-08 12:39:14 +00:00
SashegDev 3a0570e7da Remove build.version dependency
- Read version only from JAR manifest (Implementation-Version)
- Remove all VERSION_FILE references from Bootstrap
- Remove build.version from scanLocalFiles() and update methods
2026-05-08 12:15:43 +00:00
SashegDev 985abf7440 Fix: Bootstrap update and meta parsing
- Rewrite getLauncherMeta() to properly parse server meta response
- Change downloadUpdate() fallback to JAR-only (not ZIP) to avoid JRE lock issues
- Simplify downloadUpdateLegacy() to skip ZIP (which locks JRE files)
- Add handling for AccessDeniedException when updating locked files
- Improve error logging for meta parsing failures
2026-05-08 11:19:10 +00:00
SashegDev ec551ab2e3 Fix: Fabric loader launch and Bootstrap paths
- Add Fabric support in LaunchCommandBuilder.findVersionJson()
- Fix Bootstrap to properly use bin/ directory for launcher JAR
- Fix server.py to accept both ZernMC-win-*.zip and ZernMCLauncher-*.zip
- Add debug output for version.json resolution
2026-05-08 11:04:45 +00:00
SashegDev e5948b5337 Fix: Multiple launcher issues
- Fix CLI arrow keys: remove 50ms timeout in escape sequence handling (ArrowMenu, LoginMenu)
- Add network logs polling to UI via /api/logs endpoint
- Display user role in launcher header (AuthManager, AuthService, JFXLauncher, UI)
- Capture and display game logs in launcher via /api/game-logs endpoint
- Fix demo mode bug in VersionManifest.ruleMatches() - was incorrectly adding --demo flag
- Fix modloader launch: pass proper auth info (accessToken, uuid) from AuthManager
- Add game log capture in MinecraftLib and LaunchService
2026-05-08 10:11:49 +00:00
SashegDev 5a826c8511 Server: Add launcher version scanning on startup
- Scan versions/ directory and generate meta.json for each version
- Log progress: 'Scanning launcher versions...', 'Launcher meta ready: vX (Y files)'
- Meta cached in memory for faster access
2026-05-07 18:50:07 +00:00
SashegDev ce12854e1b Bootstrap: Add incremental update support via meta system
- Get server version from /launcher/meta (new method)
- Scan local files and calculate SHA256 hashes
- POST to /launcher/diff to get what files need update
- Download only changed files via /launcher/file/{version}/{path}
- Delete obsolete files
- Fallback to ZIP/JAR if meta system fails
- Works with legacy method as backup
2026-05-07 18:41:35 +00:00
SashegDev e566703332 Server: Add launcher meta system for incremental updates
- Create versions/ folder structure for new format builds
- Generate meta.json with SHA256 hashes for each file
- Add endpoints:
  - GET /launcher/meta - list all versions with meta
  - GET /launcher/meta/{version} - meta for specific version
  - POST /launcher/diff - get diff between local and server files
  - GET /launcher/file/{version}/{path} - download individual file
  - GET /launcher/download/zip/{version} - download full ZIP for new install
- Legacy builds (ZIP files) remain unchanged
2026-05-07 18:40:00 +00:00
SashegDev aaa19df5e4 Server: Default to 1 worker - better for file downloads
- Multiple workers cause contention and slow down large file downloads
- Single worker with async handles concurrent requests fine
2026-05-07 18:03:37 +00:00
SashegDev 0ee8077787 Server: Reduce rate limit log spam - periodic summary only
- Instead of logging every rate limit warning, now logs summary every 60s
- Shows: IP_blocked=X, rate_limited=Y
2026-05-07 17:56:46 +00:00
SashegDev fba944b4b8 Server: Add direct_passthrough for faster file serving
- FileResponse with direct_passthrough=True bypasses buffering
- Should improve file download speeds
2026-05-07 17:54:03 +00:00
SashegDev d39b40053a Server: Skip logging for file downloads
- Don't log every /pack/*/file/* request to reduce overhead
- Helps with large file downloads
2026-05-07 17:53:08 +00:00
SashegDev 1199ca9e21 Server: Fix /docs endpoint - allow openapi.json and swagger
- Remove openapi.json, swagger-ui, api/docs from suspicious paths
- Fix is_suspicious_path() to allow swagger/openapi patterns
2026-05-07 17:48:54 +00:00
SashegDev 50080d890f Server: Remove broken PID-based logging
- is_master() doesn't work with uvicorn workers
- Keeping clean logs from cache + disabled httpx debug
2026-05-07 17:46:42 +00:00
SashegDev f6fbb66cdc Server: PID-based logging - only master logs startup
- Only master PID logs blocklist loading, pack scanning, etc.
- Worker processes stay silent during startup
- Much cleaner logs
2026-05-07 17:45:36 +00:00
SashegDev d7a928cce4 Server: Add file lock for blocklist loading
- Only one worker downloads blocklist
- Other workers wait and read from cache
- Prevents duplicate downloads on startup
2026-05-07 17:43:21 +00:00
SashegDev 3bd3d1d0e8 Server: Cache blocklist to file + disable httpx debug logs
- Blocklist now cached to data/blocklist_cache.txt
- Only downloads once, then reuses cache
- Disable httpx/httpcore debug logs to reduce noise
2026-05-07 17:42:15 +00:00
SashegDev df9fa7b867 Server: Fix blocklist loading - only once at startup
- Move public blocklist loading into lifespan (not on import)
- Avoids loading 8 times with 4 workers
- Cleaner startup logs
2026-05-07 17:40:32 +00:00
SashegDev 81fbe028e8 Server: Auto-load public IP blocklists
- Load known bad IPs from FireHOL blocklists on startup
- ~4400 IPs blocked by default
- Set PUBLIC_BLOCKLIST=false to disable
- Combined with manual BLOCKED_IPS env var
2026-05-07 17:38:08 +00:00
SashegDev 513c07666b Server: Simplify IP filtering - only blacklist
- Remove whitelist (not needed for public launcher)
- Only BLOCKED_IPS env var supported now
2026-05-07 17:14:47 +00:00
SashegDev 04f97c3c80 Server: Add bot protection middleware
- Global rate limiting (60 requests/minute per IP)
- IP whitelist/blacklist via ALLOWED_IPS and BLOCKED_IPS env vars
- Bot detection - silent 404 for suspicious paths (.env, phpinfo, etc.)
- Path traversal detection
- Reduced noise in logs from bot scanners
2026-05-07 17:09:45 +00:00
SashegDev f40cf7afed Server: Add legacy build support
- Add version parsing to distinguish new vs legacy format builds
- New format: ZernMC-win-*.zip (1.0.8+ with bundled JRE21/JavaFX)
- Legacy: ZernMCLauncher-*.zip (< 1.0.8 or with suffix)
- /launcher/download/latest now returns new format by default
- Add /launcher/download/legacy endpoint for old builds
- Add legacy info to /launcher/info and /launcher/version responses
- Update download_zip to accept both ZernMCLauncher- and ZernMC-win- patterns
2026-05-07 16:44:10 +00:00
SashegDev 0cef411125 Refactor: Multi-module Maven project structure
- Restructured to multi-module Maven project (bootstrap + launcher)
- Removed duplicate code (launcher/launcher/ with JCEF)
- Added JavaFX modules to lib/javafx in ZIP
- Added JRE 21 to lib/jre21 in ZIP
- Fixed Bootstrap with UTF-8 encoding and JavaFX module-path
- Fixed JAR naming (zernmclauncher.jar)
- Added Windows build configuration (ZernMC-win-*.zip)
- Fixed version parsing for -any, -alpha, -beta suffixes
2026-05-06 21:35:14 +00:00
SashegDev 523f659269 коммит последних действий 2026-05-06 15:49:14 +00:00
SashegDev 04620d76c4 Multi-module project: bootstrap + launcher, UI updates
- Split into 2 Maven modules: bootstrap (updater) + launcher (UI)
- New UI: blue-orange theme, grid animation background
- Fixed version parsing bug (start += 11)
- Added unit tests for version parsing
- Server: adapted to new build structure (builds/zernmc)
2026-05-06 10:33:08 +00:00
SashegDev d0b4e187c8 feat(api): add internal API foundation for UI
- Create api package with AuthService, InstanceService, LaunchService
- Add ApiResponse<T> model for consistent responses
- Create LauncherAPI central facade for all services
- Update Main.java to use new API for session checking
- All services compile successfully
2026-05-05 04:12:39 +00:00
SashegDev f2d3de82f7 refactor(launch): dynamic version JSON parsing for Forge/NeoForge compatibility
- Replace hardcoded Forge/NeoForge args with version.json parsing
- Add VersionManifest.java — parses mainClass, arguments, libraries from JSON
- Implement rule matching for OS-specific library/argument filtering
- Build classpath dynamically from manifest libraries with fallback resolution
- Resolve game args with variable substitution (${version_name}, ${game_directory}, etc.)
- Auto-discover version.json path with multiple candidate formats
- Support all Forge versions (1.12.2 through 1.21+) and NeoForge out of the box
2026-05-04 22:58:49 +00:00
SashegDev b4431702dc feat: add NeoForge support, fix Forge installPack bug, update server proxy
- Fix MinecraftLib.installPack() returning false for Forge (was dead code)
- Add NeoForgeInstaller.java with installer download and execution
- Update LaunchCommandBuilder with NeoForge JVM args, classpath, launch args
- Update LaunchMenu with NeoForge option, version selector, support check
- Update Instance.java loader type comment (vanilla, fabric, forge, neoforge)
- Update PackDownloader to handle neoforge loader type
- Update ZHttpClient with NEOFORGE_MAVEN service type and detection
- Add NeoForge proxy endpoints (/proxy/neoforge/versions, /proxy/neoforge/maven)
- Add maven.neoforged.net to proxy allowed_domains
- Add asset_index to PackMeta model and pack_manager scanning
- Include asset_index in /packs list endpoint response
2026-05-04 22:53:22 +00:00
SashegDev cd2cf44d9c test(client): add JUnit 5 tests (30 tests) — unit + integration
- Add JUnit 5 dependency to pom.xml with surefire plugin
- Add setBaseUrl() to ZHttpClient for test server override
- AuthManagerParsingTest (7 tests): error extraction from JSON responses
  (simple detail, validation array, multiple errors, plain text, truncation)
- PackDownloaderParsingTest (13 tests): JSON contract for packs, manifests,
  diffs, file info, ServerPack toString
- ServerIntegrationTest (10 tests): real Java client ↔ real FastAPI server
  (register, login, duplicate, wrong password, /admin/me, validate token,
  refresh, packs auth, pack manifest public, launcher version)
- Integration tests auto-start test server via venv python3 subprocess
  on random port with isolated temp DB, graceful skip if unavailable

All 30 tests pass, 0 failures
2026-05-04 22:40:10 +00:00
SashegDev 8939e24e69 test(server): add client-facing endpoint tests (20 tests), fix pack contract assertions
- Add test_client.py with comprehensive client-server contract tests:
  - TestAuthFlowClient: full register → login → refresh → validate → /admin/me → logout lifecycle
  - TestPacksClientContract: /packs response fields matching ServerPack.java
  - TestPackManifestClientContract: /pack/{name} fields matching PackManifest.java
  - TestPackDiffClientContract: /pack/{name}/diff matching DiffResponse/FileInfo.java
    (all-new, no-changes, outdated-file, extra-local-file scenarios)
  - TestPackFileDownload: file serving, 404, path traversal security
  - TestPackPermissions: auth/pass requirements for /packs and /diff
  - TestLauncherVersion: /launcher/version endpoint
  - TestProxyEndpoints: /proxy/status, /proxy/fabric/versions/loader
- Add logged_in_user_with_pass fixture (role=1) for pack-related tests
- Add pack_fixture: creates temp pack with mod file, scans it, cleans up
- Fix manifest test: files don't have 'url' field (only in diff response)
- Fix /pack/{name} test: endpoint is public, no auth required

Total: 67 tests passing (47 existing + 20 new)
2026-05-04 22:28:12 +00:00
SashegDev c0310ed573 test(server): add comprehensive test suite (47 tests), fix DB lock and schema bugs
- Add pytest test suite: test_auth.py, test_admin.py, test_pass.py,
  test_proxy.py, test_rate_limit.py, test_client_contract.py
- Fix SQLite 'database is locked' errors: moved log_audit() calls outside
  with get_db() blocks in register, login, logout, refresh, activate_pass
- Enable WAL mode and busy_timeout in get_db() for concurrent access
- Fix /admin/me: removed non-existent 'email' column from query
- Fix /admin/users list: disambiguated activated_at column in JOIN query
- Fix /auth/refresh: now returns refresh_token + expires_in + username/uuid/role
  to match AuthManager.AuthSession expectations; revokes old refresh token
- Fix conftest.py: unique usernames per test to avoid conflicts
- All 47 tests passing
2026-05-04 22:14:06 +00:00
SashegDev c96b502ad4 fix(server,security): add ban check to validate_token, replace rate_limit DB with TTLCache 2026-05-04 21:12:35 +00:00
SashegDev bfcffdd88d chore(server): remove unused models, delete http_logger.py, rename viev_logs.py → view_logs.py 2026-05-04 21:10:11 +00:00
SashegDev 331fc9a863 refactor(server): clean main.py — remove duplicate imports, dead code, unify logging, fix proxy lifecycle 2026-05-04 21:09:10 +00:00
SashegDev e347c042d5 feat(server): add /auth/pass/activate endpoint for pass code activation 2026-05-04 21:06:56 +00:00
SashegDev bb564e6e9b feat(server): connect admin_router to FastAPI app 2026-05-04 21:06:02 +00:00
SashegDev 6f53002266 fix(server): add role aliases in roles.py to fix broken admin_router imports 2026-05-04 21:04:44 +00:00
SashegDev 9688509df5 fix(pom.xml): correct launch4j JAR path for exe build 2026-05-04 20:52:28 +00:00
SashegDev efc4b086d1 fix(TUI): proper arrow key handling — parse ESC sequences instead of treating as Esc 2026-05-04 20:39:29 +00:00
SashegDev 2cdc438411 just workin on the todo 2026-05-04 20:26:27 +00:00
Sashegdev b60e414d37 last commit to uuuuh idl 2026-05-04 15:19:46 +00:00
Sashegdev 10ec8625b9 The fuck was hapanned тут 2026-04-22 12:54:57 +00:00
Sashegdev f24cc078c5 Merge branch 'main' into alpha 2026-04-22 15:26:39 +03:00
Sashegdev adde40d921 Коммит, для того что бы если что роллбекать 2026-04-22 12:23:51 +00:00
Sashegdev 6bf6c1634a Фиксы проходок (нормально, в отличии от main ветки)
ОНО РАБОТАЕТ СУКАААА
2026-04-20 19:30:17 +00:00
Sashegdev 98462ba4a3 Update issue templates 2026-04-20 19:59:07 +03:00
Sashegdev 11ec84fe24 Create LICENSE 2026-04-20 19:57:52 +03:00
Sashegdev 8b56652a73 test penis 2026-04-09 18:13:21 +00:00
Sashegdev d7a6eb760e fixes 2026-04-09 18:03:00 +00:00
Sashegdev c6dd215e9b рефакторинг + новая система модерации 2026-04-09 17:28:48 +00:00
88 changed files with 16616 additions and 3111 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+11
View File
@@ -2,6 +2,8 @@ logs/
__pycache__/ __pycache__/
./.venv/ ./.venv/
launcher/target launcher/target
bootstrap/target
src/target
server/builds server/builds
server/packs server/packs
server/data server/data
@@ -9,3 +11,12 @@ jre
.vscode .vscode
dependency-reduced-pom.xml dependency-reduced-pom.xml
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
telegram-bot/
builds/
server/news/
data/
packs/
.__pycache__
.pytest_cache
.venv
resources
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 SashegDev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16 -5
View File
@@ -17,21 +17,34 @@
## Чего пока нет в лаунчере ## Чего пока нет в лаунчере
- Графического интерфейса (GUI) — только TUI
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти) - Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
- Поддержки **Forge** (в разработке) - Поддержки **Forge** (в разработке)
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров - Поддержки Quilt, LabyMod, NeoForge и других лоадеров
- Раздела новостей об обновлениях Minecraft и лаунчера - Раздела новостей об обновлениях Minecraft и лаунчера
- Выбора готовых пресетов оптимизации JVM - Выбора готовых пресетов оптимизации JVM
- Кастомных модов (UI, спавнеры, DPI, карточки)
- Сайта для лаунчера и сервера
- Трекинга наигранного времени
## Что планируется доработать в ближайшее время ## Что планируется доработать в ближайшее время
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
- **GUI мод** — привести в единый стиль с главным меню
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
- **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
- Генерацию команды запуска Minecraft - Генерацию команды запуска Minecraft
- Стабильную работу автообновления лаунчера - Стабильную работу автообновления лаунчера
- Полноценные настройки - Полноценные настройки
- Стабильность и производительность серверной части - **Улучшенный античит / ClientChecker** — проверка подлинности клиента при подключении к серверу, без нужного клиента не пустит; поставляется вместе с лаунчером, не общедоступный. Хеш-проверка всех папок и файлов сборки при каждом запуске — при несовпадении одного хеша все моды переустанавливаются. Игнорируются только: логи, ресурспаки, шейдеры, сейвы, личные файлы. Защита от подмены libs и лоадеров (Meteor и аналоги), проверка целостности модов через хеши. В перспективе — защита от Mixin-атак (перехват логики других модов), сбор отчёта о текущей сборке и сравнение с базовой
- **Баг-фиксы сервера:** подключить `admin_router` в `main.py`, исправить импорты ролей (`ROLE_USER` и др. не существуют в `roles.py`), добавить эндпоинт `/auth/pass/activate`, убрать дубли импортов (`TTLCache`, `Response`)
- Улучшение прокси-режима - Улучшение прокси-режима
- Стабильность и производительность серверной части
- Общую надёжность загрузки файлов с сервера - Общую надёжность загрузки файлов с сервера
- аккаунты, проходки
## Важная информация перед использованием ## Важная информация перед использованием
@@ -39,12 +52,10 @@
Лаунчер использует **текстовый интерфейс (TUI)**: Лаунчер использует **текстовый интерфейс (TUI)**:
- `W` / `S` (или `Ц` / `Ы`) — перемещение по меню - `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
- `ENTER` — выбор пункта - `ENTER` — выбор пункта
- `ESC` или пункт «Назад» — возврат назад - `ESC` или пункт «Назад» — возврат назад
> **Важно:** Стрелки ↑/↓ могут вызывать баги и краши. Используйте только `W`/`S`.
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре. Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
### Расположение сборок ### Расположение сборок
+24
View File
@@ -0,0 +1,24 @@
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
dependency-reduced-pom.xml
# IDE
.idea/
*.iml
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build outputs
server/builds/
server/logs/
# Colab
colab/
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.9</version>
</parent>
<artifactId>zernmc-bootstrap</artifactId>
<packaging>jar</packaging>
<name>ZernMC Bootstrap</name>
<description>Bootstrap module - handles updates and Java launching</description>
<dependencies>
<!-- Minimal dependencies for Bootstrap -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/zernmc-bootstrap.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,715 @@
package me.sashegdev.zernmc.launcher;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.*;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.swing.*;
import javax.swing.plaf.basic.BasicProgressBarUI;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
public class Bootstrap {
private static final String JAR_NAME = "zernmclauncher.jar";
private static final String BASE_URL = "http://87.120.187.36:1582";
private static List<String> MIRRORS = new ArrayList<>();
private static volatile boolean jfxChildExiting = false;
private static Path baseDir;
private static Path binDir;
private static Path logDir;
private static Path javafxPath;
private static boolean isCliMode;
private static boolean isJfxMode;
private static BootstrapUI ui;
private static Path getLauncherJar() {
return binDir.resolve(JAR_NAME);
}
public static void main(String[] args) throws Exception {
baseDir = Paths.get("").toAbsolutePath();
binDir = baseDir.resolve("bin");
Files.createDirectories(binDir);
logDir = baseDir.resolve("logs");
Files.createDirectories(logDir);
javafxPath = baseDir.resolve("lib").resolve("javafx");
log("=== ZernMC Launcher ===");
List<String> argList = Arrays.asList(args);
isCliMode = argList.contains("--cli");
isJfxMode = !isCliMode;
log("Mode: " + (isCliMode ? "CLI" : "JFX"));
if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
ui = new BootstrapUI();
SwingUtilities.invokeLater(() -> ui.show());
}
String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion();
log("Local version: " + currentVersion);
log("Server version: " + serverVersion);
setVersionInfo(currentVersion, serverVersion);
loadMirrors();
log("Primary server: " + BASE_URL);
log("Mirrors available: " + (MIRRORS.size() + 1));
if (isNewer(serverVersion, currentVersion)) {
log("Update available!");
downloadUpdate(serverVersion);
} else {
log("Version is up to date");
}
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
log("Shutdown signal received...");
}));
if (ui != null) {
setTitle("Launching...");
setProgress(100, 100);
log("Starting launcher...");
try { Thread.sleep(400); } catch (InterruptedException ignored) {}
ui.close();
}
launchMain(args);
}
private static void launchMain(String[] args) throws Exception {
log("Loading launcher: " + getLauncherJar());
if (isCliMode) {
launchInProcess(args);
} else {
launchInNewProcess(args);
}
}
private static void launchInProcess(String[] args) throws Exception {
ClassLoader parent = Bootstrap.class.getClassLoader();
URL[] urls = { getLauncherJar().toUri().toURL() };
URLClassLoader cl = new URLClassLoader(urls, parent);
Thread.currentThread().setContextClassLoader(cl);
try {
Class<?> mainClass = cl.loadClass("me.sashegdev.zernmc.launcher.Main");
java.lang.reflect.Method mainMethod = mainClass.getMethod("main", String[].class);
mainMethod.invoke(null, (Object) args);
} finally {
cl.close();
}
}
private static void launchInNewProcess(String[] args) throws Exception {
String os = System.getProperty("os.name").toLowerCase();
Path javaBin = findJava(false);
// On Windows, use javaw.exe to hide console in JFX mode
if (os.contains("windows")) {
Path javawPath = javaBin.resolveSibling("javaw.exe");
if (Files.exists(javawPath)) {
javaBin = javawPath;
}
}
Path javafxPath = baseDir.resolve("lib").resolve("javafx");
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
cmd.add("-Dsun.stdout.encoding=UTF-8");
cmd.add("-Dsun.stderr.encoding=UTF-8");
cmd.add("-Dlauncher.server=" + BASE_URL);
if (Files.exists(javafxPath)) {
cmd.add("--module-path");
cmd.add(javafxPath.toAbsolutePath().toString());
cmd.add("--add-modules");
cmd.add("javafx.controls,javafx.web");
}
cmd.add("-jar");
cmd.add(getLauncherJar().toAbsolutePath().toString());
cmd.add("--jfx");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.inheritIO();
log("Starting process: " + String.join(" ", cmd));
Process p = pb.start();
int code = p.waitFor();
log("JFX process exited with code: " + code);
System.exit(code);
}
private static Path findJava(boolean preferConsole) {
String os = System.getProperty("os.name").toLowerCase();
String javaExe = "java.exe";
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
}
if (!Files.exists(javaBin)) {
try {
Process p = new ProcessBuilder("where", javaExe).redirectErrorStream(true).start();
if (p.waitFor() == 0) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String path = br.readLine();
if (path != null) javaBin = Paths.get(path.trim());
}
}
} catch (Exception ignored) {}
}
if (!Files.exists(javaBin)) {
throw new RuntimeException("Java not found");
}
return javaBin;
}
private static void log(String msg) {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
System.out.println(entry);
try {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
if (ui != null) ui.setStatus(msg);
}
private static void setProgress(int current, int total) {
if (ui != null) ui.setProgress(current, total);
}
private static void setVersionInfo(String localVer, String serverVer) {
if (ui != null) ui.setVersionInfo(localVer, serverVer);
}
private static void setTitle(String text) {
if (ui != null) ui.setTitleText(text);
}
private static String readCurrentVersion() {
Path jar = getLauncherJar();
if (Files.exists(jar)) {
try (JarFile jarFile = new JarFile(jar.toFile())) {
Manifest manifest = jarFile.getManifest();
if (manifest != null) {
String v = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
if (v != null && !v.isBlank()) return v;
}
} catch (Exception e) {
log("Error reading manifest: " + e.getMessage());
}
}
return "0.0.0";
}
private static String getServerVersion() {
try {
URL url = new URL(BASE_URL + "/launcher/version");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
if (conn.getResponseCode() == 200) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
String response = sb.toString();
int versionStart = response.indexOf("\"version\":\"");
if (versionStart >= 0) {
int afterVersion = versionStart + 11;
int versionEnd = response.indexOf("\"", afterVersion);
if (versionEnd > afterVersion) {
return response.substring(afterVersion, versionEnd);
}
}
}
}
} catch (Exception e) {
log("Error fetching version: " + e.getMessage());
}
return "unknown";
}
private static boolean isNewer(String server, String current) {
try {
String[] sa = server.split("\\.");
String[] ca = current.split("\\.");
for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
int sv = Integer.parseInt(sa[i]);
int cv = Integer.parseInt(ca[i]);
if (sv > cv) return true;
if (sv < cv) return false;
}
return sa.length > ca.length;
} catch (Exception ignored) {}
return false;
}
private static void downloadUpdate(String newVersion) throws Exception {
log("Checking for updates...");
Map<String, FileMeta> serverFiles = fetchServerMeta(newVersion);
if (serverFiles.isEmpty()) {
log("Failed to get server meta");
return;
}
Map<String, String> localFiles = scanLocalFiles();
log("Local files: " + localFiles.size());
log("Server files: " + serverFiles.size());
int downloaded = 0;
int skipped = 0;
int failed = 0;
String selfName = getSelfFileName();
for (Map.Entry<String, FileMeta> entry : serverFiles.entrySet()) {
String filePath = entry.getKey();
FileMeta serverMeta = entry.getValue();
String localHash = localFiles.get(filePath);
String serverHash = serverMeta.hash.replace("sha256:", "");
if (localHash != null && localHash.equals(serverHash)) {
skipped++;
continue;
}
// Skip self-update (can't overwrite running executable)
if (selfName != null && (filePath.equalsIgnoreCase(selfName) || filePath.endsWith("/" + selfName))) {
log("Skipping self-update: " + filePath + " (file in use)");
skipped++;
continue;
}
if (localHash != null) {
log("Updating: " + filePath);
} else {
log("Downloading: " + filePath);
}
try {
downloadFile(newVersion, filePath, serverMeta.size);
downloaded++;
} catch (Exception e) {
log("Warning: Could not update " + filePath + " - " + e.getMessage());
failed++;
}
}
log("Updated files: " + downloaded + ", skipped: " + skipped + ", failed: " + failed);
log("Updated to v" + newVersion);
}
private static String getSelfFileName() {
try {
String classPath = Bootstrap.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
if (classPath != null) {
String fn = Paths.get(classPath).getFileName().toString();
// If running from a JAR, the exe has the same stem
if (fn.endsWith(".jar")) {
return fn.replace(".jar", ".exe");
}
}
} catch (Exception ignored) {}
return null;
}
private static Map<String, FileMeta> fetchServerMeta(String version) {
Map<String, FileMeta> files = new HashMap<>();
try {
URL url = new URL(BASE_URL + "/launcher/meta/" + version);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(10000);
if (conn.getResponseCode() == 200) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(sb.toString()).getAsJsonObject();
com.google.gson.JsonArray filesArray = json.getAsJsonArray("files");
for (com.google.gson.JsonElement fileElem : filesArray) {
com.google.gson.JsonObject file = fileElem.getAsJsonObject();
files.put(file.get("path").getAsString(), new FileMeta(
file.get("hash").getAsString(),
file.get("size").getAsLong()
));
}
}
}
} catch (Exception e) {
log("Error fetching meta: " + e.getMessage());
}
return files;
}
private static Map<String, String> scanLocalFiles() {
Map<String, String> files = new HashMap<>();
try {
Files.walk(baseDir)
.filter(Files::isRegularFile)
.filter(p -> !p.toString().contains(".git"))
.forEach(path -> {
try {
String relativePath = baseDir.relativize(path).toString().replace("\\", "/");
String hash = calculateFileHash(path);
files.put(relativePath, hash);
} catch (Exception ignored) {}
});
} catch (Exception ignored) {}
return files;
}
private static String calculateFileHash(Path path) throws Exception {
try (InputStream is = Files.newInputStream(path)) {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] buf = new byte[8192];
int len;
while ((len = is.read(buf)) > 0) {
digest.update(buf, 0, len);
}
byte[] hash = digest.digest();
StringBuilder sb = new StringBuilder();
for (byte b : hash) sb.append(String.format("%02x", b));
return sb.toString();
}
}
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception {
List<String> servers = new ArrayList<>();
if (isServerReachable(BASE_URL)) servers.add(BASE_URL);
servers.addAll(MIRRORS);
java.util.Collections.shuffle(servers);
Exception lastError = null;
for (String server : servers) {
try {
downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
return;
} catch (Exception e) {
lastError = e;
}
}
downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
}
private static void downloadFileFromServer(String urlStr, long expectedSize, String fileName) throws Exception {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(60000);
if (conn.getResponseCode() != 200) {
throw new IOException("HTTP " + conn.getResponseCode());
}
if (expectedSize <= 0) {
expectedSize = conn.getContentLengthLong();
}
Path outPath = baseDir.resolve(fileName);
Files.createDirectories(outPath.getParent());
long downloaded = 0;
long lastUpdate = 0;
long startTime = System.currentTimeMillis();
setTitle("Downloading " + fileName);
try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(outPath.toFile())) {
byte[] buf = new byte[65536];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
downloaded += len;
if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) {
long elapsed = System.currentTimeMillis() - startTime;
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
double downloadedMB = downloaded / 1024.0 / 1024.0;
double totalMB = expectedSize / 1024.0 / 1024.0;
String progressStr = String.format("%.1f/%.1f MB (%.1f MB/s)", downloadedMB, totalMB, speed);
log(progressStr);
setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
lastUpdate = downloaded;
}
}
}
long elapsed = System.currentTimeMillis() - startTime;
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
log(String.format("Downloaded %.1f MB (%.1f MB/s) - Done!", downloaded / 1024.0 / 1024.0, speed));
setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
}
private static String getProgressBar(long current, long total) {
if (total <= 0) return "====";
int filled = (int) ((current * 20) / total);
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < 20; i++) {
sb.append(i < filled ? "=" : " ");
}
sb.append("]");
return sb.toString();
}
private static class FileMeta {
String hash;
long size;
FileMeta(String hash, long size) {
this.hash = hash;
this.size = size;
}
}
private static void loadMirrors() {
try {
URL url = new URL(BASE_URL + "/launcher/mirrors");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
if (conn.getResponseCode() == 200) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
com.google.gson.JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject();
com.google.gson.JsonArray mirrorsArray = json.getAsJsonArray("mirrors");
for (com.google.gson.JsonElement elem : mirrorsArray) {
com.google.gson.JsonObject mirror = elem.getAsJsonObject();
String mirrorUrl = mirror.get("url").getAsString();
if (!MIRRORS.contains(mirrorUrl)) {
MIRRORS.add(mirrorUrl);
}
}
}
}
} catch (Exception e) {
log("Mirrors unavailable: " + e.getMessage());
}
}
private static boolean isServerReachable(String serverUrl) {
try {
URL url = new URL(serverUrl + "/launcher/version");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(2000);
conn.setReadTimeout(2000);
return conn.getResponseCode() == 200;
} catch (Exception ignored) {
return false;
}
}
// ====================== SWING UI ======================
private static class BootstrapUI {
private final JFrame frame;
private final JLabel statusLabel;
private final JProgressBar progressBar;
private final JLabel titleLabel;
private final JLabel versionLabel;
private final JLabel speedLabel;
private final Color bgColor = new Color(0x0c, 0x0c, 0x12);
private final Color surfaceColor = new Color(0x16, 0x16, 0x1f);
private final Color accentColor = new Color(0xe9, 0x45, 0x60);
private final Color textColor = new Color(0xee, 0xee, 0xf0);
private final Color mutedColor = new Color(0x88, 0x88, 0x9a);
BootstrapUI() {
try {
UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
} catch (Exception ignored) {}
frame = new JFrame("ZernMC Launcher");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(480, 280);
frame.setLocationRelativeTo(null);
frame.setResizable(false);
frame.setBackground(bgColor);
frame.setUndecorated(true);
JPanel root = new JPanel(new BorderLayout());
root.setBackground(bgColor);
root.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(new Color(0x2a, 0x2a, 0x3a), 1),
BorderFactory.createEmptyBorder(20, 24, 20, 24)
));
// Title bar
JPanel titleBar = new JPanel(new BorderLayout());
titleBar.setOpaque(false);
JLabel brandLabel = new JLabel("ZernMC Launcher");
brandLabel.setFont(new Font("Segoe UI", Font.BOLD, 18));
brandLabel.setForeground(textColor);
JPanel titleControls = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
titleControls.setOpaque(false);
JButton closeBtn = createTitleButton("\u2715");
closeBtn.addActionListener(e -> System.exit(0));
titleControls.add(closeBtn);
titleBar.add(brandLabel, BorderLayout.WEST);
titleBar.add(titleControls, BorderLayout.EAST);
root.add(titleBar, BorderLayout.NORTH);
// Center content
JPanel center = new JPanel();
center.setOpaque(false);
center.setLayout(new BoxLayout(center, BoxLayout.Y_AXIS));
center.add(Box.createVerticalStrut(16));
titleLabel = new JLabel("Initializing...");
titleLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
titleLabel.setForeground(mutedColor);
titleLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
center.add(titleLabel);
center.add(Box.createVerticalStrut(8));
versionLabel = new JLabel(" ");
versionLabel.setFont(new Font("Segoe UI", Font.PLAIN, 12));
versionLabel.setForeground(mutedColor);
versionLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
center.add(versionLabel);
center.add(Box.createVerticalStrut(16));
statusLabel = new JLabel("Starting...");
statusLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
statusLabel.setForeground(textColor);
statusLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
center.add(statusLabel);
center.add(Box.createVerticalStrut(12));
progressBar = new JProgressBar(0, 100);
progressBar.setPreferredSize(new Dimension(400, 6));
progressBar.setMaximumSize(new Dimension(400, 6));
progressBar.setAlignmentX(Component.CENTER_ALIGNMENT);
progressBar.setBackground(new Color(0x2a, 0x2a, 0x3a));
progressBar.setForeground(accentColor);
progressBar.setBorderPainted(false);
progressBar.setValue(0);
progressBar.setUI(new BasicProgressBarUI() {
protected Color getSelectionBackground() { return accentColor; }
protected Color getSelectionForeground() { return accentColor; }
});
center.add(progressBar);
center.add(Box.createVerticalStrut(6));
speedLabel = new JLabel(" ");
speedLabel.setFont(new Font("Segoe UI", Font.PLAIN, 11));
speedLabel.setForeground(mutedColor);
speedLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
center.add(speedLabel);
root.add(center, BorderLayout.CENTER);
// Draggable frame
MouseAdapter dragAdapter = new MouseAdapter() {
private int x, y;
public void mousePressed(MouseEvent e) { x = e.getX(); y = e.getY(); }
public void mouseDragged(MouseEvent e) {
frame.setLocation(e.getXOnScreen() - x, e.getYOnScreen() - y);
}
};
root.addMouseListener(dragAdapter);
root.addMouseMotionListener(dragAdapter);
frame.setContentPane(root);
}
private JButton createTitleButton(String text) {
JButton btn = new JButton(text);
btn.setFont(new Font("Segoe UI", Font.PLAIN, 14));
btn.setForeground(mutedColor);
btn.setBackground(bgColor);
btn.setBorderPainted(false);
btn.setFocusPainted(false);
btn.setContentAreaFilled(false);
btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
btn.addMouseListener(new MouseAdapter() {
public void mouseEntered(MouseEvent e) { btn.setForeground(accentColor); }
public void mouseExited(MouseEvent e) { btn.setForeground(mutedColor); }
});
return btn;
}
void show() {
frame.setVisible(true);
frame.toFront();
}
void close() {
frame.dispose();
}
void setStatus(final String text) {
SwingUtilities.invokeLater(() -> statusLabel.setText(text));
}
void setProgress(final int current, final int total) {
SwingUtilities.invokeLater(() -> {
if (total > 0) {
int pct = (int) ((long) current * 100 / total);
progressBar.setValue(Math.min(pct, 100));
speedLabel.setText(String.format("%.1f / %.1f MB",
current / 1024.0 / 1024.0, total / 1024.0 / 1024.0));
} else {
progressBar.setIndeterminate(true);
}
});
}
void setVersionInfo(final String local, final String server) {
SwingUtilities.invokeLater(() ->
versionLabel.setText("v" + local + " \u2192 v" + server));
}
void setTitleText(final String text) {
SwingUtilities.invokeLater(() -> titleLabel.setText(text));
}
}
}
+61 -7
View File
@@ -3,9 +3,13 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId> <groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId> <artifactId>ZernMCLauncher</artifactId>
<version>1.0.7</version> <version>1.0.8</version>
<build> <build>
<plugins> <plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
<plugin> <plugin>
<artifactId>maven-shade-plugin</artifactId> <artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version> <version>3.5.0</version>
@@ -24,7 +28,7 @@
<Implementation-Version>${project.version}</Implementation-Version> <Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title> <Implementation-Title>ZernMC Launcher</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor> <Implementation-Vendor>SashegDev</Implementation-Vendor>
<Implementation-Description>Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном)</Implementation-Description> <Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL> <Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
</manifestEntries> </manifestEntries>
</transformer> </transformer>
@@ -45,10 +49,11 @@
<goal>launch4j</goal> <goal>launch4j</goal>
</goals> </goals>
<configuration> <configuration>
<outfile>../server/builds/ZernMCLauncher.exe</outfile> <outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
<jar>../server/builds/ZernMCLauncher.jar</jar> <jar>../server/builds/ZernMCLauncher.jar</jar>
<headerType>console</headerType> <headerType>console</headerType>
<dontWrapJar>false</dontWrapJar> <dontWrapJar>false</dontWrapJar>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<jre> <jre>
<path>jre21</path> <path>jre21</path>
<minVersion>21</minVersion> <minVersion>21</minVersion>
@@ -56,13 +61,13 @@
<versionInfo> <versionInfo>
<fileVersion>${project.version}.0</fileVersion> <fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion> <txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription> <fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription>
<productVersion>${project.version}.0</productVersion> <productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion> <txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC Launcher</productName> <productName>ZernMC Launcher</productName>
<companyName>ZernMC(SashegDev)</companyName> <companyName>ZernMC(SashegDev)</companyName>
<internalName>ZernMCLauncher</internalName> <internalName>ZernMCLauncher</internalName>
<originalFilename>ZernMCLauncher.exe</originalFilename> <originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
</versionInfo> </versionInfo>
</configuration> </configuration>
</execution> </execution>
@@ -80,9 +85,15 @@
<configuration> <configuration>
<target> <target>
<echo>${project.version}</echo> <echo>${project.version}</echo>
<delete />
<mkdir />
<copy> <copy>
<fileset /> <fileset>
<include />
<include />
</fileset>
</copy> </copy>
<move />
<zip /> <zip />
</target> </target>
</configuration> </configuration>
@@ -91,10 +102,53 @@
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
<profiles>
<profile>
<id>global</id>
<properties>
<launcher.title>ZernMC Launcher</launcher.title>
<build.profile>global</build.profile>
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
<profile>
<id>zernmc</id>
<properties>
<launcher.title>ZernMC Private Launcher</launcher.title>
<build.profile>zernmc</build.profile>
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
</profiles>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
<exclusions>
<exclusion>
<artifactId>junit-jupiter-api</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-params</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-engine</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<properties> <properties>
<maven.compiler.target>21</maven.compiler.target> <project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass> <mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<project.organization.name>ZernMC</project.organization.name>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.inceptionYear>2026</project.inceptionYear>
</properties> </properties>
</project> </project>
+294
View File
@@ -0,0 +1,294 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.9</version>
</parent>
<artifactId>zernmclauncher</artifactId>
<packaging>jar</packaging>
<name>ZernMC Launcher</name>
<description>Main launcher module with JFX UI</description>
<dependencies>
<!-- HTTP Client -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<!-- JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>
<!-- Console/Terminal -->
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
</dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
</dependency>
<dependency>
<groupId>me.tongfei</groupId>
<artifactId>progressbar</artifactId>
</dependency>
<!-- IO -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
<!-- JavaFX - Windows -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/zernmclauncher.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<!-- Launch4j для создания .exe из bootstrap JAR -->
<plugin>
<groupId>com.akathist.maven.plugins.launch4j</groupId>
<artifactId>launch4j-maven-plugin</artifactId>
<version>2.5.0</version>
<executions>
<!-- GUI версия (основная) - без консоли -->
<execution>
<id>l4j-gui</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC</productName>
<companyName>ZernMC</companyName>
<internalName>zernmc</internalName>
<originalFilename>zernmc.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
<!-- CLI версия - с консолью -->
<execution>
<id>l4j-cli</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc-cli.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher CLI</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC CLI</productName>
<companyName>ZernMC</companyName>
<internalName>zernmc-cli</internalName>
<originalFilename>zernmc-cli.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
</executions>
</plugin>
<!-- Post-build: копирование JRE и создание ZIP -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>run</goal></goals>
<configuration>
<target>
<echo file="../../server/builds/build.version">${project.version}</echo>
<!-- Удаляем старую папку lib если есть -->
<delete dir="../../server/builds/lib"/>
<!-- Создаем папку lib -->
<mkdir dir="../../server/builds/lib"/>
<!-- Копируем JRE в lib/jre21 -->
<copy todir="../../server/builds/lib/jre21" overwrite="true">
<fileset dir="${user.home}/launcher/jre/jre21">
<include name="*"/>
<include name="**/*"/>
</fileset>
</copy>
<!-- Копируем JavaFX модули в lib/javafx -->
<mkdir dir="../../server/builds/lib/javafx"/>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21">
<include name="*win.jar"/>
</fileset>
</copy>
<!-- Создаем папку bin и копируем JAR -->
<mkdir dir="../../server/builds/bin"/>
<copy file="../../server/builds/zernmclauncher.jar"
tofile="../../server/builds/bin/zernmclauncher.jar" overwrite="true"/>
<!-- Копируем UI в assets -->
<mkdir dir="../../server/builds/assets"/>
<copy todir="../../server/builds/assets/ui" overwrite="true">
<fileset dir="${project.basedir}/src/resources/ui">
<include name="**/*"/>
</fileset>
</copy>
<!-- Создаём README -->
<echo file="../../server/builds/README.txt">
ZernMC Launcher
Files:
- zernmc.exe - Main launcher with GUI (no console window)
- zernmc-cli.exe - CLI version for servers/advanced users (with console)
How to use GUI:
Just run zernmc.exe
How to use CLI:
Run from command line: zernmc-cli.exe --cli
</echo>
<!-- Создаём один архив со всем -->
<zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
basedir="../../server/builds"
includes="zernmc.exe,zernmc-cli.exe,bin/**,assets/**,lib/**,README.txt"
excludes="build.version,*.jar"/>
</target>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,255 @@
package me.sashegdev.zernmc.launcher;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Bootstrap {
private static final String VERSION_FILE = "build.version";
private static final String JAR_NAME = "ZernMCLauncher.jar";
private static final String BASE_URL = "http://87.120.187.36:1582";
private static Path baseDir;
private static Path logDir;
public static void main(String[] args) throws Exception {
baseDir = Paths.get("").toAbsolutePath();
logDir = baseDir.resolve("logs");
Files.createDirectories(logDir);
log("=== ZernMC Launcher ===");
List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli");
boolean jfxMode = !cliMode;
String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion();
log("Local version: " + currentVersion);
log("Server version: " + serverVersion);
if (isNewer(serverVersion, currentVersion)) {
log("Update available!");
downloadUpdate(serverVersion);
} else {
log("Version is up to date");
}
if (jfxMode) {
launchJFX();
} else {
launchCLI();
}
}
private static void log(String msg) {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
System.out.println(entry);
try {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
}
private static String readCurrentVersion() {
Path f = baseDir.resolve(VERSION_FILE);
try {
if (Files.exists(f)) return Files.readString(f).trim();
} catch (Exception ignored) {}
return "0.0.0";
}
private static String getServerVersion() {
try {
URL url = new URL(BASE_URL + "/launcher/version");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
String line = br.readLine();
if (line != null && line.contains("version")) {
return line.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
}
}
}
} catch (Exception ignored) {}
return "unknown";
}
private static boolean isNewer(String server, String current) {
try {
String[] sa = server.split("\\.");
String[] ca = current.split("\\.");
for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
int sv = Integer.parseInt(sa[i]);
int cv = Integer.parseInt(ca[i]);
if (sv > cv) return true;
if (sv < cv) return false;
}
return sa.length > ca.length;
} catch (Exception ignored) {}
return false;
}
private static void downloadUpdate(String newVersion) throws Exception {
URL url = new URL(BASE_URL + "/launcher/download/jar");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) {
Path jarFile = baseDir.resolve(JAR_NAME);
Path tmp = jarFile.resolveSibling("zernmc-launcher-new.jar");
try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(tmp.toFile())) {
byte[] buf = new byte[8192];
int len;
long total = 0;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
total += len;
System.out.print("\rDownloaded: " + (total/1024/1024) + " MB");
}
}
log("Downloaded");
Path backup = jarFile.resolveSibling(JAR_NAME + ".old");
if (Files.exists(jarFile)) Files.move(jarFile, backup, StandardCopyOption.REPLACE_EXISTING);
Files.move(tmp, jarFile, StandardCopyOption.REPLACE_EXISTING);
if (Files.exists(backup)) Files.delete(backup);
Files.writeString(baseDir.resolve(VERSION_FILE), newVersion);
log("Updated to v" + newVersion);
} else {
throw new IOException("Server returned code: " + conn.getResponseCode());
}
}
private static void launchJFX() throws Exception {
Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME);
log("Starting JFX mode...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
cmd.add("-Dsun.stdout.encoding=UTF-8");
cmd.add("-Dsun.stderr.encoding=UTF-8");
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--jfx");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
}
pb.redirectErrorStream(true);
Process p = pb.start();
Thread outputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (Exception ignored) {}
});
outputThread.start();
int code = p.waitFor();
try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code);
System.exit(code);
}
private static void launchCLI() throws Exception {
Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME);
log("Starting CLI mode...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
cmd.add("-Dsun.stdout.encoding=UTF-8");
cmd.add("-Dsun.stderr.encoding=UTF-8");
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--cli");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
}
pb.redirectErrorStream(true);
Process p = pb.start();
Thread outputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (Exception ignored) {}
});
outputThread.start();
int code = p.waitFor();
try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code);
System.exit(code);
}
private static Path findJava() {
String os = System.getProperty("os.name").toLowerCase();
String javaExe = os.contains("windows") ? "java.exe" : "java";
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
}
if (!Files.exists(javaBin)) {
try {
Process p = new ProcessBuilder("which", javaExe).start();
if (p.waitFor() == 0) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String path = br.readLine();
if (path != null) {
javaBin = Paths.get(path.trim());
}
}
}
} catch (Exception ignored) {}
}
if (!Files.exists(javaBin)) {
throw new RuntimeException("Java not found. Make sure jre21 is present in the launcher folder or Java is installed on the system");
}
return javaBin;
}
}
@@ -0,0 +1,165 @@
package me.sashegdev.zernmc.launcher;
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.menu.*;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.*;
import java.io.IOException;
import java.util.List;
public class Main {
private static final String CURRENT_VERSION = Version.getCurrentVersion();
private static final LauncherAPI api = new LauncherAPI();
public static void main(String[] args) throws IOException {
System.setProperty("file.encoding", "UTF-8");
System.setProperty("sun.stderr.encoding", "UTF-8");
System.setProperty("sun.stdout.encoding", "UTF-8");
System.setProperty("java.stdout.encoding", "UTF-8");
System.setProperty("java.stderr.encoding", "UTF-8");
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
LauncherLogger.init();
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
try {
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
} catch (Exception ignored) {}
}
ZAnsi.install();
LauncherLogger.info("Starting ZernMC Launcher " + CURRENT_VERSION);
List<String> argList = List.of(args);
boolean jfxMode = argList.contains("--jfx");
boolean cliMode = argList.contains("--cli");
if (jfxMode) {
launchJFX();
return;
}
System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
startCLI();
}
private static void launchJFX() {
try {
System.setProperty("javafx.runtime.version", "21");
JFXLauncher.main(new String[]{});
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage()));
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing."));
System.err.println(ZAnsi.yellow("Try CLI mode: --cli"));
}
e.printStackTrace();
System.exit(1);
}
}
private static void startCLI() throws IOException {
ZHttpClient.checkAllServicesOnStartup(true);
System.out.println(ZAnsi.cyan("Checking authorization..."));
var sessionResponse = api.checkSession();
if (!sessionResponse.isSuccess()) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.out.println(ZAnsi.yellow("Goodbye!"));
ZAnsi.uninstall();
System.exit(0);
}
} else {
var sessionInfo = sessionResponse.getData();
System.out.println(ZAnsi.brightGreen("Welcome back, " + sessionInfo.getUsername() + "!"));
}
System.out.println(ZAnsi.cyan("Starting CLI mode..."));
try {
mainLoop();
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Critical error: " + e.getMessage()));
e.printStackTrace();
} finally {
ZAnsi.uninstall();
}
}
private static void mainLoop() throws Exception {
if (Config.isZernMCBuild()) {
zernMCFlow();
} else {
globalFlow();
}
}
private static void zernMCFlow() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
try {
String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("✓ Server is available"));
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("✗ Could not connect to ZernMC server"));
System.out.println(ZAnsi.white("Error: " + e.getMessage()));
ConsoleUtils.pause();
System.exit(1);
}
boolean sessionRestored = AuthManager.loadSavedSession();
if (!sessionRestored) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.exit(0);
}
} else {
System.out.println(ZAnsi.brightGreen("Welcome back, " + AuthManager.getUsername() + "!"));
}
LaunchMenu launchMenu = new LaunchMenu();
launchMenu.show();
}
private static void globalFlow() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
List<String> options = List.of(
"Launch Game",
"Check Updates",
"Settings",
"Server Connection Check",
"Exit"
);
ArrowMenu menu = new ArrowMenu("Main Menu", options);
int choice = menu.show();
if (choice == -1 || choice == 4) {
System.out.println(ZAnsi.yellow("Goodbye!"));
break;
}
switch (choice) {
case 0 -> new LaunchMenu().show();
case 1 -> new UpdateMenu().show();
case 2 -> new SettingsMenu().show();
case 3 -> new ServerCheckMenu().show();
}
}
}
}
@@ -0,0 +1,33 @@
package me.sashegdev.zernmc.launcher.api;
public class ApiResponse<T> {
private boolean success;
private T data;
private String error;
public ApiResponse(boolean success, T data, String error) {
this.success = success;
this.data = data;
this.error = error;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, null);
}
public static <T> ApiResponse<T> error(String error) {
return new ApiResponse<>(false, null, error);
}
public boolean isSuccess() {
return success;
}
public T getData() {
return data;
}
public String getError() {
return error;
}
}
@@ -0,0 +1,202 @@
package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class LauncherAPI {
private final AuthService authService;
private final InstanceService instanceService;
private final LaunchService launchService;
public LauncherAPI() {
this.authService = new AuthService();
this.instanceService = new InstanceService();
this.launchService = new LaunchService();
}
public AuthService auth() {
return authService;
}
public InstanceService instances() {
return instanceService;
}
public LaunchService launch() {
return launchService;
}
public boolean isLoggedIn() {
return authService.isLoggedIn();
}
public String getCurrentUsername() {
return authService.getCurrentUsername();
}
public ApiResponse<AuthService.SessionInfo> checkSession() {
return authService.checkSession();
}
public ApiResponse<AuthService.LoginResult> login(String username, String password) {
return authService.login(username, password);
}
public ApiResponse<Boolean> logout() {
return authService.logout();
}
public ApiResponse<Boolean> activatePass(String passCode) {
return authService.activatePass(passCode);
}
public ApiResponse<AuthService.LoginResult> register(String username, String password) {
return authService.register(username, password);
}
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
return instanceService.getAllInstances();
}
public ApiResponse<LaunchService.InstanceInfo> getLaunchInfo(String instanceName) {
return launchService.getLaunchInfo(instanceName);
}
public ApiResponse<LaunchService.LaunchInfo> prepareLaunch(String instanceName) {
return launchService.prepareLaunch(instanceName);
}
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
return launchService.launch(instanceName);
}
public ApiResponse<List<String>> getMCVersions() {
try {
org.json.JSONObject manifest = ZHttpClient.getMojangVersionManifest();
org.json.JSONArray versions = manifest.getJSONArray("versions");
List<String> mcVersions = new ArrayList<>();
for (int i = 0; i < versions.length(); i++) {
mcVersions.add(versions.getJSONObject(i).getString("id"));
}
return ApiResponse.success(mcVersions);
} catch (Exception e) {
System.out.println("[API] MC versions fetch failed: " + e.getMessage());
}
return ApiResponse.error("Failed to load Minecraft versions");
}
public ApiResponse<List<String>> getLoaderVersions(String mcVersion, String loader) {
try {
List<String> versions = new ArrayList<>();
switch (loader.toLowerCase()) {
case "fabric":
versions = ZHttpClient.getFabricLoaderVersions();
break;
case "forge":
String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
int idx = 0;
while ((idx = xml.indexOf("<version>", idx)) != -1) {
int start = idx + 9;
int end = xml.indexOf("</version>", start);
if (end == -1) break;
String fullVersion = xml.substring(start, end).trim();
if (fullVersion.startsWith(mcVersion + "-")) {
versions.add(fullVersion.substring(mcVersion.length() + 1));
}
idx = end;
}
versions.sort(LauncherAPI::compareVersions);
break;
case "neoforge":
String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
int neoidx = 0;
while ((neoidx = neoforgeXml.indexOf("<version>", neoidx)) != -1) {
int start = neoidx + 9;
int end = neoforgeXml.indexOf("</version>", start);
if (end == -1) break;
String fullVersion = neoforgeXml.substring(start, end).trim();
if (isNeoForgeCompatible(fullVersion, mcVersion)) {
versions.add(fullVersion);
}
neoidx = end;
}
versions.sort(LauncherAPI::compareVersions);
break;
default:
break;
}
return ApiResponse.success(versions);
} catch (Exception e) {
System.out.println("[API] Loader versions fetch failed: " + e.getMessage());
return ApiResponse.error("Failed to load loader versions");
}
}
private static int compareVersions(String a, String b) {
String[] partsA = a.split("\\.");
String[] partsB = b.split("\\.");
int len = Math.min(partsA.length, partsB.length);
for (int i = 0; i < len; i++) {
try {
int numA = Integer.parseInt(partsA[i]);
int numB = Integer.parseInt(partsB[i]);
if (numA != numB) return Integer.compare(numB, numA);
} catch (NumberFormatException e) {
int cmp = partsA[i].compareTo(partsB[i]);
if (cmp != 0) return cmp;
}
}
return Integer.compare(partsB.length, partsA.length);
}
private boolean isNeoForgeCompatible(String version, String mcVersion) {
if (mcVersion.startsWith("1.21")) {
return version.contains("1.21") && !version.contains("1.20");
} else if (mcVersion.startsWith("1.20") && !mcVersion.equals("1.20")) {
return version.contains("1.20.4") || version.contains("1.20.5") || version.contains("1.20.6");
}
return false;
}
public ApiResponse<List<Map<String, String>>> getZernMCPacks() {
try {
String token = authService.getCurrentToken();
if (token == null) {
LauncherLogger.warn("getZernMCPacks: not logged in");
return ApiResponse.error("Not logged in");
}
String response = ZHttpClient.get("/packs");
org.json.JSONObject root = new org.json.JSONObject(response);
org.json.JSONArray arr = root.optJSONArray("packs");
List<Map<String, String>> packs = new ArrayList<>();
if (arr != null) {
for (int i = 0; i < arr.length(); i++) {
org.json.JSONObject pack = arr.getJSONObject(i);
Map<String, String> packInfo = new java.util.HashMap<>();
packInfo.put("name", pack.optString("name", ""));
packInfo.put("displayName", pack.optString("displayName", pack.optString("name", "")));
packInfo.put("version", pack.optString("version", ""));
packInfo.put("mcVersion", pack.optString("minecraft_version", ""));
packInfo.put("loader", pack.optString("loader_type", "vanilla"));
packInfo.put("description", pack.optString("description", ""));
packs.add(packInfo);
}
}
LauncherLogger.info("getZernMCPacks: loaded " + packs.size() + " packs");
return ApiResponse.success(packs);
} catch (Exception e) {
LauncherLogger.error("getZernMCPacks failed: " + e.getMessage());
return ApiResponse.error("Failed to load packs: " + e.getMessage());
}
}
}
@@ -0,0 +1,173 @@
package me.sashegdev.zernmc.launcher.api.auth;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException;
public class AuthService {
public ApiResponse<LoginResult> register(String username, String password) {
try {
JsonObject json = new JsonObject();
json.addProperty("username", username);
json.addProperty("password", password);
String response = post("/auth/register", json.toString());
// If registration succeeds, auto-login
AuthManager.AuthResult result = AuthManager.login(username, password);
if (result.success) {
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
return ApiResponse.success(loginResult);
}
return ApiResponse.error(result.error != null ? result.error : "Registration failed");
} catch (Exception e) {
String msg = e.getMessage();
if (msg != null && msg.contains("HTTP 409")) {
return ApiResponse.error("Username already taken");
}
return ApiResponse.error("Registration error: " + msg);
}
}
public ApiResponse<LoginResult> login(String username, String password) {
try {
AuthManager.AuthResult result = AuthManager.login(username, password);
if (result.success) {
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
return ApiResponse.success(loginResult);
}
return ApiResponse.error(result.error != null ? result.error : "Invalid login or password");
} catch (Exception e) {
return ApiResponse.error("Auth error: " + e.getMessage());
}
}
public ApiResponse<Boolean> logout() {
try {
AuthManager.logout();
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Logout error: " + e.getMessage());
}
}
public ApiResponse<SessionInfo> checkSession() {
try {
boolean restored = AuthManager.loadSavedSession();
if (restored) {
SessionInfo info = new SessionInfo(
AuthManager.getUsername(),
AuthManager.getAccessToken(),
AuthManager.hasActivePass(),
AuthManager.getRole(),
AuthManager.getRoleName()
);
return ApiResponse.success(info);
}
return ApiResponse.error("Session not found");
} catch (Exception e) {
return ApiResponse.error("Session check error: " + e.getMessage());
}
}
public ApiResponse<Boolean> activatePass(String passCode) {
try {
JsonObject json = new JsonObject();
json.addProperty("pass_code", passCode);
String response = post("/auth/pass/activate", json.toString());
AuthManager.refreshUserInfo();
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Pass activation error: " + e.getMessage());
}
}
private String post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
java.net.URL url = new java.net.URL(fullUrl);
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
if (AuthManager.getAccessToken() != null && !AuthManager.getAccessToken().equals("0")) {
conn.setRequestProperty("Authorization", "Bearer " + AuthManager.getAccessToken());
}
conn.setDoOutput(true);
try (var os = conn.getOutputStream()) {
byte[] input = jsonBody.getBytes(java.nio.charset.StandardCharsets.UTF_8);
os.write(input);
}
int statusCode = conn.getResponseCode();
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
String responseBody;
try (var scanner = new java.util.Scanner(is, java.nio.charset.StandardCharsets.UTF_8.name())) {
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
}
conn.disconnect();
if (statusCode != 200) {
throw new IOException("HTTP " + statusCode + ": " + responseBody);
}
return responseBody;
}
public boolean isLoggedIn() {
return AuthManager.isLoggedIn();
}
public String getCurrentUsername() {
return AuthManager.getUsername();
}
public String getCurrentToken() {
return AuthManager.getAccessToken();
}
public static class LoginResult {
private String username;
private String token;
public LoginResult(String username, String token) {
this.username = username;
this.token = token;
}
public String getUsername() { return username; }
public String getToken() { return token; }
}
public static class SessionInfo {
private String username;
private String token;
private boolean passActive;
private int role;
private String roleName;
public SessionInfo(String username, String token, boolean passActive, int role, String roleName) {
this.username = username;
this.token = token;
this.passActive = passActive;
this.role = role;
this.roleName = roleName;
}
public String getUsername() { return username; }
public String getToken() { return token; }
public boolean isPassActive() { return passActive; }
public int getRole() { return role; }
public String getRoleName() { return roleName; }
}
}
@@ -0,0 +1,122 @@
package me.sashegdev.zernmc.launcher.api.instance;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
public class InstanceService {
public ApiResponse<List<InstanceInfo>> getAllInstances() {
try {
List<Instance> instances = InstanceManager.getAllInstances();
List<InstanceInfo> infoList = instances.stream()
.map(this::toInstanceInfo)
.collect(Collectors.toList());
return ApiResponse.success(infoList);
} catch (IOException e) {
return ApiResponse.error("Error getting instances list: " + e.getMessage());
}
}
public ApiResponse<InstanceInfo> getInstance(String name) {
try {
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
return ApiResponse.error("Pack not found: " + name);
}
return ApiResponse.success(toInstanceInfo(instance));
} catch (Exception e) {
return ApiResponse.error("Error getting pack: " + e.getMessage());
}
}
public ApiResponse<InstanceInfo> createInstance(String name) {
try {
boolean created = InstanceManager.createInstanceFolder(name);
if (!created) {
return ApiResponse.error("A pack with this name already exists: " + name);
}
Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(toInstanceInfo(instance));
} catch (IOException e) {
return ApiResponse.error("Error creating pack: " + e.getMessage());
}
}
public ApiResponse<Boolean> deleteInstance(String name) {
try {
boolean deleted = InstanceManager.deleteInstance(name);
if (!deleted) {
return ApiResponse.error("Failed to delete pack: " + name);
}
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Error deleting pack: " + e.getMessage());
}
}
public ApiResponse<Boolean> isInstanceExists(String name) {
try {
Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(instance != null);
} catch (Exception e) {
return ApiResponse.error("Error checking pack: " + e.getMessage());
}
}
private InstanceInfo toInstanceInfo(Instance instance) {
String name = instance.getName().toLowerCase();
String category = instance.isServerPack() ? "zernmc" : "local";
return new InstanceInfo(
instance.getName(),
instance.getPath().toString(),
instance.getMinecraftVersion(),
instance.getLoaderType(),
category,
instance.isServerPack(),
instance.getServerVersion(),
instance.getLoaderVersion(),
instance.getServerPackName()
);
}
public static class InstanceInfo {
private String name;
private String path;
private String version;
private String loaderType;
private String category;
private boolean isServerPack;
private int serverVersion;
private String loaderVersion;
private String serverPackName;
public InstanceInfo(String name, String path, String version, String loaderType, String category,
boolean isServerPack, int serverVersion, String loaderVersion, String serverPackName) {
this.name = name;
this.path = path;
this.version = version;
this.loaderType = loaderType;
this.category = category;
this.isServerPack = isServerPack;
this.serverVersion = serverVersion;
this.loaderVersion = loaderVersion;
this.serverPackName = serverPackName;
}
public String getName() { return name; }
public String getPath() { return path; }
public String getVersion() { return version; }
public String getLoaderType() { return loaderType; }
public String getCategory() { return category; }
public boolean isServerPack() { return isServerPack; }
public int getServerVersion() { return serverVersion; }
public String getLoaderVersion() { return loaderVersion; }
public String getServerPackName() { return serverPackName; }
}
}
@@ -0,0 +1,252 @@
package me.sashegdev.zernmc.launcher.api.launch;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
public class LaunchService {
private static final ConcurrentHashMap<Long, Process> runningProcesses = new ConcurrentHashMap<>();
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[LAUNCH] Shutting down all running processes...");
runningProcesses.values().forEach(p -> {
try {
p.destroy();
} catch (Exception ignored) {}
});
}));
}
public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName);
}
LauncherLogger.info("Preparing launch for: " + instanceName);
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions();
List<String> command = builder.build(options);
LaunchInfo info = new LaunchInfo(
instanceName,
command,
instance.getPath().toString()
);
return ApiResponse.success(info);
} catch (Exception e) {
LauncherLogger.error("Error preparing launch for " + instanceName, e);
return ApiResponse.error("Error preparing launch: " + e.getMessage());
}
}
public ApiResponse<ProcessInfo> launch(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName);
}
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions();
options.setUsername(AuthManager.getUsername());
options.setAccessToken(AuthManager.getAccessToken());
options.setUuid(AuthManager.getUuid());
List<String> command = builder.build(options);
LauncherLogger.info("Generated command for " + instanceName + ":");
command.forEach(arg -> LauncherLogger.debug(" " + arg));
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile());
processBuilder.redirectErrorStream(true);
Path logsDir = instance.getPath().resolve("logs");
java.nio.file.Files.createDirectories(logsDir);
Path gameLog = logsDir.resolve("game.log");
Process process = processBuilder.start();
long pid = process.pid();
runningProcesses.put(pid, process);
LauncherLogger.info("Process started, pid=" + pid);
java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true);
Thread logReader = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line;
JFXLauncher.appendGameLog(line);
try {
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
logFileOut.flush();
} catch (Exception ignored) {}
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
} finally {
try { logFileOut.close(); } catch (Exception ignored) {}
}
}, "GameLogReader-" + instanceName);
logReader.setDaemon(true);
logReader.start();
process.onExit().thenRun(() -> {
runningProcesses.remove(pid);
JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
});
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
return ApiResponse.success(info);
} catch (Exception e) {
LauncherLogger.error("Launch error for " + instanceName, e);
return ApiResponse.error("Launch error: " + e.getMessage());
}
}
public static void killAllProcesses() {
runningProcesses.values().forEach(p -> {
try {
p.destroyForcibly();
} catch (Exception ignored) {}
});
runningProcesses.clear();
}
public ApiResponse<Boolean> isReady(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName);
}
Path versionJson = instance.getPath().resolve("version.json");
boolean hasVersionJson = versionJson.toFile().exists();
return ApiResponse.success(hasVersionJson);
} catch (Exception e) {
return ApiResponse.error("Readiness check error: " + e.getMessage());
}
}
public ApiResponse<InstanceInfo> getLaunchInfo(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName);
}
InstanceInfo info = new InstanceInfo(
instance.getName(),
instance.getMinecraftVersion(),
instance.getLoaderType(),
instance.getLoaderVersion(),
instance.getAssetIndex()
);
return ApiResponse.success(info);
} catch (Exception e) {
return ApiResponse.error("Info retrieval error: " + e.getMessage());
}
}
private static LaunchOptions createOptions() {
LaunchOptions options = new LaunchOptions();
options.setMaxMemory(Config.getMaxMemory());
options.setWidth(Config.getWindowWidth());
options.setHeight(Config.getWindowHeight());
options.setJavaPath(Config.getJavaPath());
List<String> extraArgs = new ArrayList<>();
if (Config.isSystemBasedJvm()) {
String[] systemFlags = Config.getSystemJvmFlags().split("\\s+");
for (String arg : systemFlags) {
if (!arg.isEmpty()) extraArgs.add(arg);
}
}
String args = Config.getExtraJvmArgs();
if (args != null && !args.isEmpty()) {
for (String arg : args.split("\n")) {
arg = arg.trim();
if (!arg.isEmpty()) extraArgs.add(arg);
}
}
options.setExtraJvmArgs(extraArgs);
return options;
}
public static class LaunchInfo {
private String instanceName;
private List<String> command;
private String workingDirectory;
public LaunchInfo(String instanceName, List<String> command, String workingDirectory) {
this.instanceName = instanceName;
this.command = command;
this.workingDirectory = workingDirectory;
}
public String getInstanceName() { return instanceName; }
public List<String> getCommand() { return command; }
public String getWorkingDirectory() { return workingDirectory; }
}
public static class ProcessInfo {
private String instanceName;
private long pid;
private String status;
public ProcessInfo(String instanceName, long pid, String status) {
this.instanceName = instanceName;
this.pid = pid;
this.status = status;
}
public String getInstanceName() { return instanceName; }
public long getPid() { return pid; }
public String getStatus() { return status; }
}
public static class InstanceInfo {
private String name;
private String minecraftVersion;
private String loaderType;
private String loaderVersion;
private String assetIndex;
public InstanceInfo(String name, String minecraftVersion, String loaderType,
String loaderVersion, String assetIndex) {
this.name = name;
this.minecraftVersion = minecraftVersion;
this.loaderType = loaderType;
this.loaderVersion = loaderVersion;
this.assetIndex = assetIndex;
}
public String getName() { return name; }
public String getMinecraftVersion() { return minecraftVersion; }
public String getLoaderType() { return loaderType; }
public String getLoaderVersion() { return loaderVersion; }
public String getAssetIndex() { return assetIndex; }
}
}
@@ -0,0 +1,482 @@
package me.sashegdev.zernmc.launcher.auth;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.annotations.SerializedName;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class AuthManager {
private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json");
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static volatile AuthSession session = null;
private static volatile UserInfo userInfo = null;
public static final int ROLE_USER = 0;
public static final int ROLE_PASS_HOLDER = 1;
public static final int ROLE_MODERATOR = 2;
public static final int ROLE_ELDER = 3;
public static final int ROLE_CREATOR = 4;
public static final String PERM_VIEW_PACKS = "view_packs";
public static final String PERM_DOWNLOAD_PACK = "download_pack";
public static boolean loadSavedSession() {
if (!Files.exists(AUTH_FILE)) {
LauncherLogger.warn("loadSavedSession: auth.json not found at " + AUTH_FILE);
return false;
}
try {
String json = Files.readString(AUTH_FILE);
AuthSession loaded = GSON.fromJson(json, AuthSession.class);
if (loaded == null || loaded.accessToken == null) {
LauncherLogger.warn("loadSavedSession: invalid auth.json content, deleting");
Files.deleteIfExists(AUTH_FILE);
return false;
}
session = loaded;
LauncherLogger.info("loadSavedSession: loaded session for " + loaded.username
+ " expiresAt=" + loaded.expiresAt + " hasRefresh=" + (loaded.refreshToken != null));
refreshUserInfo();
if (isAccessTokenExpired()) {
LauncherLogger.info("loadSavedSession: token expired, attempting refresh");
boolean refreshed = tryRefresh();
if (!refreshed) {
if (session == null) {
LauncherLogger.warn("loadSavedSession: token rejected by server (401)");
return false;
}
LauncherLogger.warn("loadSavedSession: refresh failed (network/no refreshToken),"
+ " keeping session for retry on next launch");
return false;
}
}
if (session == null) {
LauncherLogger.warn("loadSavedSession: session invalidated during token refresh");
return false;
}
LauncherLogger.info("loadSavedSession: session valid for " + session.username);
return true;
} catch (Exception e) {
LauncherLogger.error("loadSavedSession error: " + e.getMessage());
invalidateSession();
return false;
}
}
public static boolean tryAutoLogin() {
if (isLoggedIn()) return true;
if (!Files.exists(AUTH_FILE)) return false;
return loadSavedSession();
}
public static AuthResult login(String username, String password) {
return authRequest("/auth/login", username, password);
}
public static AuthResult register(String username, String password) {
return authRequest("/auth/register", username, password);
}
private static AuthResult authRequest(String endpoint, String username, String password) {
try {
String body = GSON.toJson(new LoginRequest(username, password));
SimpleHttpResponse resp = post(endpoint, body);
if (resp.statusCode() == 200) {
session = GSON.fromJson(resp.body(), AuthSession.class);
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
LauncherLogger.info("authRequest: login successful, expiresAt=" + session.expiresAt
+ " hasRefresh=" + (session.refreshToken != null));
saveSession();
userInfo = fetchUserInfo();
return AuthResult.ok();
} else if (resp.statusCode() == 422) {
return AuthResult.fail("Validation error: " + extractError(resp.body()));
} else {
return AuthResult.fail(extractError(resp.body()));
}
} catch (Exception e) {
e.printStackTrace();
return AuthResult.fail("Connection error: " + e.getMessage());
}
}
public static void logout() {
if (session != null && session.refreshToken != null) {
try {
JsonObject json = new JsonObject();
json.addProperty("refresh_token", session.refreshToken);
post("/auth/logout", json.toString());
} catch (Exception e) {
LauncherLogger.warn("Logout error: " + e.getMessage());
}
}
session = null;
userInfo = null;
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception e) {
LauncherLogger.warn("Failed to delete auth.json: " + e.getMessage());
}
}
public static boolean isLoggedIn() {
return session != null && session.accessToken != null;
}
public static boolean authFileExists() {
return Files.exists(AUTH_FILE);
}
public static String getUsername() {
AuthSession localSession = session;
return localSession != null ? localSession.username : "Player";
}
public static String getUuid() {
AuthSession localSession = session;
return localSession != null ? localSession.uuid : "00000000-0000-0000-0000-000000000000";
}
public static String getAccessToken() {
AuthSession localSession = session;
if (localSession == null) return "0";
if (isAccessTokenExpired()) {
boolean refreshed = tryRefresh();
if (!refreshed) {
localSession = session;
if (localSession == null) return "0";
return localSession.accessToken != null ? localSession.accessToken : "0";
}
}
localSession = session;
return localSession != null && localSession.accessToken != null ? localSession.accessToken : "0";
}
private static boolean isAccessTokenExpired() {
if (session == null) return true;
return System.currentTimeMillis() / 1000L >= session.expiresAt - 300;
}
private static boolean tryRefresh() {
if (session == null) {
LauncherLogger.warn("tryRefresh: session is null");
return false;
}
if (session.refreshToken == null) {
LauncherLogger.warn("tryRefresh: no refreshToken in session");
return false;
}
try {
JsonObject json = new JsonObject();
json.addProperty("refresh_token", session.refreshToken);
SimpleHttpResponse resp = post("/auth/refresh", json.toString());
if (resp.statusCode() == 200) {
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
session = newSession;
userInfo = fetchUserInfo();
if (userInfo != null) {
session.role = userInfo.role;
}
saveSession();
LauncherLogger.info("tryRefresh: token refreshed successfully");
return true;
}
if (resp.statusCode() == 401) {
LauncherLogger.warn("tryRefresh: server rejected refresh token (401)");
invalidateSession();
} else {
LauncherLogger.warn("tryRefresh: server returned " + resp.statusCode());
}
} catch (Exception e) {
LauncherLogger.warn("tryRefresh: network error: " + e.getMessage());
return false;
}
return false;
}
private static void invalidateSession() {
session = null;
userInfo = null;
try {
Files.deleteIfExists(AUTH_FILE);
LauncherLogger.info("Session invalidated, auth.json deleted");
} catch (Exception e) {
LauncherLogger.error("Failed to delete auth.json", e);
}
}
private static void saveSession() {
try {
Files.createDirectories(AUTH_FILE.getParent());
Files.writeString(AUTH_FILE, GSON.toJson(session));
LauncherLogger.info("Session saved to " + AUTH_FILE);
} catch (IOException e) {
LauncherLogger.error("Failed to save session", e);
}
}
private static UserInfo fetchUserInfo() {
if (!isLoggedIn() || session.accessToken == null) return null;
try {
java.net.HttpURLConnection conn = null;
try {
URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me");
conn = (java.net.HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
int responseCode = conn.getResponseCode();
if (responseCode != 200) return null;
StringBuilder response = new StringBuilder();
try (var reader = new java.io.BufferedReader(
new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
return GSON.fromJson(response.toString(), UserInfo.class);
} finally {
if (conn != null) conn.disconnect();
}
} catch (Exception e) {
LauncherLogger.warn("Failed to get UserInfo: " + e.getMessage());
return null;
}
}
public static boolean hasPass() {
if (!isLoggedIn()) return false;
if (userInfo != null) return userInfo.has_pass;
if (getRole() >= ROLE_PASS_HOLDER) return true;
try {
String response = ZHttpClient.get("/auth/pass/my");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
if (json.has("has_active")) {
return json.get("has_active").getAsBoolean();
}
} catch (Exception e) {
LauncherLogger.warn("Failed to check pass: " + e.getMessage());
}
return false;
}
public static boolean canViewPacks() {
if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_VIEW_PACKS);
}
return hasPass();
}
public static boolean canDownloadPacks() {
if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
}
return hasPass();
}
public static int getRole() {
return session != null ? session.role : ROLE_USER;
}
public static String getRoleName() {
if (userInfo != null && userInfo.role_name != null) {
return userInfo.role_name;
}
return "USER";
}
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
HttpURLConnection conn = null;
try {
URL url = new URL(fullUrl);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
conn.setRequestProperty("Connection", "close");
if (session != null && session.accessToken != null) {
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
}
conn.setDoOutput(true);
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
conn.setFixedLengthStreamingMode(input.length);
try (var os = conn.getOutputStream()) {
os.write(input);
os.flush();
}
int statusCode = conn.getResponseCode();
InputStream is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
String responseBody;
if (is != null) {
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
}
} else {
responseBody = "No response body (status " + statusCode + ")";
}
return new SimpleHttpResponse(statusCode, responseBody);
} finally {
if (conn != null) conn.disconnect();
}
}
private static String extractError(String body) {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("detail")) {
if (json.get("detail").isJsonArray()) {
return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
}
return json.get("detail").getAsString();
}
} catch (Exception ignored) {}
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
}
public static void updateRole(int newRole) {
if (session != null) {
session.role = newRole;
saveSession();
}
refreshUserInfo();
}
public static void refreshUserInfo() {
UserInfo fresh = fetchUserInfo();
if (fresh != null) {
userInfo = fresh;
if (session != null) {
session.role = fresh.role;
}
}
if (session != null) {
saveSession();
}
}
public static boolean hasActivePass() {
if (!isLoggedIn()) return false;
return hasPass();
}
public static String getPassStatus() {
if (!isLoggedIn()) return "Not logged in";
try {
String response = ZHttpClient.get("/auth/pass/my");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean();
return hasActive ? "Active pass" : "No pass";
} catch (Exception e) {
return "Check error";
}
}
public static class AuthSession {
@SerializedName("access_token") public String accessToken;
@SerializedName("refresh_token") public String refreshToken;
@SerializedName("expires_in") public int expiresIn;
public long expiresAt;
public String username;
public String uuid;
public int role;
}
public static class UserInfo {
public int id;
public String username;
public String uuid;
public int role;
public String role_name;
public boolean has_pass;
public List<String> permissions;
public boolean hasPermission(String perm) {
return permissions != null && permissions.contains(perm);
}
}
private static class LoginRequest {
final String username;
final String password;
LoginRequest(String u, String p) {
this.username = u;
this.password = p;
}
}
public static class AuthResult {
public final boolean success;
public final String error;
private AuthResult(boolean s, String e) { success = s; error = e; }
public static AuthResult ok() { return new AuthResult(true, null); }
public static AuthResult fail(String msg) { return new AuthResult(false, msg); }
}
// === TEST HELPERS ===
static void resetForTest() {
session = null;
userInfo = null;
}
static void setTestSession(AuthSession s) {
session = s;
}
static void setTestUserInfo(UserInfo u) {
userInfo = u;
}
}
class SimpleHttpResponse {
final int statusCode;
final String body;
SimpleHttpResponse(int statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
int statusCode() { return statusCode; }
String body() { return body; }
}
@@ -0,0 +1,767 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.Input;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.awt.*;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class LaunchMenu {
public void show() throws Exception {
if (Config.isZernMCBuild()) {
showZernMCOnly();
} else {
showGlobal();
}
}
private void showZernMCOnly() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
System.out.println(ZAnsi.cyan("Server packs only"));
if (!awaitActivePass()) {
return;
}
PackDownloader tempDownloader = new PackDownloader(null);
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("No packs available on the server."));
ConsoleUtils.pause();
return;
}
List<String> options = availablePacks.stream()
.map(p -> String.format("%s [%s + %s v%d] - %d files",
p.getName(),
p.getMinecraftVersion(),
p.getLoaderType(),
p.getVersion(),
p.getFilesCount()))
.collect(Collectors.toList());
options.add("Back to main menu");
ArrowMenu menu = new ArrowMenu("Select a pack", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return;
ServerPack selected = availablePacks.get(choice);
installAndRunServerPack(selected);
}
}
private boolean awaitActivePass() throws Exception {
if (AuthManager.hasActivePass()) {
System.out.println(ZAnsi.brightGreen("✓ Active pass confirmed"));
return true;
}
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightRed("You don't have an active pass!"));
System.out.println(ZAnsi.white("Access to ZernMC packs requires an active pass."));
System.out.println();
openActivationWebsite();
System.out.println(ZAnsi.cyan("Waiting for pass activation... (checking every 10 seconds)"));
System.out.println(ZAnsi.white("Press Enter to cancel"));
for (int i = 0; i < 60; i++) {
try {
if (System.in.available() > 0) {
Input.readLine();
System.out.println(ZAnsi.yellow("\nWaiting cancelled."));
return false;
}
} catch (Exception ignored) {}
Thread.sleep(10000);
if (AuthManager.hasActivePass()) {
System.out.println(ZAnsi.brightGreen("\n✓ Pass activated successfully!"));
return true;
}
System.out.print(ZAnsi.cyan("."));
if ((i + 1) % 6 == 0) System.out.println();
}
System.out.println(ZAnsi.brightRed("\n\nWaiting time expired."));
return false;
}
private void openActivationWebsite() {
String url = ZHttpClient.getBaseUrl() + "/activate-pass";
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(new URI(url));
System.out.println(ZAnsi.cyan("Browser opened: " + url));
} else {
System.out.println(ZAnsi.yellow("Could not open browser automatically."));
System.out.println(ZAnsi.white("Open manually: " + url));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error opening browser: " + e.getMessage()));
System.out.println(ZAnsi.white("Link: " + url));
}
}
private void installAndRunServerPack(ServerPack selected) throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Installing pack: " + selected.getName()));
System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion());
System.out.println(ZAnsi.white(" Loader: ") + selected.getLoaderType() +
(selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : ""));
System.out.println(ZAnsi.white(" Version: v") + selected.getVersion());
System.out.println(ZAnsi.white(" Files: ") + selected.getFilesCount());
String localName = askPackName();
if (localName == null) return;
if (InstanceManager.getInstance(localName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
ConsoleUtils.pause();
return;
}
InstanceManager.createInstanceFolder(localName);
Instance newInstance = InstanceManager.getInstance(localName);
PackDownloader packDownloader = new PackDownloader(newInstance);
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
if (!success) {
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
ConsoleUtils.pause();
return;
}
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
ConsoleUtils.pause();
launchExistingInstance(newInstance);
}
private void showGlobal() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
List<Instance> instances = InstanceManager.getAllInstances();
List<String> options = instances.stream()
.map(Instance::toString)
.collect(Collectors.toList());
options.add("Install new pack");
options.add("Back to main menu");
ArrowMenu menu = new ArrowMenu("Manage packs", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) break;
if (choice == instances.size()) {
installNewPackGlobal();
continue;
}
Instance selected = instances.get(choice);
manageInstance(selected);
}
}
private void installNewPackGlobal() throws Exception {
ConsoleUtils.clearScreen();
List<String> options = List.of(
"Install pack from ZernMC server",
"Install Vanilla Minecraft",
"Create custom pack (Fabric/Forge)",
"Back"
);
ArrowMenu menu = new ArrowMenu("Install new pack", options);
int choice = menu.show();
if (choice == -1 || choice == 3) return;
switch (choice) {
case 0 -> installServerPackGlobal();
case 1 -> createVanillaInstance();
case 2 -> createCustomInstance();
}
}
private void installServerPackGlobal() throws Exception {
if (!awaitActivePass()) return;
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Fetching available packs..."));
PackDownloader tempDownloader = new PackDownloader(null);
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("No packs available on the server."));
ConsoleUtils.pause();
return;
}
List<String> options = availablePacks.stream()
.map(p -> String.format("%s [%s + %s v%d] - %d files",
p.getName(),
p.getMinecraftVersion(),
p.getLoaderType(),
p.getVersion(),
p.getFilesCount()))
.collect(Collectors.toList());
options.add("Back");
ArrowMenu menu = new ArrowMenu("Select a pack to install", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return;
ServerPack selected = availablePacks.get(choice);
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Installing pack: " + selected.getName()));
System.out.print(ZAnsi.white("\nEnter local pack name (Enter = pack name): "));
String localName = Input.readLine().trim();
if (localName.isEmpty()) localName = selected.getName();
if (InstanceManager.getInstance(localName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
ConsoleUtils.pause();
return;
}
InstanceManager.createInstanceFolder(localName);
Instance newInstance = InstanceManager.getInstance(localName);
PackDownloader packDownloader = new PackDownloader(newInstance);
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
} else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
}
ConsoleUtils.pause();
}
private void manageInstance(Instance instance) throws Exception {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Managing pack: " + instance.getName()));
System.out.println(ZAnsi.white("Version: " + instance.getMinecraftVersion()));
System.out.println(ZAnsi.white("Loader: " + instance.getLoaderType() +
(instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : "")));
if (instance.isServerPack()) {
System.out.println(ZAnsi.green("Server pack: v" + instance.getServerVersion()));
}
List<String> options = new ArrayList<>();
options.add("Launch pack");
if (instance.isServerPack()) {
options.add("Check for updates");
}
options.add("Change loader version");
options.add("Delete pack");
options.add("Back");
ArrowMenu menu = new ArrowMenu("Actions", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return;
switch (choice) {
case 0 -> launchExistingInstance(instance);
case 1 -> {
if (instance.isServerPack()) {
checkAndUpdateServerPack(instance);
} else {
changeLoaderVersion(instance);
}
}
case 2 -> {
if (instance.isServerPack()) {
changeLoaderVersion(instance);
} else {
deleteInstance(instance);
}
}
case 3 -> deleteInstance(instance);
}
}
}
private void checkAndUpdateServerPack(Instance instance) throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName()));
PackDownloader downloader = new PackDownloader(instance);
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
if (!hasUpdate) {
System.out.println(ZAnsi.green("Pack is up to date (v" + instance.getServerVersion() + ")"));
ConsoleUtils.pause();
return;
}
System.out.println(ZAnsi.brightYellow("Update available!"));
if (Input.confirm("Update pack")) {
boolean success = downloader.updatePack(instance.getServerPackName());
if (success) {
System.out.println(ZAnsi.brightGreen("Pack updated successfully!"));
} else {
System.out.println(ZAnsi.brightRed("Failed to update pack."));
}
} else {
System.out.println(ZAnsi.yellow("Update cancelled."));
}
ConsoleUtils.pause();
}
private void changeLoaderVersion(Instance instance) throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Changing loader version for " + instance.getName()));
String currentLoader = instance.getLoaderType();
String mcVersion = instance.getMinecraftVersion();
if ("vanilla".equalsIgnoreCase(currentLoader)) {
System.out.println(ZAnsi.yellow("This is a vanilla instance. Cannot change loader."));
ConsoleUtils.pause();
return;
}
String newLoaderVersion;
if ("fabric".equalsIgnoreCase(currentLoader)) {
newLoaderVersion = askFabricLoaderVersion();
} else if ("neoforge".equalsIgnoreCase(currentLoader)) {
newLoaderVersion = askNeoForgeVersion(mcVersion);
} else {
newLoaderVersion = askForgeVersion(mcVersion);
}
if (newLoaderVersion == null) return;
System.out.println(ZAnsi.cyan("Reinstalling loader " + currentLoader + " -> " + newLoaderVersion + "..."));
MinecraftLib lib = new MinecraftLib(instance);
boolean success;
try {
if ("fabric".equalsIgnoreCase(currentLoader)) {
success = lib.installFabric(mcVersion, newLoaderVersion);
} else if ("neoforge".equalsIgnoreCase(currentLoader)) {
success = lib.installNeoForge(mcVersion, newLoaderVersion);
} else {
success = lib.installForge(mcVersion, newLoaderVersion);
}
if (success) {
System.out.println(ZAnsi.brightGreen("Loader version changed successfully!"));
} else {
System.out.println(ZAnsi.brightRed("Failed to change loader version."));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error changing loader: " + e.getMessage()));
}
ConsoleUtils.pause();
}
private void deleteInstance(Instance instance) throws IOException {
ConsoleUtils.clearScreen();
List<String> confirmOptions = List.of(
"Yes, delete pack",
"No, cancel"
);
ArrowMenu confirmMenu = new ArrowMenu(
"Are you sure you want to delete '" + instance.getName() + "'?",
confirmOptions
);
int choice = confirmMenu.show();
if (choice == 0) {
boolean deleted = InstanceManager.deleteInstance(instance.getName());
if (deleted) {
System.out.println(ZAnsi.brightGreen("Pack '" + instance.getName() + "' deleted successfully."));
} else {
System.out.println(ZAnsi.brightRed("Failed to delete pack."));
}
} else {
System.out.println(ZAnsi.yellow("Deletion cancelled."));
}
ConsoleUtils.pause();
}
private void launchExistingInstance(Instance instance) {
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightRed("Launching a server pack requires an active pass!"));
ConsoleUtils.pause();
return;
}
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
MinecraftLib lib = new MinecraftLib(instance);
LaunchOptions options = new LaunchOptions();
options.setMaxMemory(Config.getMaxMemory());
options.setWidth(Config.getWindowWidth());
options.setHeight(Config.getWindowHeight());
options.setJavaPath(Config.getJavaPath());
options.setUsername(AuthManager.getUsername());
options.setUuid(AuthManager.getUuid());
options.setAccessToken(AuthManager.getAccessToken());
try {
lib.launch(options);
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error launching: " + e.getMessage()));
e.printStackTrace();
}
ConsoleUtils.pause();
}
private String askPackName() {
System.out.print(ZAnsi.white("\nEnter new pack name: "));
String name = Input.readLine().trim();
if (name.isEmpty()) {
System.out.println(ZAnsi.yellow("Cancelled."));
return null;
}
return name;
}
private void createVanillaInstance() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList());
versionOptions.add("Back");
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
MinecraftVersion selectedMc = allVersions.get(versionChoice);
String mcVersion = selectedMc.getId();
String packName = askPackName();
if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
ConsoleUtils.pause();
return;
}
InstanceManager.createInstanceFolder(packName);
Instance newInstance = InstanceManager.getInstance(packName);
MinecraftLib lib = new MinecraftLib(newInstance);
boolean success = lib.installMinecraft(mcVersion);
if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla pack '" + packName + "' created successfully!"));
} else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to create pack."));
}
ConsoleUtils.pause();
}
private void createCustomInstance() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList());
versionOptions.add("Back");
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
MinecraftVersion selectedMc = allVersions.get(versionChoice);
String mcVersion = selectedMc.getId();
List<String> loaderOptions = buildLoaderOptions(mcVersion);
ArrowMenu loaderMenu = new ArrowMenu("Select mod loader for " + mcVersion, loaderOptions);
int loaderChoice = loaderMenu.show();
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
String selectedLoader = loaderOptions.get(loaderChoice);
if (selectedLoader.contains("Vanilla")) {
createVanillaInstance();
return;
}
String loaderType;
if (selectedLoader.contains("Fabric")) {
loaderType = "fabric";
} else if (selectedLoader.contains("NeoForge")) {
loaderType = "neoforge";
} else {
loaderType = "forge";
}
String loaderVersion;
if (loaderType.equals("fabric")) {
loaderVersion = askFabricLoaderVersion();
} else if (loaderType.equals("neoforge")) {
loaderVersion = askNeoForgeVersion(mcVersion);
} else {
loaderVersion = askForgeVersion(mcVersion);
}
if (loaderVersion == null) return;
String packName = askPackName();
if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
ConsoleUtils.pause();
return;
}
InstanceManager.createInstanceFolder(packName);
Instance newInstance = InstanceManager.getInstance(packName);
MinecraftLib lib = new MinecraftLib(newInstance);
boolean success;
if (loaderType.equals("fabric")) {
success = lib.installFabric(mcVersion, loaderVersion);
} else if (loaderType.equals("neoforge")) {
success = lib.installNeoForge(mcVersion, loaderVersion);
} else {
success = lib.installForge(mcVersion, loaderVersion);
}
if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + packName + "' installed successfully!"));
} else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to install pack."));
}
ConsoleUtils.pause();
}
private List<String> buildLoaderOptions(String mcVersion) {
List<String> options = new ArrayList<>();
if (isFabricSupported(mcVersion)) options.add("Fabric");
if (isNeoForgeSupported(mcVersion)) options.add("NeoForge");
if (isForgeSupported(mcVersion)) options.add("Forge");
options.add("Vanilla");
options.add("Back");
return options;
}
private boolean isFabricSupported(String version) {
return version.matches("^1\\.(1[4-9]|[2-9]\\d).*");
}
private boolean isForgeSupported(String version) {
if (version.matches("^1\\.2[2-9].*") || version.matches("^\\d{2}.*")) return false;
return version.matches("^1\\.(1[2-9]|[2-9]\\d).*") ||
version.matches("^1\\.20.*") || version.matches("^1\\.21.*");
}
private boolean isNeoForgeSupported(String version) {
return version.matches("^1\\.20\\.[1-9].*") ||
version.matches("^1\\.21.*") ||
version.matches("^\\d{2}\\..*");
}
private String askFabricLoaderVersion() throws Exception {
System.out.println(ZAnsi.cyan("Fetching Fabric Loader versions..."));
List<String> versions = ZHttpClient.getFabricLoaderVersions();
List<String> options = versions.stream()
.limit(30)
.map(v -> "Fabric Loader " + v)
.collect(Collectors.toList());
options.add("Back");
ArrowMenu menu = new ArrowMenu("Select Fabric Loader version", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null;
return versions.get(choice);
}
private String askForgeVersion(String mcVersion) throws Exception {
System.out.println(ZAnsi.cyan("Fetching Forge versions for " + mcVersion + "..."));
List<String> allForgeVersions = getAllForgeVersions();
List<String> compatibleVersions = allForgeVersions.stream()
.filter(v -> v.startsWith(mcVersion + "-"))
.map(v -> v.substring(mcVersion.length() + 1))
.collect(Collectors.toList());
if (compatibleVersions.isEmpty()) {
System.out.println(ZAnsi.yellow("No compatible Forge versions found for " + mcVersion));
ConsoleUtils.pause();
return null;
}
List<String> options = compatibleVersions.stream()
.limit(30)
.map(v -> "Forge " + v)
.collect(Collectors.toList());
options.add("Back");
ArrowMenu menu = new ArrowMenu("Select Forge version for " + mcVersion, options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null;
return compatibleVersions.get(choice);
}
private List<String> getAllForgeVersions() throws Exception {
String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
List<String> versions = new ArrayList<>();
int index = 0;
while ((index = xml.indexOf("<version>", index)) != -1) {
int start = index + 9;
int end = xml.indexOf("</version>", start);
if (end == -1) break;
String version = xml.substring(start, end).trim();
versions.add(version);
index = end;
}
versions.sort((a, b) -> b.compareTo(a));
return versions;
}
private String askNeoForgeVersion(String mcVersion) throws Exception {
System.out.println(ZAnsi.cyan("Fetching NeoForge versions for " + mcVersion + "..."));
List<String> allNeoForgeVersions = getAllNeoForgeVersions();
List<String> compatibleVersions = allNeoForgeVersions.stream()
.filter(v -> isNeoForgeVersionCompatible(v, mcVersion))
.collect(Collectors.toList());
if (compatibleVersions.isEmpty()) {
System.out.println(ZAnsi.yellow("No compatible NeoForge versions found for " + mcVersion));
ConsoleUtils.pause();
return null;
}
List<String> options = compatibleVersions.stream()
.limit(30)
.map(v -> "NeoForge " + v)
.collect(Collectors.toList());
options.add("Back");
ArrowMenu menu = new ArrowMenu("Select NeoForge version for " + mcVersion, options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null;
return compatibleVersions.get(choice);
}
private boolean isNeoForgeVersionCompatible(String version, String mcVersion) {
if (mcVersion.equals("1.20.1")) {
return version.startsWith("47.");
}
String majorMinor = mcVersion.replace("1.", "");
String[] parts = majorMinor.split("\\.");
int targetMajor = Integer.parseInt(parts[0]);
return version.startsWith(targetMajor + ".");
}
private List<String> getAllNeoForgeVersions() throws Exception {
List<String> versions = new ArrayList<>();
String[] mavenUrls = {
"https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml",
"https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml"
};
for (String mavenUrl : mavenUrls) {
try {
String xml = ZHttpClient.downloadString(mavenUrl);
int index = 0;
while ((index = xml.indexOf("<version>", index)) != -1) {
int start = index + 9;
int end = xml.indexOf("</version>", start);
if (end == -1) break;
String version = xml.substring(start, end).trim();
if (!versions.contains(version)) {
versions.add(version);
}
index = end;
}
} catch (Exception e) {
}
}
versions.sort((a, b) -> b.compareTo(a));
return versions;
}
}
@@ -0,0 +1,186 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.auth.AuthManager.AuthResult;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.Input;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.IOException;
import java.util.List;
public class LoginMenu {
public boolean show() throws IOException {
while (true) {
ConsoleUtils.clearScreen();
printBanner();
List<String> options = List.of(
"Sign In",
"Create Account",
"Exit Launcher"
);
ArrowMenu menu = new ArrowMenu("Welcome to ZernMC!", options);
int choice = menu.show();
if (choice == -1 || choice == 2) return false;
boolean success = switch (choice) {
case 0 -> doLogin();
case 1 -> doRegister();
default -> false;
};
if (success) return true;
}
}
public void showAccountMenu() throws IOException {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== Account ==="));
System.out.println();
System.out.println(ZAnsi.white(" Player: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid()));
System.out.println();
List<String> options = List.of(
"Log Out",
"Back"
);
ArrowMenu menu = new ArrowMenu("Account Management", options);
int choice = menu.show();
if (choice == 0) {
AuthManager.logout();
System.out.println(ZAnsi.yellow("Logged out."));
ConsoleUtils.pause();
}
}
private boolean doLogin() throws IOException {
ConsoleUtils.clearScreen();
printBanner();
System.out.println(ZAnsi.cyan(" [ Sign In ]"));
System.out.println();
String username = Input.readLine(ZAnsi.white(" Username: "));
if (username.isEmpty()) return false;
String password = readPassword(" Password: ");
if (password.isEmpty()) return false;
System.out.println();
System.out.print(ZAnsi.cyan(" Signing in..."));
AuthResult result = AuthManager.login(username, password);
if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Welcome, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause();
return true;
} else {
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
ConsoleUtils.pause();
return false;
}
}
private boolean doRegister() throws IOException {
ConsoleUtils.clearScreen();
printBanner();
System.out.println(ZAnsi.cyan(" [ Create Account ]"));
System.out.println();
System.out.println(ZAnsi.yellow(" Allowed characters: a-z, A-Z, 0-9, _"));
System.out.println(ZAnsi.yellow(" Name length: 3-16 chars | Password length: 6+ chars"));
System.out.println();
String username = Input.readLine(ZAnsi.white(" Username: "));
if (username.isEmpty()) return false;
String password = readPassword(" Password: ");
if (password.isEmpty()) return false;
String confirm = readPassword(" Confirm password: ");
if (!password.equals(confirm)) {
System.out.println(ZAnsi.brightRed("\n Passwords do not match!"));
ConsoleUtils.pause();
return false;
}
System.out.println();
System.out.print(ZAnsi.cyan(" Creating account..."));
AuthResult result = AuthManager.register(username, password);
if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Account created! Welcome, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause();
return true;
} else {
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
ConsoleUtils.pause();
return false;
}
}
private String readPassword(String prompt) throws IOException {
org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder()
.system(true)
.jna(true)
.build();
passTerminal.enterRawMode();
passTerminal.writer().print(prompt);
passTerminal.writer().flush();
StringBuilder password = new StringBuilder();
try {
while (true) {
int key = passTerminal.reader().read();
if (key == 27) {
int next = passTerminal.reader().read();
if (next == 91) {
passTerminal.reader().read();
}
continue;
}
if (key == 13 || key == 10) {
passTerminal.writer().println();
break;
} else if (key == 127 || key == 8) {
if (password.length() > 0) {
password.setLength(password.length() - 1);
passTerminal.writer().print("\b \b");
passTerminal.writer().flush();
}
} else if (key == 3) {
passTerminal.writer().println();
System.exit(0);
} else if (key >= 32 && key < 127) {
password.append((char) key);
passTerminal.writer().print('*');
passTerminal.writer().flush();
}
}
} finally {
passTerminal.close();
}
return password.toString();
}
private void printBanner() {
System.out.println(ZAnsi.header("╔══════════════════════════════╗"));
System.out.println(ZAnsi.header("║ ZernMC Launcher ║"));
System.out.println(ZAnsi.header("╚══════════════════════════════╝"));
System.out.println();
}
}
@@ -0,0 +1,141 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
public class ServerCheckMenu {
public void show() throws IOException {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Connection Diagnostics"));
List<String> options = List.of(
"Check ZernMC server connection",
"Check Mojang (Minecraft) access",
"Check Fabric Meta access",
"Check Forge Maven access",
"Back to main menu"
);
ArrowMenu menu = new ArrowMenu("Select check", options);
int choice = menu.show();
if (choice == -1 || choice == 4) {
return;
}
ConsoleUtils.clearScreen();
switch (choice) {
case 0 -> checkZernServer();
case 1 -> checkMojang();
case 2 -> checkFabric();
case 3 -> checkForge();
}
ConsoleUtils.pause();
}
}
private void checkZernServer() {
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
try {
String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("[OK] ZernMC server connected successfully!"));
System.out.println(ZAnsi.white("Server response: ") + response);
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Could not connect to ZernMC server"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
}
}
private void checkMojang() {
System.out.println(ZAnsi.cyan("Checking Mojang access..."));
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Mojang is accessible"));
} else {
System.out.println(ZAnsi.brightRed("[FAIL] Mojang returned code " + response.statusCode()));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Mojang"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
}
}
private void checkFabric() {
System.out.println(ZAnsi.cyan("Checking Fabric Meta access..."));
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://meta.fabricmc.net/v2/versions"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta is accessible"));
} else {
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta returned code " + response.statusCode()));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Fabric Meta"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
}
}
private void checkForge() {
System.out.println(ZAnsi.cyan("Checking Forge Maven access..."));
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven is accessible"));
} else {
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven returned code " + response.statusCode()));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Forge Maven"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
}
}
}
@@ -0,0 +1,68 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.Input;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.IOException;
import java.util.List;
public class SettingsMenu {
public void show() throws IOException {
List<String> options = List.of(
"Configure Java path",
"Configure allocated RAM",
"Additional JVM parameters",
"Back to main menu"
);
ArrowMenu menu = new ArrowMenu("Launcher Settings", options);
int choice = menu.show();
if (choice == -1 || choice == 3) return;
ConsoleUtils.clearScreen();
switch (choice) {
case 0 -> configureJava();
case 1 -> configureRam();
case 2 -> configureJvmArgs();
}
ConsoleUtils.pause();
}
private void configureJava() {
System.out.println(ZAnsi.cyan("Java path:"));
System.out.println(" " + Config.getJreDir().toAbsolutePath());
System.out.println(ZAnsi.white("\nJava will be searched automatically in ~/.zernmc/jre/"));
System.out.println("If needed, place your own Java version there.");
}
private void configureRam() {
System.out.println(ZAnsi.cyan("RAM Allocation"));
System.out.println(Config.getRamInfo());
int newRam = Input.readInt(
ZAnsi.white("\nEnter new RAM value in MB (or 0 to cancel): "),
0, 32768
);
if (newRam == 0) {
System.out.println(ZAnsi.yellow("Setting cancelled."));
return;
}
Config.setMaxMemory(newRam);
System.out.println(ZAnsi.brightGreen("Allocated RAM changed to " + newRam + " MB"));
}
private void configureJvmArgs() {
System.out.println(ZAnsi.yellow("Additional JVM parameters"));
System.out.println("Currently in development.");
System.out.println("A list of preset optimizations will be available in the future.");
}
}
@@ -18,12 +18,12 @@ public class UpdateMenu {
public void show() throws IOException { public void show() throws IOException {
List<String> options = List.of( List<String> options = List.of(
"Проверить обновления сборки (модпака)", "Check pack updates",
"Проверить обновления лаунчера", "Check launcher updates",
"Назад в главное меню" "Back to main menu"
); );
ArrowMenu menu = new ArrowMenu("Проверка обновлений", options); ArrowMenu menu = new ArrowMenu("Update Check", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == 2) return; if (choice == -1 || choice == 2) return;
@@ -34,7 +34,7 @@ public class UpdateMenu {
try { try {
checkPackUpdates(); checkPackUpdates();
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage())); System.out.println(ZAnsi.brightRed("Error: " + e.getMessage()));
e.printStackTrace(); e.printStackTrace();
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
@@ -44,7 +44,7 @@ public class UpdateMenu {
} }
private void checkPackUpdates() throws Exception { private void checkPackUpdates() throws Exception {
System.out.println(ZAnsi.cyan("Проверка обновлений сборок...")); System.out.println(ZAnsi.cyan("Checking pack updates..."));
List<Instance> instances = InstanceManager.getAllInstances(); List<Instance> instances = InstanceManager.getAllInstances();
List<Instance> serverInstances = instances.stream() List<Instance> serverInstances = instances.stream()
@@ -52,12 +52,12 @@ public class UpdateMenu {
.collect(Collectors.toList()); .collect(Collectors.toList());
if (serverInstances.isEmpty()) { if (serverInstances.isEmpty()) {
System.out.println(ZAnsi.yellow("Нет сборок, установленных с сервера.")); System.out.println(ZAnsi.yellow("No server-installed packs found."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
System.out.println(ZAnsi.cyan("\nПроверка обновлений для серверных сборок:\n")); System.out.println(ZAnsi.cyan("\nChecking updates for server packs:\n"));
boolean hasUpdates = false; boolean hasUpdates = false;
List<Instance> updatableInstances = new ArrayList<>(); List<Instance> updatableInstances = new ArrayList<>();
@@ -68,42 +68,41 @@ public class UpdateMenu {
try { try {
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
if (hasUpdate) { if (hasUpdate) {
System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!")); System.out.println(ZAnsi.yellow(instance.getName() + " - Update available!"));
updatableInstances.add(instance); updatableInstances.add(instance);
hasUpdates = true; hasUpdates = true;
} else { } else {
System.out.println(ZAnsi.green(instance.getName() + " - Актуальна")); System.out.println(ZAnsi.green(instance.getName() + " - Up to date"));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.red(instance.getName() + " - Ошибка проверки: " + e.getMessage())); System.out.println(ZAnsi.red(instance.getName() + " - Check error: " + e.getMessage()));
} }
} }
if (!hasUpdates) { if (!hasUpdates) {
System.out.println(ZAnsi.green("\nВсе сборки актуальны!")); System.out.println(ZAnsi.green("\nAll packs are up to date!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
// Предлагаем обновить каждую сборку отдельно
for (Instance instance : updatableInstances) { for (Instance instance : updatableInstances) {
System.out.println(ZAnsi.brightYellow("\nОбновить сборку '" + instance.getName() + "'?")); System.out.println(ZAnsi.brightYellow("\nUpdate pack '" + instance.getName() + "'?"));
if (Input.confirm("Обновить")) { if (Input.confirm("Update")) {
System.out.println(ZAnsi.cyan("Обновление " + instance.getName() + "...")); System.out.println(ZAnsi.cyan("Updating " + instance.getName() + "..."));
PackDownloader downloader = new PackDownloader(instance); PackDownloader downloader = new PackDownloader(instance);
try { try {
boolean success = downloader.updatePack(instance.getServerPackName()); boolean success = downloader.updatePack(instance.getServerPackName());
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen(instance.getName() + " обновлен")); System.out.println(ZAnsi.brightGreen(instance.getName() + " updated"));
} else { } else {
System.out.println(ZAnsi.brightRed(instance.getName() + " не удалось обновить")); System.out.println(ZAnsi.brightRed(instance.getName() + " update failed"));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage())); System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage()));
} }
} else { } else {
System.out.println(ZAnsi.yellow(" Пропущено: " + instance.getName())); System.out.println(ZAnsi.yellow(" Skipped: " + instance.getName()));
} }
} }
@@ -111,28 +110,27 @@ public class UpdateMenu {
} }
private void checkLauncherUpdates() { private void checkLauncherUpdates() {
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера...")); System.out.println(ZAnsi.cyan("Checking launcher updates..."));
try { try {
String json = ZHttpClient.getLauncherVersionInfo(); String json = ZHttpClient.getLauncherVersionInfo();
String serverVersion = extractVersion(json); String serverVersion = extractVersion(json);
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion(); String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
System.out.println(ZAnsi.white("Текущая версия: ") + currentVersion); System.out.println(ZAnsi.white("Current version: ") + currentVersion);
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion); System.out.println(ZAnsi.white("Server version: ") + serverVersion);
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) { if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия!")); System.out.println(ZAnsi.brightYellow("\nNew version available!"));
if (Input.confirm("Обновить лаунчер?")) { if (Input.confirm("Update launcher?")) {
// Обновление будет при следующем запуске System.out.println(ZAnsi.green("Launcher will be updated on next restart."));
System.out.println(ZAnsi.green("Лаунчер будет обновлен при следующем запуске."));
} }
} else { } else {
System.out.println(ZAnsi.brightGreen("Лаунчер актуален.")); System.out.println(ZAnsi.brightGreen("Launcher is up to date."));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера.")); System.out.println(ZAnsi.yellow("Could not check launcher updates."));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); System.out.println(ZAnsi.white("Error: ") + e.getMessage());
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -149,4 +147,4 @@ public class UpdateMenu {
return "unknown"; return "unknown";
} }
} }
} }
@@ -14,7 +14,7 @@ public class Instance {
private final Path path; private final Path path;
private String minecraftVersion; private String minecraftVersion;
private String loaderType; // vanilla, fabric, forge private String loaderType; // vanilla, fabric, forge, neoforge
private String loaderVersion; private String loaderVersion;
private String assetIndex; private String assetIndex;
private boolean isServerPack; // флаг, что это сборка с сервера private boolean isServerPack; // флаг, что это сборка с сервера
@@ -2,13 +2,19 @@ package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
@@ -41,12 +47,17 @@ public class MinecraftLib {
return installer.install(minecraftVersion, forgeVersion); return installer.install(minecraftVersion, forgeVersion);
} }
public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception {
NeoForgeInstaller installer = new NeoForgeInstaller(instance);
return installer.install(minecraftVersion, neoforgeVersion);
}
public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception { public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception {
FabricInstaller installer = new FabricInstaller(instance); FabricInstaller installer = new FabricInstaller(instance);
boolean success = installer.install(minecraftVersion, loaderVersion); boolean success = installer.install(minecraftVersion, loaderVersion);
if (success) { if (success) {
// Сохраняем информацию в Instance // Save info to Instance
instance.setMinecraftVersion(minecraftVersion); instance.setMinecraftVersion(minecraftVersion);
instance.setLoaderType("fabric"); instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVersion); instance.setLoaderVersion(loaderVersion);
@@ -55,72 +66,110 @@ public class MinecraftLib {
} }
/** /**
* Полная установка сборки (vanilla + loader + моды) * Full pack install (vanilla + loader + mods)
* Пока заглушка будем расширять * Stub - will be expanded
*/ */
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception { public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
System.out.println(ZAnsi.cyan("Начинается полная установка сборки: " + packName)); System.out.println(ZAnsi.cyan("Starting full pack install: " + packName));
// 1. Устанавливаем Minecraft // 1. Install Minecraft
boolean mcInstalled = installMinecraft(minecraftVersion); boolean mcInstalled = installMinecraft(minecraftVersion);
if (!mcInstalled) { if (!mcInstalled) {
System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion)); System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion));
return false; return false;
} }
// 2. Устанавливаем лоадер // 2. Install loader
if ("fabric".equalsIgnoreCase(loaderType)) { if ("fabric".equalsIgnoreCase(loaderType)) {
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion); boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
if (!fabricInstalled) { if (!fabricInstalled) {
System.out.println(ZAnsi.brightRed("Не удалось установить Fabric")); System.out.println(ZAnsi.brightRed("Failed to install Fabric"));
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(loaderType)) { } else if ("forge".equalsIgnoreCase(loaderType)) {
System.out.println(ZAnsi.yellow("Forge пока не поддерживается")); boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
return false; if (!forgeInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Forge"));
return false;
}
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
if (!neoforgeInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install NeoForge"));
return false;
}
} }
// 3. В будущем здесь будет diff и скачивание модов // 3. In the future: diff and mod download
System.out.println(ZAnsi.brightGreen("Базовая установка сборки завершена!")); System.out.println(ZAnsi.brightGreen("Basic pack install complete!"));
return true; return true;
} }
//Запуск //Launch
public void launch(LaunchOptions options) throws Exception { public void launch(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName())); System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
cleanupOldLoaders(); cleanupOldLoaders();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options); List<String> command = builder.build(options);
System.out.println(ZAnsi.cyan("Команда запуска (" + command.size() + " аргументов):")); System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):"));
command.forEach(arg -> System.out.println(" " + arg)); command.forEach(arg -> System.out.println(" " + arg));
ProcessBuilder pb = new ProcessBuilder(command); ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(instance.getPath().toFile()); pb.directory(instance.getPath().toFile());
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n")); System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
Process process = pb.start(); Process process = pb.start();
int exitCode = process.waitFor();
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode)); // Capture output
Thread outThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog(line);
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Error reading output: " + e.getMessage() + "]");
}
});
outThread.setDaemon(true);
outThread.start();
// Capture errors
Thread errThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog("[ERR] " + line);
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
}
});
errThread.setDaemon(true);
errThread.start();
int exitCode = process.waitFor();
outThread.join(1000);
errThread.join(1000);
System.out.println(ZAnsi.yellow("\nMinecraft exited with code: " + exitCode));
} }
private void safeDeleteDirectory(Path dir) { private void safeDeleteDirectory(Path dir) {
try { try (var stream = Files.walk(dir)) {
Files.walk(dir) stream.sorted((a, b) -> b.compareTo(a))
.sorted((a, b) -> b.compareTo(a)) .forEach(p -> {
.forEach(p -> { try { Files.deleteIfExists(p); }
try { Files.deleteIfExists(p); } catch (IOException e) { /* ignore */ }
catch (IOException ignored) {} });
}); } catch (IOException e) {
} catch (IOException ignored) {} LauncherLogger.warn("safeDeleteDirectory: " + e.getMessage());
}
} }
private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException { private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException {
@@ -129,7 +178,8 @@ public class MinecraftLib {
try (var stream = Files.walk(versionsDir)) { try (var stream = Files.walk(versionsDir)) {
stream.filter(Files::isDirectory) stream.filter(Files::isDirectory)
.filter(dir -> dir.getFileName().toString().contains("fabric-loader") || .filter(dir -> dir.getFileName().toString().contains("fabric-loader") ||
dir.getFileName().toString().contains("forge")) dir.getFileName().toString().contains("forge") ||
dir.getFileName().toString().contains("neoforge"))
.filter(dir -> !dir.getFileName().toString().contains(keepVersion)) .filter(dir -> !dir.getFileName().toString().contains(keepVersion))
.forEach(this::safeDeleteDirectory); .forEach(this::safeDeleteDirectory);
} }
@@ -154,15 +204,17 @@ public class MinecraftLib {
if (currentLoaderVer == null) return; if (currentLoaderVer == null) return;
System.out.println(ZAnsi.yellow("Выполняем очистку старых версий лоадера...")); System.out.println(ZAnsi.yellow("Cleaning old loader versions..."));
// Удаляем все старые fabric-loader / forge // Delete all old fabric-loader / forge
Path libraries = instance.getPath().resolve("libraries"); Path libraries = instance.getPath().resolve("libraries");
if ("fabric".equals(loaderType)) { if ("fabric".equals(loaderType)) {
deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer); deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer);
} else if ("forge".equals(loaderType)) { } else if ("forge".equals(loaderType)) {
deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer); deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer);
} else if ("neoforge".equals(loaderType)) {
deleteAllExcept(libraries.resolve("net/neoforged/neoforge"), currentLoaderVer);
} }
// Также чистим versions/ от старых fabric/forge версий // Также чистим versions/ от старых fabric/forge версий
@@ -6,6 +6,9 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar; import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
@@ -17,6 +20,7 @@ import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
@@ -27,88 +31,104 @@ public class PackDownloader {
private final Instance instance; private final Instance instance;
private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
private final HttpClient httpClient = HttpClient.newHttpClient(); private final HttpClient httpClient = HttpClient.newHttpClient();
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; //private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
public PackDownloader(Instance instance) { public PackDownloader(Instance instance) {
this.instance = instance; this.instance = instance;
} }
/** /**
* Получить список доступных паков с сервера * Get list of available packs from server
*/ */
public List<ServerPack> getAvailablePacks() throws Exception { public List<ServerPack> getAvailablePacks() throws Exception {
String response = ZHttpClient.get("/packs"); String accessToken = AuthManager.getAccessToken();
if (accessToken == null) {
// Для отладки - выведем ответ сервера throw new IOException("Not authenticated. Active pass required to view packs.");
System.out.println(ZAnsi.cyan("Ответ сервера: " + response));
JsonObject root = JsonParser.parseString(response).getAsJsonObject();
// Проверяем, есть ли поле "packs"
if (!root.has("packs")) {
System.out.println(ZAnsi.yellow("Сервер вернул неожиданный формат ответа"));
return new ArrayList<>();
} }
if (!AuthManager.canViewPacks()) {
throw new IOException("Active pass required to view packs");
}
// Use HttpURLConnection for GET with auth
java.net.HttpURLConnection connection = null;
try {
java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs");
connection = (java.net.HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
connection.setConnectTimeout(15000);
connection.setReadTimeout(15000);
int responseCode = connection.getResponseCode();
if (responseCode == 403) {
throw new IOException("Active pass required to view packs");
}
StringBuilder response = new StringBuilder();
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
if (responseCode != 200) {
throw new IOException("HTTP " + responseCode);
}
return parsePacksResponse(response.toString());
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private List<ServerPack> parsePacksResponse(String responseBody) {
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray packsArray = root.getAsJsonArray("packs"); JsonArray packsArray = root.getAsJsonArray("packs");
List<ServerPack> result = new ArrayList<>(); List<ServerPack> result = new ArrayList<>();
for (JsonElement elem : packsArray) { for (JsonElement elem : packsArray) {
JsonObject pack = elem.getAsJsonObject(); JsonObject pack = elem.getAsJsonObject();
// Пропускаем паки с ошибками if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
if (pack.has("error")) {
System.out.println(ZAnsi.yellow("Пак имеет ошибку: " + pack.get("error").getAsString()));
continue; continue;
} }
// Пропускаем паки со статусом not_scanned
if (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString())) {
System.out.println(ZAnsi.yellow("Пак " + pack.get("name").getAsString() + " не отсканирован на сервере"));
continue;
}
try { try {
// Пробуем получить name или pack_name (разные форматы) String name = pack.get("name").getAsString();
String name = null;
if (pack.has("name")) {
name = pack.get("name").getAsString();
} else if (pack.has("pack_name")) {
name = pack.get("pack_name").getAsString();
} else {
continue; // Пропускаем если нет имени
}
int version = pack.has("version") ? pack.get("version").getAsInt() : 0; int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
// Получаем остальные поля (могут отсутствовать)
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown"; String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla"; String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() ? pack.get("loader_version").getAsString() : ""; String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull()
? pack.get("loader_version").getAsString() : "";
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0; int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
// Парсим дату, если есть
LocalDateTime updatedAt = null; LocalDateTime updatedAt = null;
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) { if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
try { try {
updatedAt = parseDateTime(pack.get("updated_at").getAsString()); updatedAt = LocalDateTime.parse(pack.get("updated_at").getAsString(),
} catch (Exception e) { DateTimeFormatter.ISO_DATE_TIME);
// Игнорируем ошибки парсинга даты } catch (Exception ignored) {}
}
} }
result.add(new ServerPack(name, version, minecraftVersion, result.add(new ServerPack(name, version, minecraftVersion, loaderType,
loaderType, loaderVersion, updatedAt, filesCount)); loaderVersion, updatedAt, filesCount));
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.yellow("Ошибка парсинга пака: " + e.getMessage())); LauncherLogger.warn("Error parsing pack: " + e.getMessage());
} }
} }
return result; return result;
} }
/** /**
* Получить манифест пака * Get pack manifest
*/ */
public PackManifest getPackManifest(String packName) throws Exception { public PackManifest getPackManifest(String packName) throws Exception {
String response = ZHttpClient.get("/pack/" + packName); String response = ZHttpClient.get("/pack/" + packName);
@@ -116,18 +136,18 @@ public class PackDownloader {
} }
/** /**
* Установить или обновить сборку с сервера * Install or update a pack from the server
*/ */
public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception { public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception {
System.out.println(ZAnsi.cyan("Установка сборки " + packName + " с сервера...")); LauncherLogger.info("Installing pack " + packName + " from server...");
// 1. Получаем манифест // 1. Get manifest
PackManifest manifest = getPackManifest(packName); PackManifest manifest = getPackManifest(packName);
// 2. Сначала устанавливаем Minecraft + Loader через MinecraftLib // 2. First install Minecraft + Loader via MinecraftLib
MinecraftLib lib = new MinecraftLib(instance); MinecraftLib lib = new MinecraftLib(instance);
System.out.println(ZAnsi.cyan("Установка Minecraft " + manifest.getMinecraftVersion() + "...")); System.out.println(ZAnsi.cyan("Installing Minecraft " + manifest.getMinecraftVersion() + "..."));
boolean needsMinecraftInstall = instance.getMinecraftVersion() == null || boolean needsMinecraftInstall = instance.getMinecraftVersion() == null ||
!instance.getMinecraftVersion().equals(manifest.getMinecraftVersion()); !instance.getMinecraftVersion().equals(manifest.getMinecraftVersion());
@@ -136,34 +156,40 @@ public class PackDownloader {
if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) { if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Не удалось установить Fabric")); System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
return false;
}
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Не удалось установить Forge")); System.err.println(ZAnsi.brightRed("Failed to install Forge"));
return false; return false;
} }
} else { } else {
boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Не удалось установить Vanilla Minecraft")); System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
return false; return false;
} }
} }
} else { } else {
System.out.println(ZAnsi.green("Minecraft уже установлен, пропускаем...")); System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
} }
// 3. Сканируем локальные файлы ТОЛЬКО если есть файлы для скачивания // 3. Scan local files only if there are files to download
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
// Если в сборке нет файлов (только vanilla/loader), пропускаем diff // If pack has no files (vanilla/loader only), skip diff
if (manifest.files == null || manifest.files.isEmpty()) { if (manifest.files == null || manifest.files.isEmpty()) {
System.out.println(ZAnsi.green("Сборка не содержит дополнительных файлов")); System.out.println(ZAnsi.green("Pack contains no additional files"));
// Обновляем метаданные инстанса // Update instance metadata
instance.setServerPack(true); instance.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
@@ -172,19 +198,19 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(manifest.getAssetIndex()); instance.setAssetIndex(manifest.getAssetIndex());
System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!")); System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
return true; return true;
} }
// 4. Отправляем diff запрос // 4. Send diff request
System.out.println(ZAnsi.cyan("Проверка файлов сборки...")); System.out.println(ZAnsi.cyan("Checking pack files..."));
DiffResponse diff = getDiff(packName, localFiles); DiffResponse diff = getDiff(packName, localFiles);
// 5. Применяем изменения // 5. Apply changes
boolean success = applyDiff(diff, packName); boolean success = applyDiff(diff, packName);
if (success) { if (success) {
// 6. Обновляем метаданные инстанса // 6. Update instance metadata
instance.setServerPack(true); instance.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
@@ -193,14 +219,14 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(manifest.getAssetIndex()); instance.setAssetIndex(manifest.getAssetIndex());
System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!")); System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
} }
return success; return success;
} }
/** /**
* Проверить наличие обновлений для серверной сборки * Check for server pack updates
*/ */
public boolean checkForUpdates(String packName) throws Exception { public boolean checkForUpdates(String packName) throws Exception {
if (!instance.isServerPack()) return false; if (!instance.isServerPack()) return false;
@@ -213,40 +239,40 @@ public class PackDownloader {
} }
/** /**
* Обновить существующую серверную сборку * Update an existing server pack
*/ */
public boolean updatePack(String packName) throws Exception { public boolean updatePack(String packName) throws Exception {
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName() + "...")); System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "..."));
PackManifest manifest = getPackManifest(packName); PackManifest manifest = getPackManifest(packName);
int serverVersion = manifest.getVersion(); int serverVersion = manifest.getVersion();
if (serverVersion <= instance.getServerVersion()) { if (serverVersion <= instance.getServerVersion()) {
System.out.println(ZAnsi.green("Сборка уже актуальна (v" + instance.getServerVersion() + ")")); System.out.println(ZAnsi.green("Pack is already up to date (v" + instance.getServerVersion() + ")"));
return true; return true;
} }
System.out.println(ZAnsi.yellow("Доступно обновление: v" + instance.getServerVersion() + " → v" + serverVersion)); System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " → v" + serverVersion));
// Сканируем локальные файлы // Scan local files
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
// Получаем diff // Get diff
DiffResponse diff = getDiff(packName, localFiles); DiffResponse diff = getDiff(packName, localFiles);
// Применяем изменения // Apply changes
boolean success = applyDiff(diff, packName); boolean success = applyDiff(diff, packName);
if (success) { if (success) {
instance.setServerVersion(serverVersion); instance.setServerVersion(serverVersion);
System.out.println(ZAnsi.brightGreen("Сборка обновлена до v" + serverVersion)); System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
} }
return success; return success;
} }
/** /**
* Сканирование локальных файлов и вычисление хешей * Scan local files and compute hashes
*/ */
private Map<String, String> scanLocalFiles() throws IOException { private Map<String, String> scanLocalFiles() throws IOException {
Map<String, String> files = new HashMap<>(); Map<String, String> files = new HashMap<>();
@@ -288,26 +314,23 @@ public class PackDownloader {
} }
/** /**
* Отправить diff запрос на сервер * Send diff request to server
*/ */
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception { private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
String json = gson.toJson(localFiles); String json = gson.toJson(localFiles);
System.out.println(ZAnsi.cyan("Отправка diff запроса для " + packName)); // Get auth token
System.out.println(ZAnsi.cyan("JSON размер: " + json.length() + " байт")); String accessToken = AuthManager.getAccessToken();
System.out.println(ZAnsi.cyan("JSON тело: " + json)); if (accessToken == null) {
throw new IOException("Not authenticated. Active pass required to download packs.");
String baseUrl = ZHttpClient.getBaseUrl();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
} }
String url = baseUrl + "/pack/" + packName + "/diff"; if (!AuthManager.canDownloadPacks()) {
throw new IOException("Active pass required to download packs");
System.out.println(ZAnsi.cyan("URL: " + url)); }
// ПРОБЛЕМА: стандартный HttpClient может отправлять chunked encoding String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
// РЕШЕНИЕ: используем HttpURLConnection вместо HttpClient
// Use HttpURLConnection for full control
java.net.HttpURLConnection connection = null; java.net.HttpURLConnection connection = null;
try { try {
java.net.URL urlObj = new java.net.URL(url); java.net.URL urlObj = new java.net.URL(url);
@@ -315,22 +338,22 @@ public class PackDownloader {
connection.setRequestMethod("POST"); connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "application/json"); connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length)); connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length));
connection.setDoOutput(true); connection.setDoOutput(true);
connection.setConnectTimeout(30000); connection.setConnectTimeout(30000);
connection.setReadTimeout(30000); connection.setReadTimeout(30000);
// Отправляем JSON // Send JSON
try (java.io.OutputStream os = connection.getOutputStream()) { try (java.io.OutputStream os = connection.getOutputStream()) {
byte[] input = json.getBytes("UTF-8"); byte[] input = json.getBytes("UTF-8");
os.write(input, 0, input.length); os.write(input, 0, input.length);
os.flush(); os.flush();
} }
int responseCode = connection.getResponseCode(); int responseCode = connection.getResponseCode();
System.out.println(ZAnsi.cyan("Diff ответ: HTTP " + responseCode));
// Read response
// Читаем ответ
StringBuilder response = new StringBuilder(); StringBuilder response = new StringBuilder();
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) { java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) {
@@ -339,16 +362,19 @@ public class PackDownloader {
response.append(line); response.append(line);
} }
} }
String responseBody = response.toString(); String responseBody = response.toString();
System.out.println(ZAnsi.cyan("Тело ответа: " + responseBody));
if (responseCode == 403) {
if (responseCode != 200) { throw new IOException("Active pass required to download packs. Contact the administrator.");
throw new IOException("HTTP " + responseCode + ": " + responseBody);
} }
if (responseCode != 200) {
throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody));
}
return gson.fromJson(responseBody, DiffResponse.class); return gson.fromJson(responseBody, DiffResponse.class);
} finally { } finally {
if (connection != null) { if (connection != null) {
connection.disconnect(); connection.disconnect();
@@ -356,35 +382,45 @@ public class PackDownloader {
} }
} }
private String extractErrorFromResponse(String body) {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("detail")) {
return json.get("detail").getAsString();
}
} catch (Exception ignored) {}
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
}
/** /**
* Применить diff (скачать новые файлы, удалить старые) * Apply diff (download new files, delete old ones)
*/ */
private boolean applyDiff(DiffResponse diff, String packName) { private boolean applyDiff(DiffResponse diff, String packName) {
System.out.println(ZAnsi.cyan("\nПрименение изменений:")); System.out.println(ZAnsi.cyan("\nApplying changes:"));
System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов"); System.out.println(" Download: " + diff.getToDownload().size() + " files");
System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов"); System.out.println(" Delete: " + diff.getToDelete().size() + " files");
// Создаем директории если нужно // Create directories if needed
try { try {
Files.createDirectories(instance.getPath()); Files.createDirectories(instance.getPath());
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage())); System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage()));
return false; return false;
} }
// Удаляем файлы // Delete files
for (String filePath : diff.getToDelete()) { for (String filePath : diff.getToDelete()) {
Path fullPath = instance.getPath().resolve(filePath); Path fullPath = instance.getPath().resolve(filePath);
try { try {
if (Files.deleteIfExists(fullPath)) { if (Files.deleteIfExists(fullPath)) {
System.out.println(ZAnsi.yellow(" Удален: " + filePath)); System.out.println(ZAnsi.yellow(" Deleted: " + filePath));
} }
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.red(" Ошибка удаления " + filePath + ": " + e.getMessage())); System.err.println(ZAnsi.red(" Error deleting " + filePath + ": " + e.getMessage()));
} }
} }
// Скачиваем файлы // Download files
AtomicInteger downloaded = new AtomicInteger(0); AtomicInteger downloaded = new AtomicInteger(0);
int total = diff.getToDownload().size(); int total = diff.getToDownload().size();
@@ -393,32 +429,32 @@ public class PackDownloader {
Path fullPath = instance.getPath().resolve(path); Path fullPath = instance.getPath().resolve(path);
try { try {
// Создаем директории // Create directories
Files.createDirectories(fullPath.getParent()); Files.createDirectories(fullPath.getParent());
// Скачиваем файл // Download file
downloadFile(file, fullPath); downloadFile(file, fullPath);
// Проверяем хеш // Verify hash
String actualHash = calculateHash(fullPath); String actualHash = calculateHash(fullPath);
if (!actualHash.equals(file.getHash())) { if (!actualHash.equals(file.getHash())) {
throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() + throw new IOException("Hash mismatch! Expected: " + file.getHash() +
", получен: " + actualHash); ", got: " + actualHash);
} }
downloaded.incrementAndGet(); downloaded.incrementAndGet();
if (total > 0) { if (total > 0) {
ProgressBar.show("Скачивание", downloaded.get(), total, "файлов"); ProgressBar.show("Download", downloaded.get(), total, "files");
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage())); System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage()));
return false; return false;
} }
} }
if (total > 0) { if (total > 0) {
ProgressBar.finish("Скачивание"); ProgressBar.finish("Download");
} }
return true; return true;
@@ -429,12 +465,19 @@ public class PackDownloader {
*/ */
private void downloadFile(FileInfo file, Path destination) throws Exception { private void downloadFile(FileInfo file, Path destination) throws Exception {
String url = ZHttpClient.getBaseUrl() + file.getUrl(); String url = ZHttpClient.getBaseUrl() + file.getUrl();
String accessToken = AuthManager.getAccessToken();
HttpRequest request = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(java.net.URI.create(url)) .uri(java.net.URI.create(url))
.GET() .timeout(Duration.ofSeconds(60))
.build(); .header("User-Agent", "ZernMC-Launcher/1.0")
.GET();
if (accessToken != null && !accessToken.equals("0")) {
builder.header("Authorization", "Bearer " + accessToken);
}
HttpRequest request = builder.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse<InputStream> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofInputStream()); HttpResponse.BodyHandlers.ofInputStream());
@@ -486,17 +529,6 @@ public class PackDownloader {
return sb.toString(); return sb.toString();
} }
/**
* Парсинг даты из строки
*/
private LocalDateTime parseDateTime(String dateTimeStr) {
try {
return LocalDateTime.parse(dateTimeStr, DATE_FORMATTER);
} catch (Exception e) {
return null;
}
}
// ====================== Вложенные классы ====================== // ====================== Вложенные классы ======================
public static class PackManifest { public static class PackManifest {
@@ -26,7 +26,7 @@ public class FabricInstaller {
} }
public boolean install(String minecraftVersion, String loaderVersion) throws Exception { public boolean install(String minecraftVersion, String loaderVersion) throws Exception {
System.out.println(ZAnsi.cyan("Установка Fabric " + loaderVersion + " для Minecraft " + minecraftVersion)); System.out.println(ZAnsi.cyan("Installing Fabric " + loaderVersion + " for Minecraft " + minecraftVersion));
Path instancePath = instance.getPath(); Path instancePath = instance.getPath();
cleanOldFabricLoaders(); cleanOldFabricLoaders();
@@ -34,7 +34,7 @@ public class FabricInstaller {
VersionInstaller versionInstaller = new VersionInstaller(instancePath); VersionInstaller versionInstaller = new VersionInstaller(instancePath);
String assetIndex = versionInstaller.install(minecraftVersion); String assetIndex = versionInstaller.install(minecraftVersion);
System.out.println(ZAnsi.green("Asset index получен: " + assetIndex)); System.out.println(ZAnsi.green("Asset index obtained: " + assetIndex));
instance.setAssetIndex(assetIndex); instance.setAssetIndex(assetIndex);
instance.setMinecraftVersion(minecraftVersion); instance.setMinecraftVersion(minecraftVersion);
@@ -46,12 +46,12 @@ public class FabricInstaller {
Path installerJar = instancePath.resolve("fabric-installer.jar"); Path installerJar = instancePath.resolve("fabric-installer.jar");
if (!Files.exists(installerJar)) { if (!Files.exists(installerJar)) {
ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%"); ProgressBar.show("Downloading Fabric Installer", 0, 100, "%");
downloadFileWithFallback(installerUrl, installerJar); downloadFileWithFallback(installerUrl, installerJar);
ProgressBar.finish("Fabric Installer скачан"); ProgressBar.finish("Fabric Installer downloaded");
} }
System.out.println(ZAnsi.cyan("Запуск Fabric Installer...")); System.out.println(ZAnsi.cyan("Running Fabric Installer..."));
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion; String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
@@ -71,24 +71,24 @@ public class FabricInstaller {
int exitCode = process.waitFor(); int exitCode = process.waitFor();
if (exitCode != 0) { if (exitCode != 0) {
System.out.println(ZAnsi.brightRed("Fabric Installer завершился с ошибкой (код " + exitCode + ")")); System.out.println(ZAnsi.brightRed("Fabric Installer failed (code " + exitCode + ")"));
return false; return false;
} }
Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId); Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId);
if (Files.exists(fabricVersionDir)) { if (Files.exists(fabricVersionDir)) {
System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!")); System.out.println(ZAnsi.brightGreen("Fabric installed successfully!"));
instance.setLoaderType("fabric"); instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVersion); instance.setLoaderVersion(loaderVersion);
instance.setFabricVersionId(fabricVersionId); // СОХРАНЯЕМ instance.setFabricVersionId(fabricVersionId);
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex); ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
return true; return true;
} else { } else {
System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена.")); System.out.println(ZAnsi.brightRed("Fabric Installer ran, but version not found."));
return false; return false;
} }
} }
@@ -97,7 +97,7 @@ public class FabricInstaller {
try { try {
ZHttpClient.downloadFile(url, target); ZHttpClient.downloadFile(url, target);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось скачать Fabric Installer: " + e.getMessage())); System.out.println(ZAnsi.yellow("Failed to download Fabric Installer: " + e.getMessage()));
throw e; throw e;
} }
} }
@@ -106,28 +106,28 @@ public class FabricInstaller {
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json"); Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
if (!Files.exists(versionJson)) { if (!Files.exists(versionJson)) {
System.out.println(ZAnsi.yellow("JSON файл версии не найден: " + versionJson)); System.out.println(ZAnsi.yellow("Version JSON file not found: " + versionJson));
return; return;
} }
String content = Files.readString(versionJson); String content = Files.readString(versionJson);
// Проверяем и исправляем asset index // Check and fix asset index
if (!content.contains("\"assets\":\"" + assetIndex + "\"")) { if (!content.contains("\"assets\":\"" + assetIndex + "\"")) {
System.out.println(ZAnsi.yellow("Исправляем asset index в JSON файле версии...")); System.out.println(ZAnsi.yellow("Fixing asset index in version JSON file..."));
// Заменяем assets на правильное значение // Replace assets with correct value
content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\""); content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\"");
// Также проверяем assetIndex // Also check assetIndex
if (content.contains("\"assetIndex\"")) { if (content.contains("\"assetIndex\"")) {
content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\""); content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\"");
} }
Files.writeString(versionJson, content); Files.writeString(versionJson, content);
System.out.println(ZAnsi.green("Asset index исправлен на: " + assetIndex)); System.out.println(ZAnsi.green("Asset index fixed to: " + assetIndex));
} else { } else {
System.out.println(ZAnsi.green("Asset index в JSON версии правильный: " + assetIndex)); System.out.println(ZAnsi.green("Asset index in version JSON is correct: " + assetIndex));
} }
} }
@@ -135,7 +135,7 @@ public class FabricInstaller {
Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader"); Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader");
if (!Files.exists(librariesDir)) return; if (!Files.exists(librariesDir)) return;
System.out.println(ZAnsi.yellow("Очистка старых версий Fabric Loader...")); System.out.println(ZAnsi.yellow("Cleaning old Fabric Loader versions..."));
try (var stream = Files.walk(librariesDir)) { try (var stream = Files.walk(librariesDir)) {
stream.filter(Files::isDirectory) stream.filter(Files::isDirectory)
@@ -155,18 +155,18 @@ public class FabricInstaller {
private String getLatestInstallerVersion() throws Exception { private String getLatestInstallerVersion() throws Exception {
try { try {
// Используем ZHttpClient с умным прокси // Use ZHttpClient with smart proxy
String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml"); String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml");
int start = xml.indexOf("<latest>") + 8; int start = xml.indexOf("<latest>") + 8;
int end = xml.indexOf("</latest>", start); int end = xml.indexOf("</latest>", start);
return xml.substring(start, end).trim(); return xml.substring(start, end).trim();
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Ошибка получения версии Fabric Installer: " + e.getMessage())); System.out.println(ZAnsi.yellow("Error getting Fabric Installer version: " + e.getMessage()));
throw new Exception("Не удалось получить версию Fabric Installer", e); throw new Exception("Failed to get Fabric Installer version", e);
} }
} }
// под рефактор оставить // under refactor - keep
private String downloadString(String url) throws Exception { private String downloadString(String url) throws Exception {
Exception lastException = null; Exception lastException = null;
@@ -186,7 +186,7 @@ public class FabricInstaller {
throw new IOException("HTTP " + resp.statusCode()); throw new IOException("HTTP " + resp.statusCode());
} catch (Exception e) { } catch (Exception e) {
lastException = e; lastException = e;
System.out.println(ZAnsi.yellow("Попытка " + attempt + " не удалась: " + e.getMessage())); System.out.println(ZAnsi.yellow("Attempt " + attempt + " failed: " + e.getMessage()));
if (attempt < 3) { if (attempt < 3) {
Thread.sleep(1000 * attempt); Thread.sleep(1000 * attempt);
} }
@@ -207,7 +207,7 @@ public class FabricInstaller {
HttpResponse.BodyHandlers.ofFile(target)); HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode() + " при скачивании " + url); throw new IOException("HTTP " + response.statusCode() + " when downloading " + url);
} }
} }
} }
@@ -11,7 +11,9 @@ import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
public class ForgeInstaller { public class ForgeInstaller {
@@ -26,59 +28,59 @@ public class ForgeInstaller {
} }
public boolean install(String mcVersion, String forgeVersion) throws Exception { public boolean install(String mcVersion, String forgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Установка Forge " + forgeVersion + " для Minecraft " + mcVersion)); System.out.println(ZAnsi.cyan("Installing Forge " + forgeVersion + " for Minecraft " + mcVersion));
// Шаг 1: Устанавливаем vanilla и получаем настоящий assetIndex // Step 1: Install vanilla and get real assetIndex
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "...")); System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "..."));
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
String assetIndex = vanillaInstaller.install(mcVersion); String assetIndex = vanillaInstaller.install(mcVersion);
if (assetIndex == null || assetIndex.isEmpty()) { if (assetIndex == null || assetIndex.isEmpty()) {
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft")); System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
return false; return false;
} }
instance.setAssetIndex(assetIndex); instance.setAssetIndex(assetIndex);
// Шаг 2: Создаём launcher_profiles.json // Step 2: Create launcher_profiles.json
createLauncherProfile(); createLauncherProfile();
// Шаг 3: Скачиваем Forge Installer с прогресс-баром // Step 3: Download Forge Installer with progress bar
String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/" String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/"
+ mcVersion + "-" + forgeVersion + mcVersion + "-" + forgeVersion
+ "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar"; + "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar";
Path installerJar = instance.getPath().resolve("forge-installer.jar"); Path installerJar = instance.getPath().resolve("forge-installer.jar");
System.out.println(ZAnsi.cyan("Скачивание Forge Installer...")); System.out.println(ZAnsi.cyan("Downloading Forge Installer..."));
downloadFileWithProgress(installerUrl, installerJar); downloadFileWithProgress(installerUrl, installerJar);
// Шаг 4: Запускаем Forge Installer и показываем его вывод // Step 4: Run Forge Installer and show its output
System.out.println(ZAnsi.cyan("Запуск Forge Installer...")); System.out.println(ZAnsi.cyan("Running Forge Installer..."));
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n")); System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
boolean success = runForgeInstaller(installerJar); boolean success = runForgeInstaller(installerJar);
// После успешной установки Forge, но перед сохранением метаданных // After successful Forge install, before saving metadata
if (success) { if (success) {
// Докачиваем пропущенные библиотеки // Download missing libraries
try { try {
downloadMissingLibraries(mcVersion, forgeVersion); downloadMissingLibraries(mcVersion, forgeVersion);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage())); System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
} }
System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " успешно установлен!")); System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " installed successfully!"));
instance.setMinecraftVersion(mcVersion); instance.setMinecraftVersion(mcVersion);
instance.setLoaderType("forge"); instance.setLoaderType("forge");
instance.setLoaderVersion(forgeVersion); instance.setLoaderVersion(forgeVersion);
// Очищаем временный файл установщика // Clean up temporary installer file
Files.deleteIfExists(installerJar); Files.deleteIfExists(installerJar);
return true; return true;
} else { } else {
System.out.println(ZAnsi.brightRed("\nОшибка при установке Forge!")); System.out.println(ZAnsi.brightRed("\nError installing Forge!"));
return false; return false;
} }
} }
@@ -94,7 +96,7 @@ public class ForgeInstaller {
} }
"""; """;
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
System.out.println(ZAnsi.yellow("Создан launcher_profiles.json")); System.out.println(ZAnsi.yellow("Created launcher_profiles.json"));
} }
private void downloadFileWithProgress(String url, Path target) throws Exception { private void downloadFileWithProgress(String url, Path target) throws Exception {
@@ -132,10 +134,10 @@ public class ForgeInstaller {
lastPercent = percent; lastPercent = percent;
} }
} else { } else {
// Если размер неизвестен, показываем анимацию // If size unknown, show animation
char[] spinner = {'|', '/', '-', '\\'}; char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4; int idx = (int) (totalRead / 1024) % 4;
System.out.print("\rСкачивание Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]); System.out.print("\rDownloading Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
} }
} }
} }
@@ -144,12 +146,12 @@ public class ForgeInstaller {
} }
private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException { private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException {
// Пробуем до 3 раз с разными опциями // Try up to 3 times with different options
int maxRetries = 3; int maxRetries = 3;
int attempt = 1; int attempt = 1;
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries)); System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
"java", "java",
@@ -158,7 +160,7 @@ public class ForgeInstaller {
"--installClient" "--installClient"
); );
// Добавляем JVM аргументы для увеличения таймаутов // Add JVM args for increased timeouts
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
pb.directory(instance.getPath().toFile()); pb.directory(instance.getPath().toFile());
@@ -166,7 +168,7 @@ public class ForgeInstaller {
Process process = pb.start(); Process process = pb.start();
// Читаем вывод в реальном времени // Read output in real time
StringBuilder output = new StringBuilder(); StringBuilder output = new StringBuilder();
boolean hasErrors = false; boolean hasErrors = false;
@@ -175,7 +177,7 @@ public class ForgeInstaller {
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
output.append(line).append("\n"); output.append(line).append("\n");
// Форматируем вывод Forge Installer // Format Forge Installer output
if (line.contains("Downloading") || line.contains("Extracting")) { if (line.contains("Downloading") || line.contains("Extracting")) {
System.out.println(ZAnsi.blue(" -> " + line)); System.out.println(ZAnsi.blue(" -> " + line));
} else if (line.contains("SUCCESS") || line.contains("successfully")) { } else if (line.contains("SUCCESS") || line.contains("successfully")) {
@@ -195,17 +197,17 @@ public class ForgeInstaller {
int exitCode = process.waitFor(); int exitCode = process.waitFor();
// Если успешно или нет ошибок скачивания // If successful or no download errors
if (exitCode == 0 && !hasErrors) { if (exitCode == 0 && !hasErrors) {
return true; return true;
} }
// Если ошибка и это не последняя попытка // If error and not last attempt
if (attempt < maxRetries) { if (attempt < maxRetries) {
System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд...")); System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
Thread.sleep(5000); Thread.sleep(5000);
// Очищаем временные файлы перед повтором // Clean temp files before retry
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) { if (Files.exists(librariesDir)) {
// Удаляем только частично скачанные библиотеки Forge // Удаляем только частично скачанные библиотеки Forge
@@ -218,15 +220,15 @@ public class ForgeInstaller {
} }
} }
} else { } else {
System.out.println(ZAnsi.brightRed("Forge Installer завершился с кодом ошибки: " + exitCode)); System.out.println(ZAnsi.brightRed("Forge Installer exited with error code: " + exitCode));
// Показываем возможное решение // Show possible solution
if (output.toString().contains("timed out")) { if (output.toString().contains("timed out")) {
System.out.println(ZAnsi.yellow("\nВозможные решения:")); System.out.println(ZAnsi.yellow("\nPossible solutions:"));
System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение")); System.out.println(ZAnsi.yellow("1. Check your internet connection"));
System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора")); System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр")); System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию Forge")); System.out.println(ZAnsi.yellow("4. Try installing a different Forge version"));
} }
} }
@@ -237,32 +239,38 @@ public class ForgeInstaller {
} }
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception { private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек...")); System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
// Список проблемных библиотек и их альтернативные URL
Map<String, String> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar");
// List of problematic libraries and their alternate URLs
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
for (Map.Entry<String, String> entry : alternativeUrls.entrySet()) { // Map from maven path to list of mirror URLs (tried in order)
Map<String, List<String>> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", Arrays.asList(
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"
));
for (Map.Entry<String, List<String>> entry : alternativeUrls.entrySet()) {
Path target = librariesDir.resolve(entry.getKey()); Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) { if (!Files.exists(target)) {
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName())); System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
for (int attempt = 1; attempt <= 3; attempt++) { boolean downloaded = false;
try { for (String mirrorUrl : entry.getValue()) {
downloadFileWithProgress(entry.getValue(), target); for (int attempt = 1; attempt <= 3; attempt++) {
break; try {
} catch (Exception e) { downloadFileWithProgress(mirrorUrl, target);
if (attempt == 3) throw e; downloaded = true;
System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3...")); break;
Thread.sleep(2000); } catch (Exception e) {
if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e;
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
}
} }
if (downloaded) break;
} }
} }
} }
@@ -0,0 +1,271 @@
package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.*;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashMap;
import java.util.Map;
public class NeoForgeInstaller {
private final Instance instance;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(30))
.build();
public NeoForgeInstaller(Instance instance) {
this.instance = instance;
}
public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Installing NeoForge " + neoForgeVersion + " for Minecraft " + mcVersion));
System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "..."));
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
String assetIndex = vanillaInstaller.install(mcVersion);
if (assetIndex == null || assetIndex.isEmpty()) {
System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
return false;
}
instance.setAssetIndex(assetIndex);
createLauncherProfile();
String mavenGroup = getMavenGroup(mcVersion);
String mavenArtifact = getMavenArtifact(mcVersion);
String installerUrl = "https://maven.neoforged.net/releases/"
+ mavenGroup.replace('.', '/') + "/"
+ mavenArtifact + "/"
+ neoForgeVersion
+ "/" + mavenArtifact + "-" + neoForgeVersion + "-installer.jar";
Path installerJar = instance.getPath().resolve("neoforge-installer.jar");
System.out.println(ZAnsi.cyan("Downloading NeoForge Installer..."));
downloadFileWithProgress(installerUrl, installerJar);
System.out.println(ZAnsi.cyan("Running NeoForge Installer..."));
System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
boolean success = runNeoForgeInstaller(installerJar);
if (success) {
try {
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
}
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " installed successfully!"));
instance.setMinecraftVersion(mcVersion);
instance.setLoaderType("neoforge");
instance.setLoaderVersion(neoForgeVersion);
Files.deleteIfExists(installerJar);
return true;
} else {
System.out.println(ZAnsi.brightRed("\nError installing NeoForge!"));
return false;
}
}
private String getMavenGroup(String mcVersion) {
if (mcVersion.equals("1.20.1")) {
return "net.neoforged";
}
return "net.neoforged";
}
private String getMavenArtifact(String mcVersion) {
if (mcVersion.equals("1.20.1")) {
return "forge";
}
return "neoforge";
}
private void createLauncherProfile() throws IOException {
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
if (Files.exists(profilePath)) return;
String minimalProfile = """
{
"profiles": {},
"selectedProfile": "Default"
}
""";
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
System.out.println(ZAnsi.yellow("Created launcher_profiles.json"));
}
private void downloadFileWithProgress(String url, Path target) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode());
}
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
try (InputStream in = response.body();
FileOutputStream out = new FileOutputStream(target.toFile())) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
int lastPercent = -1;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
totalRead += bytesRead;
if (contentLength > 0) {
int percent = (int) ((totalRead * 100) / contentLength);
if (percent != lastPercent) {
String downloaded = ProgressBar.formatBytes(totalRead);
String total = ProgressBar.formatBytes(contentLength);
ProgressBar.show("NeoForge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
lastPercent = percent;
}
} else {
char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4;
System.out.print("\rDownloading NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
}
}
}
ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
}
private boolean runNeoForgeInstaller(Path installerJar) throws IOException, InterruptedException {
int maxRetries = 3;
int attempt = 1;
while (attempt <= maxRetries) {
System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
ProcessBuilder pb = new ProcessBuilder(
"java",
"-jar",
installerJar.toAbsolutePath().toString(),
"--installClient"
);
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
pb.directory(instance.getPath().toFile());
pb.redirectErrorStream(true);
Process process = pb.start();
StringBuilder output = new StringBuilder();
boolean hasErrors = false;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
if (line.contains("Downloading") || line.contains("Extracting")) {
System.out.println(ZAnsi.blue(" -> " + line));
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
System.out.println(ZAnsi.brightGreen(" + " + line));
} else if (line.contains("WARNING") || line.contains("warning")) {
System.out.println(ZAnsi.yellow(" ! " + line));
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
System.out.println(ZAnsi.brightRed(" X " + line));
if (line.contains("timed out") || line.contains("failed to download")) {
hasErrors = true;
}
} else if (!line.isBlank()) {
System.out.println(" " + line);
}
}
}
int exitCode = process.waitFor();
if (exitCode == 0 && !hasErrors) {
return true;
}
if (attempt < maxRetries) {
System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
Thread.sleep(5000);
Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) {
try (var stream = Files.walk(librariesDir)) {
stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar"))
.forEach(p -> {
try { Files.deleteIfExists(p); }
catch (IOException e) { /* ignore */ }
});
}
}
} else {
System.out.println(ZAnsi.brightRed("NeoForge Installer exited with error code: " + exitCode));
if (output.toString().contains("timed out")) {
System.out.println(ZAnsi.yellow("\nPossible solutions:"));
System.out.println(ZAnsi.yellow("1. Check your internet connection"));
System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
System.out.println(ZAnsi.yellow("4. Try installing a different NeoForge version"));
}
}
attempt++;
}
return false;
}
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
Map<String, String> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
alternativeUrls.put("org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar");
alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar");
Path librariesDir = instance.getPath().resolve("libraries");
for (Map.Entry<String, String> entry : alternativeUrls.entrySet()) {
Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) {
Files.createDirectories(target.getParent());
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
for (int attempt = 1; attempt <= 3; attempt++) {
try {
downloadFileWithProgress(entry.getValue(), target);
break;
} catch (Exception e) {
if (attempt == 3) throw e;
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
Thread.sleep(2000);
}
}
}
}
}
}
@@ -57,12 +57,12 @@ public class VersionInstaller {
} }
public String install(String versionId) throws Exception { public String install(String versionId) throws Exception {
System.out.println(ZAnsi.cyan("Полная установка Minecraft " + versionId + "...")); System.out.println(ZAnsi.cyan("Full install of Minecraft " + versionId + "..."));
Path versionDir = minecraftDir.resolve("versions").resolve(versionId); Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
Files.createDirectories(versionDir); Files.createDirectories(versionDir);
String versionUrl = getVersionUrl(versionId); String versionUrl = getVersionUrl(versionId);
if (versionUrl == null) throw new Exception("Версия " + versionId + " не найдена"); if (versionUrl == null) throw new Exception("Version " + versionId + " not found");
String versionJson = downloadString(versionUrl); String versionJson = downloadString(versionUrl);
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson); Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
@@ -73,8 +73,8 @@ public class VersionInstaller {
downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"), downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
versionDir.resolve(versionId + ".jar"), "client.jar"); versionDir.resolve(versionId + ".jar"), "client.jar");
// Библиотеки // Libraries
System.out.println(ZAnsi.cyan("Скачивание библиотек...")); System.out.println(ZAnsi.cyan("Downloading libraries..."));
downloadLibraries(versionData.getJSONArray("libraries")); downloadLibraries(versionData.getJSONArray("libraries"));
String assetIndex; String assetIndex;
@@ -86,12 +86,12 @@ public class VersionInstaller {
System.out.println(ZAnsi.cyan("Asset index: " + assetIndex)); System.out.println(ZAnsi.cyan("Asset index: " + assetIndex));
// Скачиваем ассеты используя правильный индекс // Download assets using correct index
System.out.println(ZAnsi.cyan("Скачивание ассетов...")); System.out.println(ZAnsi.cyan("Downloading assets..."));
downloadAssets(versionData, assetIndex); downloadAssets(versionData, assetIndex);
System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " полностью установлен!")); System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " fully installed!"));
return assetIndex; // возвращаем "5" а не "1.20.1" return assetIndex;
} }
private void downloadLibraries(JSONArray libraries) throws Exception { private void downloadLibraries(JSONArray libraries) throws Exception {
@@ -111,32 +111,32 @@ public class VersionInstaller {
try { try {
downloadFile(url, target, "library"); downloadFile(url, target, "library");
} catch (Exception e) { } catch (Exception e) {
// Пропускаем проблемные библиотеки // Skip problematic libraries
} }
} }
count++; count++;
ProgressBar.show("Библиотеки", count, total, "файлов"); ProgressBar.show("Libraries", count, total, "files");
} }
ProgressBar.finish("Библиотеки загружены"); ProgressBar.finish("Libraries downloaded");
} }
private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception { private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception {
// Находим URL для asset index // Find URL for asset index
JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex"); JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex");
String indexUrl = assetIndexInfo.getString("url"); String indexUrl = assetIndexInfo.getString("url");
Path indexesDir = minecraftDir.resolve("assets/indexes"); Path indexesDir = minecraftDir.resolve("assets/indexes");
Files.createDirectories(indexesDir); Files.createDirectories(indexesDir);
Path indexPath = indexesDir.resolve(assetIndex + ".json"); // используем assetIndex Path indexPath = indexesDir.resolve(assetIndex + ".json");
System.out.println(ZAnsi.cyan("Скачивание asset index (" + assetIndex + ")...")); System.out.println(ZAnsi.cyan("Downloading asset index (" + assetIndex + ")..."));
downloadFile(indexUrl, indexPath, "asset index"); downloadFile(indexUrl, indexPath, "asset index");
String jsonContent = Files.readString(indexPath); String jsonContent = Files.readString(indexPath);
JSONObject root = new JSONObject(jsonContent); JSONObject root = new JSONObject(jsonContent);
JSONObject objects = root.getJSONObject("objects"); JSONObject objects = root.getJSONObject("objects");
System.out.println(ZAnsi.cyan("Скачивание " + objects.length() + " объектов ассетов (index: " + assetIndex + ")...")); System.out.println(ZAnsi.cyan("Downloading " + objects.length() + " asset objects (index: " + assetIndex + ")..."));
int total = objects.length(); int total = objects.length();
int[] success = {0}; int[] success = {0};
@@ -146,7 +146,7 @@ public class VersionInstaller {
for (String key : objects.keySet()) { for (String key : objects.keySet()) {
JSONObject asset = objects.getJSONObject(key); JSONObject asset = objects.getJSONObject(key);
String hash = asset.getString("hash"); // вот это правильный хеш! String hash = asset.getString("hash");
String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash;
Path target = minecraftDir.resolve("assets/objects") Path target = minecraftDir.resolve("assets/objects")
@@ -160,19 +160,19 @@ public class VersionInstaller {
for (int attempt = 1; attempt <= 3; attempt++) { for (int attempt = 1; attempt <= 3; attempt++) {
try { try {
downloadFile(url, target, ""); downloadFile(url, target, "");
synchronized (this) {
success[0]++;
ProgressBar.show("Ассеты", success[0], total, "файлов");
}
downloaded = true;
break;
} catch (Exception e) {
if (attempt == 3) {
synchronized (this) { synchronized (this) {
failed[0]++; success[0]++;
ProgressBar.show("Assets", success[0], total, "files");
} }
System.err.println("Не удалось скачать " + hash); downloaded = true;
} else { break;
} catch (Exception e) {
if (attempt == 3) {
synchronized (this) {
failed[0]++;
}
System.err.println("Failed to download " + hash);
} else {
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {} try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
} }
} }
@@ -183,18 +183,19 @@ public class VersionInstaller {
} }
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
executor.shutdown();
ProgressBar.finish("Ассеты загружены (" + success[0] + " успешно, " + failed[0] + " пропущено)"); ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)");
if (failed[0] > 0) { if (failed[0] > 0) {
System.out.println(ZAnsi.yellow("Предупреждение: " + failed[0] + " файлов ассетов не удалось скачать.")); System.out.println(ZAnsi.yellow("Warning: " + failed[0] + " asset files could not be downloaded."));
System.out.println(ZAnsi.yellow("Игра запустится, но некоторые текстуры/звуки могут отсутствовать.")); System.out.println(ZAnsi.yellow("The game will launch, but some textures/sounds may be missing."));
} }
} }
public String getAssetIndexId(String versionId) throws Exception { public String getAssetIndexId(String versionId) throws Exception {
String versionUrl = getVersionUrl(versionId); String versionUrl = getVersionUrl(versionId);
if (versionUrl == null) throw new Exception("Версия не найдена"); if (versionUrl == null) throw new Exception("Version not found");
String versionJson = downloadString(versionUrl); String versionJson = downloadString(versionUrl);
JSONObject versionData = new JSONObject(versionJson); JSONObject versionData = new JSONObject(versionJson);
@@ -202,7 +203,7 @@ public class VersionInstaller {
if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) { if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) {
return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1 return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1
} }
return versionData.getString("assets"); // fallback (очень старые версии) return versionData.getString("assets"); // fallback (very old versions)
} }
private String getVersionUrl(String versionId) throws Exception { private String getVersionUrl(String versionId) throws Exception {
@@ -222,7 +223,7 @@ public class VersionInstaller {
private void downloadFile(String url, Path target, String label) throws Exception { private void downloadFile(String url, Path target, String label) throws Exception {
if (!label.isEmpty()) { if (!label.isEmpty()) {
ProgressBar.clearLine(); ProgressBar.clearLine();
System.out.println(ZAnsi.cyan("Скачивание " + label + "...")); System.out.println(ZAnsi.cyan("Downloading " + label + "..."));
} }
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
@@ -233,8 +234,8 @@ public class VersionInstaller {
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target)); HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
if (label.isEmpty()) return; // для ассетов молча if (label.isEmpty()) return; // for assets silently
throw new IOException("HTTP " + response.statusCode() + " при скачивании " + label); throw new IOException("HTTP " + response.statusCode() + " while downloading " + label);
} }
if (!label.isEmpty()) { if (!label.isEmpty()) {
@@ -0,0 +1,494 @@
package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import org.json.JSONObject;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class LaunchCommandBuilder {
private final Instance instance;
public LaunchCommandBuilder(Instance instance) {
this.instance = instance;
}
public List<String> build(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
List<String> command = new ArrayList<>();
String javaPath = options.getJavaPath() != null && !options.getJavaPath().isEmpty()
? options.getJavaPath() : "java";
command.add(javaPath);
command.addAll(getJvmArguments(options));
Path nativesDir = instance.getPath().resolve("natives");
if (!Files.exists(nativesDir)) {
Files.createDirectories(nativesDir);
}
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
String loaderType = instance.getLoaderType().toLowerCase();
boolean isModloader = "fabric".equals(loaderType) || "forge".equals(loaderType) || "neoforge".equals(loaderType);
VersionManifest manifest = resolveVersionManifest();
// For modloaders, always use vanilla classpath with all libraries
if (isModloader) {
System.out.println(ZAnsi.cyan(" Modloader detected (" + loaderType + "), using vanilla classpath"));
command.add("-cp");
command.add(buildVanillaClasspath());
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
} else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest);
// Fallback if classpath is empty
if (classpath.isEmpty() || classpath.equals(instance.getPath().resolve("versions").resolve(getVersionId()).resolve(getVersionId() + ".jar").toAbsolutePath().toString())) {
System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
command.add("-cp");
command.add(buildVanillaClasspath());
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
} else {
command.add("-cp");
command.add(classpath);
String mainClass = resolveMainClass(manifest);
command.add(mainClass);
command.addAll(resolveGameArguments(manifest, options));
}
} else {
command.add("-cp");
command.add(buildVanillaClasspath());
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
}
return command;
}
private VersionManifest resolveVersionManifest() {
try {
Path versionJson = findVersionJson();
if (versionJson != null && Files.exists(versionJson)) {
String content = Files.readString(versionJson);
JSONObject json = new JSONObject(content);
System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
return new VersionManifest(json);
} else {
System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName()));
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
}
return null;
}
private Path findVersionJson() {
Path versionsDir = instance.getPath().resolve("versions");
String loaderType = instance.getLoaderType().toLowerCase();
String mcVersion = instance.getMinecraftVersion();
String loaderVersion = instance.getLoaderVersion();
if ("fabric".equals(loaderType)) {
String versionId = getVersionId();
// Try fabric version ID first
Path jsonPath = versionsDir.resolve(versionId).resolve(versionId + ".json");
if (Files.exists(jsonPath)) {
return jsonPath;
}
// Try instance's fabricVersionId if available
String fabricId = instance.getFabricVersionId();
if (fabricId != null && !fabricId.isEmpty()) {
Path fabricPath = versionsDir.resolve(fabricId).resolve(fabricId + ".json");
if (Files.exists(fabricPath)) {
return fabricPath;
}
}
// Try generic fabric pattern
try {
if (Files.exists(versionsDir)) {
try (var stream = Files.list(versionsDir)) {
return stream
.filter(Files::isDirectory)
.filter(dir -> dir.getFileName().toString().contains("fabric"))
.filter(dir -> dir.getFileName().toString().contains(mcVersion))
.findFirst()
.map(dir -> dir.resolve(dir.getFileName().toString() + ".json"))
.filter(Files::exists)
.orElse(null);
}
}
} catch (Exception ignored) {}
}
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
String[] candidates = {
getVersionId(),
mcVersion + "-" + loaderType + "-" + loaderVersion,
loaderType + "-" + loaderVersion,
mcVersion + "-" + loaderVersion,
mcVersion
};
for (String candidate : candidates) {
Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json");
if (Files.exists(jsonPath)) {
return jsonPath;
}
}
try {
if (Files.exists(versionsDir)) {
try (var stream = Files.list(versionsDir)) {
return stream
.filter(Files::isDirectory)
.filter(dir -> dir.getFileName().toString().contains("forge") ||
dir.getFileName().toString().contains("neoforge"))
.filter(dir -> dir.getFileName().toString().contains(mcVersion))
.findFirst()
.map(dir -> dir.resolve(dir.getFileName().toString() + ".json"))
.filter(Files::exists)
.orElse(null);
}
}
} catch (Exception ignored) {}
}
Path fallback = versionsDir.resolve(mcVersion).resolve(mcVersion + ".json");
if (Files.exists(fallback)) {
return fallback;
}
return null;
}
private String getVersionId() {
String loaderType = instance.getLoaderType().toLowerCase();
String mcVersion = instance.getMinecraftVersion();
String loaderVer = instance.getLoaderVersion();
if ("vanilla".equals(loaderType)) {
return mcVersion;
}
else if ("fabric".equals(loaderType)) {
String fabricId = instance.getFabricVersionId();
if (fabricId != null && !fabricId.isEmpty()) {
return fabricId;
}
return "fabric-loader-" + loaderVer + "-" + mcVersion;
}
else if ("forge".equals(loaderType)) {
return mcVersion + "-forge-" + loaderVer;
}
else if ("neoforge".equals(loaderType)) {
if (mcVersion.equals("1.20.1")) {
return mcVersion + "-neoforge-" + loaderVer;
}
return "neoforge-" + loaderVer;
}
return mcVersion;
}
private String resolveMainClass(VersionManifest manifest) {
return manifest.getMainClass();
}
private String getVanillaMainClass() {
String loaderType = instance.getLoaderType().toLowerCase();
if ("fabric".equals(loaderType)) {
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
} else if ("forge".equals(loaderType)) {
return "net.minecraftforge.client.main.ForgeClient";
} else if ("neoforge".equals(loaderType)) {
return "cpw.mods.bootstraplauncher.BootstrapLauncher";
}
return "net.minecraft.client.main.Main";
}
private List<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) {
List<String> args = new ArrayList<>();
Map<String, String> vars = buildVariableMap(options);
for (String raw : manifest.getGameArguments()) {
args.add(resolveVariable(raw, vars));
}
if (options.getWidth() > 0) {
args.add("--width");
args.add(String.valueOf(options.getWidth()));
}
if (options.getHeight() > 0) {
args.add("--height");
args.add(String.valueOf(options.getHeight()));
}
return args;
}
private List<String> getVanillaGameArguments(LaunchOptions options) {
List<String> args = new ArrayList<>();
args.add("--version");
args.add(instance.getName());
args.add("--gameDir");
args.add(instance.getPath().toAbsolutePath().toString());
args.add("--assetsDir");
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
args.add("--assetIndex");
String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion();
System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
} else {
System.out.println(ZAnsi.green("Using asset index: " + assetIndex));
}
args.add(assetIndex);
args.add("--username");
args.add(options.getUsername() != null ? options.getUsername() : "Player");
args.add("--accessToken");
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
args.add("--uuid");
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
args.add("--userType");
args.add("legacy");
return args;
}
private Map<String, String> buildVariableMap(LaunchOptions options) {
Map<String, String> vars = new HashMap<>();
Path gameDir = instance.getPath().toAbsolutePath();
Path assetsDir = gameDir.resolve("assets");
Path nativesDir = gameDir.resolve("natives");
Path librariesDir = gameDir.resolve("libraries");
vars.put("version_name", instance.getName());
vars.put("game_directory", gameDir.toString());
vars.put("assets_root", assetsDir.toString());
vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion());
vars.put("auth_player_name", options.getUsername() != null ? options.getUsername() : "Player");
vars.put("auth_access_token", options.getAccessToken() != null ? options.getAccessToken() : "0");
vars.put("auth_uuid", options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
vars.put("auth_xuid", "0");
vars.put("user_type", "legacy");
vars.put("version_type", "release");
vars.put("natives_directory", nativesDir.toString());
vars.put("library_directory", librariesDir.toString());
vars.put("launcher_name", "ZernMC");
vars.put("launcher_version", "1.0");
vars.put("classpath_separator", System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":");
vars.put("resolution_width", String.valueOf(options.getWidth() > 0 ? options.getWidth() : 1920));
vars.put("resolution_height", String.valueOf(options.getHeight() > 0 ? options.getHeight() : 1080));
vars.put("game_directory", gameDir.toString());
String loaderType = instance.getLoaderType().toLowerCase();
if ("forge".equals(loaderType)) {
vars.put("forge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
} else if ("neoforge".equals(loaderType)) {
vars.put("neoforge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
vars.put("fml.neoForgeVersion", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
vars.put("fml.neoForgeGroup", "net.neoforged");
}
return vars;
}
private String resolveVariable(String raw, Map<String, String> vars) {
if (!raw.contains("${")) return raw;
String result = raw;
for (Map.Entry<String, String> entry : vars.entrySet()) {
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
}
return result;
}
private String buildClasspathFromManifest(VersionManifest manifest) throws Exception {
List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries");
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getLibraries().size() + " libraries in manifest"));
for (VersionManifest.Library lib : manifest.getLibraries()) {
Path libPath = librariesDir.resolve(lib.artifactPath);
if (Files.exists(libPath)) {
paths.add(libPath.toAbsolutePath().toString());
} else {
String mavenPath = mavenToPath(lib.name);
Path fallbackPath = librariesDir.resolve(mavenPath);
if (Files.exists(fallbackPath)) {
paths.add(fallbackPath.toAbsolutePath().toString());
} else {
System.out.println(ZAnsi.yellow(" Library not found: " + lib.name));
}
}
}
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
Path versionJar = findVersionJar();
if (versionJar != null) {
paths.add(0, versionJar.toAbsolutePath().toString());
System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
}
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
return String.join(separator, paths);
}
private String buildVanillaClasspath() throws Exception {
List<String> paths = new ArrayList<>();
String versionId = getVersionId();
Path versionJar = instance.getPath()
.resolve("versions")
.resolve(versionId)
.resolve(versionId + ".jar");
if (Files.exists(versionJar)) {
paths.add(versionJar.toAbsolutePath().toString());
} else {
String mcVersion = instance.getMinecraftVersion();
Path fallbackJar = instance.getPath()
.resolve("versions")
.resolve(mcVersion)
.resolve(mcVersion + ".jar");
if (Files.exists(fallbackJar)) {
paths.add(fallbackJar.toAbsolutePath().toString());
}
}
Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) {
try (var stream = Files.walk(librariesDir)) {
stream.filter(p -> p.toString().endsWith(".jar"))
.map(p -> p.toAbsolutePath().toString())
.forEach(paths::add);
}
}
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
return String.join(separator, paths);
}
private Path findVersionJar() {
String versionId = getVersionId();
Path versionsDir = instance.getPath().resolve("versions");
Path[] candidates = {
versionsDir.resolve(versionId).resolve(versionId + ".jar"),
versionsDir.resolve(instance.getMinecraftVersion()).resolve(instance.getMinecraftVersion() + ".jar")
};
for (Path candidate : candidates) {
if (Files.exists(candidate)) {
return candidate;
}
}
try {
if (Files.exists(versionsDir)) {
try (var stream = Files.list(versionsDir)) {
return stream
.filter(Files::isDirectory)
.filter(dir -> dir.getFileName().toString().contains("forge") ||
dir.getFileName().toString().contains("neoforge"))
.filter(dir -> dir.getFileName().toString().contains(instance.getMinecraftVersion()))
.findFirst()
.map(dir -> dir.resolve(dir.getFileName().toString() + ".jar"))
.filter(Files::exists)
.orElse(null);
}
}
} catch (Exception ignored) {}
return null;
}
private String mavenToPath(String mavenName) {
String[] parts = mavenName.split(":");
if (parts.length < 3) return mavenName;
String group = parts[0].replace('.', '/');
String artifact = parts[1];
String version = parts[2];
if (parts.length == 4) {
String classifier = parts[3];
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + "-" + classifier + ".jar";
}
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + ".jar";
}
private List<String> getJvmArguments(LaunchOptions options) {
List<String> jvmArgs = new ArrayList<>();
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
jvmArgs.add("-Xmx" + ramMB + "M");
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
jvmArgs.add("-XX:+UseG1GC");
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
jvmArgs.add("-XX:G1NewSizePercent=20");
jvmArgs.add("-XX:G1ReservePercent=20");
jvmArgs.add("-XX:MaxGCPauseMillis=50");
jvmArgs.add("-XX:G1HeapRegionSize=32M");
String loaderType = instance.getLoaderType().toLowerCase();
if ("fabric".equals(loaderType)) {
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
} else if ("forge".equals(loaderType)) {
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
} else if ("neoforge".equals(loaderType)) {
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
}
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
jvmArgs.addAll(options.getExtraJvmArgs());
}
return jvmArgs;
}
}
@@ -0,0 +1,174 @@
package me.sashegdev.zernmc.launcher.minecraft.launch;
import org.json.JSONArray;
import org.json.JSONObject;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class VersionManifest {
private final String id;
private final String mainClass;
private final String assetIndexId;
private final List<String> jvmArguments;
private final List<String> gameArguments;
private final List<Library> libraries;
public VersionManifest(JSONObject json) {
this.id = json.getString("id");
this.mainClass = json.getString("mainClass");
if (json.has("assetIndex")) {
JSONObject ai = json.getJSONObject("assetIndex");
this.assetIndexId = ai.has("id") ? ai.getString("id") : "unknown";
} else {
this.assetIndexId = "unknown";
}
this.jvmArguments = parseArguments(json, "jvm");
this.gameArguments = parseArguments(json, "game");
this.libraries = parseLibraries(json);
}
public String getId() { return id; }
public String getMainClass() { return mainClass; }
public String getAssetIndexId() { return assetIndexId; }
public List<String> getJvmArguments() { return jvmArguments; }
public List<String> getGameArguments() { return gameArguments; }
public List<Library> getLibraries() { return libraries; }
private List<String> parseArguments(JSONObject json, String type) {
List<String> args = new ArrayList<>();
if (!json.has("arguments")) return args;
JSONObject arguments = json.getJSONObject("arguments");
if (!arguments.has(type)) return args;
JSONArray arr = arguments.getJSONArray(type);
for (int i = 0; i < arr.length(); i++) {
Object item = arr.get(i);
if (item instanceof String) {
args.add((String) item);
} else if (item instanceof JSONObject) {
JSONObject ruleObj = (JSONObject) item;
if (ruleMatches(ruleObj)) {
Object value = ruleObj.get("value");
if (value instanceof String) {
args.add((String) value);
} else if (value instanceof JSONArray) {
JSONArray valArr = (JSONArray) value;
for (int j = 0; j < valArr.length(); j++) {
args.add(valArr.getString(j));
}
}
}
}
}
return args;
}
private boolean ruleMatches(JSONObject ruleObj) {
JSONArray rules = ruleObj.getJSONArray("rules");
boolean result = false;
for (int i = 0; i < rules.length(); i++) {
JSONObject rule = rules.getJSONObject(i);
String action = rule.getString("action");
boolean matches = true;
if (rule.has("os")) {
JSONObject os = rule.getJSONObject("os");
String osName = System.getProperty("os.name").toLowerCase();
if (os.has("name")) {
String reqName = os.getString("name").toLowerCase();
if (reqName.equals("windows") && !osName.contains("win")) matches = false;
else if (reqName.equals("linux") && !osName.contains("linux") && !osName.contains("nix")) matches = false;
else if (reqName.equals("osx") && !osName.contains("mac")) matches = false;
}
if (os.has("arch")) {
String reqArch = os.getString("arch");
String osArch = System.getProperty("os.arch");
if (!reqArch.equals(osArch)) matches = false;
}
}
if (rule.has("features")) {
JSONObject features = rule.getJSONObject("features");
for (String key : features.keySet()) {
if (key.startsWith("has_custom_resolution")) {
continue; // Лаунчер сам обрабатывает разрешение
}
if (key.startsWith("is_demo_user")) {
// Лаунчер не использует demo режим, считаем фичу false
matches = false;
break;
}
// Неизвестная фича — считаем false
matches = false;
break;
}
}
if ("allow".equals(action) && matches) {
result = true;
} else if ("disallow".equals(action) && matches) {
return false;
}
}
return result;
}
private List<Library> parseLibraries(JSONObject json) {
List<Library> libs = new ArrayList<>();
if (!json.has("libraries")) return libs;
JSONArray arr = json.getJSONArray("libraries");
for (int i = 0; i < arr.length(); i++) {
JSONObject libJson = arr.getJSONObject(i);
if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) {
String name = libJson.getString("name");
String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path");
Library lib = new Library(name, artifactPath);
if (libJson.has("natives")) {
JSONObject natives = libJson.getJSONObject("natives");
for (String key : natives.keySet()) {
String osKey = key.toLowerCase();
lib.natives.put(osKey, natives.getString(key));
}
}
if (libJson.has("rules")) {
JSONObject dummyObj = new JSONObject();
dummyObj.put("rules", libJson.getJSONArray("rules"));
dummyObj.put("value", "");
if (ruleMatches(dummyObj)) {
libs.add(lib);
}
} else {
libs.add(lib);
}
}
}
return libs;
}
public static class Library {
public final String name;
public final String artifactPath;
public final Map<String, String> natives = new HashMap<>();
public Library(String name, String artifactPath) {
this.name = name;
this.artifactPath = artifactPath;
}
public String getSimpleName() {
return name.substring(name.indexOf(':') + 1);
}
}
}
@@ -37,5 +37,7 @@ public class LaunchOptions {
public void setExtraJvmArgs(List<String> extraJvmArgs) { this.extraJvmArgs = extraJvmArgs; } public void setExtraJvmArgs(List<String> extraJvmArgs) { this.extraJvmArgs = extraJvmArgs; }
public int getWidth() { return width; } public int getWidth() { return width; }
public void setWidth(int width) { this.width = width; }
public int getHeight() { return height; } public int getHeight() { return height; }
public void setHeight(int height) { this.height = height; }
} }
@@ -0,0 +1,133 @@
package me.sashegdev.zernmc.launcher.ui;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
import org.jline.utils.InfoCmp;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class ArrowMenu {
private final String title;
private final List<String> options;
private int selected = 0;
private final Terminal terminal;
private final InputStream rawInput;
private static final int VISIBLE_ITEMS = 7;
public ArrowMenu(String title, List<String> options) throws IOException {
this.title = title;
this.options = options;
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows");
System.setProperty("jline.terminal", isWindows ? "win" : "unsupported");
this.terminal = TerminalBuilder.builder()
.system(true)
.jna(isWindows)
.jansi(true)
.encoding(StandardCharsets.UTF_8)
.build();
this.rawInput = terminal.input();
}
public int show() throws IOException {
terminal.enterRawMode();
terminal.puts(InfoCmp.Capability.clear_screen);
terminal.puts(InfoCmp.Capability.cursor_invisible);
try {
while (true) {
printPagedMenu();
int b = rawInput.read();
if (b == -1) continue;
// w/W/k/K or ц (0xD1 0x86) = up
// s/S/j/J or ы (0xD1 0x8B) = down
if (b == 'w' || b == 'W' || b == 'k' || b == 'K') {
selected = (selected - 1 + options.size()) % options.size();
}
else if (b == 's' || b == 'S' || b == 'j' || b == 'J') {
selected = (selected + 1) % options.size();
}
// ESC sequences: arrows + cyrillic start byte
else if (b == 0x1B) {
int next = nonBlockingRead();
if (next == -1) {
return -1;
}
if (next == 0x5B || next == 0x4F) { // '[' (CSI) or 'O' (SS3)
int arrow = nonBlockingRead();
if (arrow == 0x41) { // Up
selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 0x42) { // Down
selected = (selected + 1) % options.size();
}
}
}
else if (b == 0xD1) {
int second = nonBlockingRead();
if (second == 0x86) { // ц
selected = (selected - 1 + options.size()) % options.size();
} else if (second == 0x8B) { // ы
selected = (selected + 1) % options.size();
}
}
else if (b == 13 || b == 10) {
return selected;
}
}
} finally {
terminal.puts(InfoCmp.Capability.cursor_visible);
terminal.close();
}
}
private int nonBlockingRead() throws IOException {
long startTime = System.currentTimeMillis();
while (System.currentTimeMillis() - startTime < 100) {
if (rawInput.available() > 0) {
return rawInput.read();
}
try {
Thread.sleep(2);
} catch (InterruptedException e) {
return -1;
}
}
return -1;
}
private void printPagedMenu() {
StringBuilder sb = new StringBuilder();
sb.append("\033[H\033[2J");
sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n");
sb.append(ZAnsi.yellow(title)).append("\n\n");
int start = Math.max(0, selected - (VISIBLE_ITEMS / 2));
int end = Math.min(options.size(), start + VISIBLE_ITEMS);
if (end - start < VISIBLE_ITEMS && start > 0) {
start = Math.max(0, end - VISIBLE_ITEMS);
}
for (int i = start; i < end; i++) {
String line = options.get(i);
if (i == selected) {
sb.append(ZAnsi.selected(line)).append("\n");
} else {
sb.append(ZAnsi.white(" " + line)).append("\n");
}
}
sb.append("\n")
.append(ZAnsi.white("W/S or \u2191/\u2193 - navigate | Enter - select | Esc - back"));
System.out.print(sb);
System.out.flush();
}
}
@@ -0,0 +1,267 @@
package me.sashegdev.zernmc.launcher.utils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
public class Config {
private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc");
private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties");
private static final String BUILD_PROFILE = System.getProperty("build.profile", "global");
private static final Properties props = new Properties();
private static volatile int maxMemory = 4096;
private static volatile String serverUrl = "http://87.120.187.36:1582";
private static volatile String lastUsername = "Player";
private static volatile int windowWidth = 1280;
private static volatile int windowHeight = 720;
private static volatile String extraJvmArgs = "";
private static volatile String javaPath = "java";
private static volatile boolean ramManuallySet = false;
private static volatile String locale = "en";
private static volatile boolean systemBasedJvm = false;
static {
load();
if (!ramManuallySet) {
applySmartRamRecommendation();
}
}
private static void load() {
try {
Files.createDirectories(CONFIG_DIR);
if (Files.exists(CONFIG_FILE)) {
try (var is = Files.newInputStream(CONFIG_FILE)) {
props.load(is);
}
}
try {
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
} catch (NumberFormatException e) {
System.err.println(ZAnsi.yellow("Config: invalid maxMemory value, using default"));
}
ramManuallySet = Boolean.parseBoolean(props.getProperty("ramManuallySet", "false"));
serverUrl = props.getProperty("serverUrl", serverUrl);
lastUsername = props.getProperty("lastUsername", lastUsername);
try {
windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280"));
} catch (NumberFormatException e) {
System.err.println(ZAnsi.yellow("Config: invalid windowWidth value, using default"));
}
try {
windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720"));
} catch (NumberFormatException e) {
System.err.println(ZAnsi.yellow("Config: invalid windowHeight value, using default"));
}
extraJvmArgs = props.getProperty("extraJvmArgs", "");
javaPath = props.getProperty("javaPath", "java");
locale = props.getProperty("locale", "en");
systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false"));
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage());
}
}
public static void save() {
try {
props.setProperty("maxMemory", String.valueOf(maxMemory));
props.setProperty("ramManuallySet", String.valueOf(ramManuallySet));
props.setProperty("serverUrl", serverUrl);
props.setProperty("lastUsername", lastUsername);
props.setProperty("windowWidth", String.valueOf(windowWidth));
props.setProperty("windowHeight", String.valueOf(windowHeight));
props.setProperty("extraJvmArgs", extraJvmArgs);
props.setProperty("javaPath", javaPath);
props.setProperty("locale", locale);
props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm));
try (var os = Files.newOutputStream(CONFIG_FILE)) {
props.store(os, "ZernMC Launcher Configuration");
}
} catch (IOException e) {
System.err.println(ZAnsi.brightRed("Failed to save config: ") + e.getMessage());
}
}
private static void applySmartRamRecommendation() {
long totalRamMB = getTotalSystemRamMB();
if (totalRamMB <= 0) return;
long recommended;
if (totalRamMB <= 8192) {
recommended = 2560;
} else if (totalRamMB <= 12288) {
recommended = 3072;
} else if (totalRamMB <= 16384) {
recommended = 4096;
} else {
recommended = 5120;
}
if (Math.abs(maxMemory - recommended) > 512) {
maxMemory = (int) recommended;
save();
System.out.println(ZAnsi.cyan("Auto-recommended RAM: " + maxMemory + " MB"));
}
}
public static void resetRamRecommendation() {
ramManuallySet = false;
applySmartRamRecommendation();
}
private static long getTotalSystemRamMB() {
try {
Class<?> beanClass = Class.forName("com.sun.management.OperatingSystemMXBean");
java.lang.management.OperatingSystemMXBean osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
if (beanClass.isInstance(osBean)) {
Object totalBytes = beanClass.getMethod("getTotalMemorySize").invoke(osBean);
return ((Number) totalBytes).longValue() / (1024 * 1024);
}
} catch (Exception ignored) {}
return 0;
}
public static int getMaxMemory() {
return maxMemory;
}
public static boolean isZernMCBuild() {
return "zernmc".equalsIgnoreCase(BUILD_PROFILE);
}
public static boolean isGlobalBuild() {
return !isZernMCBuild();
}
public static void setMaxMemory(int memory) {
if (memory < 1024) memory = 1536;
if (memory > 32768) memory = 32768;
maxMemory = memory;
ramManuallySet = true;
save();
}
public static String getServerUrl() {
return serverUrl;
}
public static String getLastUsername() {
return lastUsername;
}
public static void setLastUsername(String username) {
lastUsername = username;
save();
}
public static Path getInstancesDir() {
return CONFIG_DIR.resolve("instances");
}
public static Path getJreDir() {
return CONFIG_DIR.resolve("jre");
}
public static Path getConfigDir() {
return CONFIG_DIR;
}
public static int getWindowWidth() {
return windowWidth;
}
public static void setWindowWidth(int width) {
windowWidth = Math.max(640, width);
save();
}
public static int getWindowHeight() {
return windowHeight;
}
public static void setWindowHeight(int height) {
windowHeight = Math.max(480, height);
save();
}
public static String getExtraJvmArgs() {
return extraJvmArgs;
}
public static void setExtraJvmArgs(String args) {
extraJvmArgs = args != null ? args : "";
save();
}
public static String getJavaPath() {
return javaPath;
}
public static void setJavaPath(String path) {
javaPath = path != null && !path.isEmpty() ? path : "java";
save();
}
public static String getLocale() {
return locale;
}
public static void setLocale(String lang) {
if (lang != null && (lang.equals("en") || lang.equals("ru"))) {
locale = lang;
save();
}
}
public static boolean isSystemBasedJvm() {
return systemBasedJvm;
}
public static void setSystemBasedJvm(boolean enabled) {
systemBasedJvm = enabled;
save();
}
public static int getSystemCpuCores() {
return Runtime.getRuntime().availableProcessors();
}
public static long getSystemTotalRamMB() {
long totalMb = getTotalSystemRamMB();
if (totalMb > 0) return totalMb;
return Runtime.getRuntime().maxMemory() / (1024 * 1024);
}
public static String getSystemJvmFlags() {
int cores = getSystemCpuCores();
long ramMB = getSystemTotalRamMB();
StringBuilder sb = new StringBuilder();
sb.append("-XX:ParallelGCThreads=").append(Math.max(1, cores));
sb.append(" -XX:ConcGCThreads=").append(Math.max(1, cores / 2));
sb.append(" -XX:+AlwaysPreTouch");
if (ramMB >= 8192) {
sb.append(" -XX:+UseZGC");
sb.append(" -XX:ZAllocationSpikeTolerance=2.0");
} else {
sb.append(" -XX:+UseG1GC");
sb.append(" -XX:MaxGCPauseMillis=50");
sb.append(" -XX:G1HeapRegionSize=16M");
}
sb.append(" -Xss4M");
return sb.toString();
}
public static String getRamInfo() {
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB";
}
}
@@ -10,10 +10,9 @@ public class ConsoleUtils {
} }
public static void pause() { public static void pause() {
System.out.print(ZAnsi.white("\nНажмите Enter для продолжения...")); System.out.print(ZAnsi.white("\nPress Enter to continue..."));
try { try {
System.in.read(); System.in.read();
// Очищаем буфер ввода
while (System.in.available() > 0) { while (System.in.available() > 0) {
System.in.read(); System.in.read();
} }
@@ -36,4 +35,4 @@ public class ConsoleUtils {
public static void separator() { public static void separator() {
System.out.println(ZAnsi.white("────────────────────────────────────────────────────────────")); System.out.println(ZAnsi.white("────────────────────────────────────────────────────────────"));
} }
} }
@@ -3,22 +3,20 @@ package me.sashegdev.zernmc.launcher.utils;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu; import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.Scanner; import java.util.Scanner;
/**
* Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu
*/
public class Input { public class Input {
// Используем UTF-8 явно это помогает на Windows private static final Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8);
private static final Scanner scanner = new Scanner(System.in, "UTF-8");
public static String readLine() { public static String readLine() {
return scanner.nextLine().trim(); return scanner.nextLine().trim();
} }
public static String readLine(String prompt) { public static String readLine(String prompt) {
flushInput();
System.out.print(prompt); System.out.print(prompt);
return scanner.nextLine().trim(); return scanner.nextLine().trim();
} }
@@ -29,7 +27,7 @@ public class Input {
System.out.print(prompt); System.out.print(prompt);
return Integer.parseInt(scanner.nextLine().trim()); return Integer.parseInt(scanner.nextLine().trim());
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
System.out.println(ZAnsi.brightRed("Некорректное число. Попробуйте ещё раз.")); System.out.println(ZAnsi.brightRed("Invalid number. Try again."));
} }
} }
} }
@@ -40,43 +38,41 @@ public class Input {
if (value >= min && value <= max) { if (value >= min && value <= max) {
return value; return value;
} }
System.out.println(ZAnsi.brightRed("Значение должно быть от " + min + " до " + max + ".")); System.out.println(ZAnsi.brightRed("Value must be between " + min + " and " + max + "."));
} }
} }
/**
* Новый confirm через ArrowMenu
* @throws IOException
*/
public static boolean confirm(String question) throws IOException { public static boolean confirm(String question) throws IOException {
ConsoleUtils.clearScreen(); // опционально, можно убрать ConsoleUtils.clearScreen();
List<String> options = List.of( List<String> options = List.of(
"Да", "Yes",
"Нет" "No"
); );
ArrowMenu menu = new ArrowMenu(question, options); ArrowMenu menu = new ArrowMenu(question, options);
int choice = menu.show(); int choice = menu.show();
return choice == 0; // 0 = "Да" return choice == 0;
} }
/**
* Альтернативный confirm без очистки экрана
* @throws IOException
*/
public static boolean confirmInline(String question) throws IOException { public static boolean confirmInline(String question) throws IOException {
List<String> options = List.of("Да", "Нет"); List<String> options = List.of("Yes", "No");
ArrowMenu menu = new ArrowMenu(question, options); ArrowMenu menu = new ArrowMenu(question, options);
int choice = menu.show(); int choice = menu.show();
return choice == 0; return choice == 0;
} }
/**
* Закрытие сканнера (вызывать при выходе из программы, если нужно)
*/
public static void close() { public static void close() {
scanner.close(); scanner.close();
} }
}
public static void flushInput() {
try {
while (System.in.available() > 0) {
System.in.read();
}
} catch (IOException e) {
}
}
}
@@ -0,0 +1,95 @@
package me.sashegdev.zernmc.launcher.utils;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.locks.ReentrantLock;
public class LauncherLogger {
private static Path logFile;
private static boolean initialized = false;
private static final ReentrantLock lock = new ReentrantLock();
public static synchronized void init() {
if (initialized) return;
initialized = true;
try {
Path logsDir = Paths.get(System.getProperty("user.home"), ".zernmc", "logs");
Files.createDirectories(logsDir);
logFile = logsDir.resolve("launcher.log");
Files.writeString(logFile,
"=== Launcher Log " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " ===\n",
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath());
} catch (Exception e) {
System.err.println("[LauncherLogger] init error: " + e.getMessage());
e.printStackTrace();
}
}
public static Path getLogFile() {
return logFile;
}
public static void info(String msg) {
write("INFO", msg, null);
}
public static void warn(String msg) {
write("WARN", msg, null);
}
public static void error(String msg) {
write("ERROR", msg, null);
}
public static void error(String msg, Throwable t) {
write("ERROR", msg, t);
}
public static void debug(String msg) {
write("DEBUG", msg, null);
}
private static void write(String level, String msg, Throwable t) {
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"));
String line = "[" + ts + "] [" + level + "] " + msg;
System.out.println(line);
if (t != null) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
t.printStackTrace(pw);
pw.flush();
System.err.print(sw.toString());
}
if (logFile != null) {
lock.lock();
try {
Files.writeString(logFile, line + "\n", StandardOpenOption.APPEND);
if (t != null) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
t.printStackTrace(pw);
pw.flush();
Files.writeString(logFile, sw.toString(), StandardOpenOption.APPEND);
}
} catch (IOException e) {
System.err.println("[LauncherLogger] write error: " + e.getMessage());
} finally {
lock.unlock();
}
}
}
}
@@ -6,11 +6,20 @@ public class ProgressBar {
private static final int BAR_LENGTH = 40; private static final int BAR_LENGTH = 40;
private static final DecimalFormat DF = new DecimalFormat("#.##"); private static final DecimalFormat DF = new DecimalFormat("#.##");
private static String currentLabel = "";
private static long currentTotal = 0;
/**
* Прогресс по количеству файлов (для библиотек и общего прогресса)
*/
public static void show(String label, long current, long total, String unit) { public static void show(String label, long current, long total, String unit) {
currentLabel = label;
currentTotal = total;
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
setProgress.invoke(null, label, (int) current, (int) total);
} catch (Exception ignored) {}
if (total <= 0) { if (total <= 0) {
System.out.print("\r" + ZAnsi.cyan(label) + " ..."); System.out.print("\r" + ZAnsi.cyan(label) + " ...");
return; return;
@@ -27,10 +36,16 @@ public class ProgressBar {
System.out.flush(); System.out.flush();
} }
/**
* Прогресс по байтам для одного файла (реальный прогресс)
*/
public static void showDownload(String label, long downloaded, long totalBytes) { public static void showDownload(String label, long downloaded, long totalBytes) {
currentLabel = label;
currentTotal = totalBytes;
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
setProgress.invoke(null, label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
} catch (Exception ignored) {}
if (totalBytes <= 0) { if (totalBytes <= 0) {
System.out.print("\r" + ZAnsi.cyan(label) + " ..."); System.out.print("\r" + ZAnsi.cyan(label) + " ...");
return; return;
@@ -53,8 +68,16 @@ public class ProgressBar {
} }
public static void showAnimated(String label, long current, long total, String unit) { public static void showAnimated(String label, long current, long total, String unit) {
currentLabel = label;
currentTotal = total;
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
setProgress.invoke(null, label, (int) current, (int) (total > 0 ? total : 100));
} catch (Exception ignored) {}
if (total <= 0) { if (total <= 0) {
// Анимация для неизвестного размера
char[] spinner = {'|', '/', '-', '\\'}; char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (current / 1024) % 4; int idx = (int) (current / 1024) % 4;
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current)); System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
@@ -64,7 +87,13 @@ public class ProgressBar {
} }
public static void finish(String message) { public static void finish(String message) {
System.out.println("\r" + ZAnsi.brightGreen(message + " завершено ✓")); try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setInProgress = jfxClass.getMethod("setInstallInProgress", boolean.class);
setInProgress.invoke(null, false);
} catch (Exception ignored) {}
System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓"));
System.out.flush(); System.out.flush();
} }
@@ -78,4 +107,4 @@ public class ProgressBar {
if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB"; if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB";
return DF.format(bytes / (1024.0 * 1024)) + " MB"; return DF.format(bytes / (1024.0 * 1024)) + " MB";
} }
} }
@@ -33,24 +33,42 @@ public class Version {
public static boolean isNewer(String current, String server) { public static boolean isNewer(String current, String server) {
if (current == null || server == null) return false; if (current == null || server == null) return false;
current = current.replace("-SNAPSHOT", "").trim(); // Нормализуем версии - убираем суффиксы типа -any, -alpha, -beta, -SNAPSHOT
server = server.replace("-SNAPSHOT", "").trim(); current = normalizeVersion(current);
server = normalizeVersion(server);
if (current.equals(server)) return false; if (current.equals(server)) return false;
String[] cParts = current.split("\\."); String[] cParts = current.split("\\.");
String[] sParts = server.split("\\."); String[] sParts = server.split("\\.");
int max = Math.max(cParts.length, sParts.length); int max = Math.max(cParts.length, sParts.length);
for (int i = 0; i < max; i++) { for (int i = 0; i < max; i++) {
int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0; int c = i < cParts.length ? parseVersionPart(cParts[i]) : 0;
int s = i < sParts.length ? Integer.parseInt(sParts[i]) : 0; int s = i < sParts.length ? parseVersionPart(sParts[i]) : 0;
if (s > c) return true; if (s > c) return true;
if (s < c) return false; if (s < c) return false;
} }
return false; return false;
} }
private static String normalizeVersion(String version) {
if (version == null) return "0.0.0";
// Убираем суффиксы: -any, -alpha1, -beta2, -SNAPSHOT, -rc1 и т.д.
return version.split("-")[0].split("\\+")[0].trim();
}
private static int parseVersionPart(String part) {
try {
// Убираем всё, что не является цифрой (на случай если суффикс остался)
String numeric = part.replaceAll("[^0-9]", "");
return numeric.isEmpty() ? 0 : Integer.parseInt(numeric);
} catch (Exception e) {
return 0;
}
}
} }
@@ -0,0 +1,182 @@
package me.sashegdev.zernmc.launcher.utils;
import org.fusesource.jansi.Ansi;
import org.fusesource.jansi.AnsiConsole;
public class ZAnsi {
//поддержка ANSI епта
public static void install() {
AnsiConsole.systemInstall();
}
public static void uninstall() {
AnsiConsole.systemUninstall();
}
// === Основные цвета ===
public static String green(String text) {
return Ansi.ansi().fg(Ansi.Color.GREEN).a(text).reset().toString();
}
public static String brightGreen(String text) {
return Ansi.ansi().fgBright(Ansi.Color.GREEN).a(text).reset().toString();
}
public static String cyan(String text) {
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
}
public static String brightCyan(String text) {
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a(text).reset().toString();
}
public static String yellow(String text) {
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
}
public static String brightYellow(String text) {
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).a(text).reset().toString();
}
public static String red(String text) {
return Ansi.ansi().fg(Ansi.Color.RED).a(text).reset().toString();
}
public static String brightRed(String text) {
return Ansi.ansi().fgBright(Ansi.Color.RED).a(text).reset().toString();
}
public static String blue(String text) {
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
}
public static String brightBlue(String text) {
return Ansi.ansi().fgBright(Ansi.Color.BLUE).a(text).reset().toString();
}
public static String magenta(String text) {
return Ansi.ansi().fg(Ansi.Color.MAGENTA).a(text).reset().toString();
}
public static String brightMagenta(String text) {
return Ansi.ansi().fgBright(Ansi.Color.MAGENTA).a(text).reset().toString();
}
// Пурпурный как brightPurple (используем magenta)
public static String purple(String text) {
return brightMagenta(text);
}
public static String brightPurple(String text) {
return brightMagenta(text);
}
public static String white(String text) {
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
}
public static String brightWhite(String text) {
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
}
public static String black(String text) {
return Ansi.ansi().fg(Ansi.Color.BLACK).a(text).reset().toString();
}
// === Фоновые цвета ===
public static String bgGreen(String text) {
return Ansi.ansi().bg(Ansi.Color.GREEN).a(text).reset().toString();
}
public static String bgRed(String text) {
return Ansi.ansi().bg(Ansi.Color.RED).a(text).reset().toString();
}
public static String bgYellow(String text) {
return Ansi.ansi().bg(Ansi.Color.YELLOW).a(text).reset().toString();
}
public static String bgBlue(String text) {
return Ansi.ansi().bg(Ansi.Color.BLUE).a(text).reset().toString();
}
// === Стили ===
public static String bold(String text) {
return Ansi.ansi().bold().a(text).reset().toString();
}
public static String reset() {
return Ansi.ansi().reset().toString();
}
// === Комбинированные удобные методы ===
public static String header(String text) {
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
}
public static String success(String text) {
return Ansi.ansi().fgBright(Ansi.Color.GREEN).bold().a("[✓] " + text).reset().toString();
}
public static String error(String text) {
return Ansi.ansi().fgBright(Ansi.Color.RED).bold().a("[✗] " + text).reset().toString();
}
public static String warning(String text) {
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).bold().a("[!] " + text).reset().toString();
}
public static String info(String text) {
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a("[i] " + text).reset().toString();
}
public static String selected(String text) {
return Ansi.ansi()
.bgBright(Ansi.Color.WHITE)
.fg(Ansi.Color.BLACK)
.bold()
.a(" > " + text + " ")
.reset()
.toString();
}
public static String dim(String text) {
return Ansi.ansi().fgBright(Ansi.Color.BLACK).a(text).reset().toString();
}
// === Цветной текст для ролей ===
public static String roleUser(String text) {
return white(text);
}
public static String rolePassHolder(String text) {
return brightGreen(text);
}
public static String roleModerator(String text) {
return brightBlue(text);
}
public static String roleElder(String text) {
return brightPurple(text);
}
public static String roleCreator(String text) {
return brightRed(text);
}
// === Очистка экрана ===
public static String clearScreen() {
return Ansi.ansi().eraseScreen().cursor(1, 1).toString();
}
// === Прогресс бар символы ===
public static String progressChar() {
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a("").reset().toString();
}
public static String progressEmpty() {
return Ansi.ansi().fg(Ansi.Color.BLACK).a("").reset().toString();
}
}
@@ -3,6 +3,8 @@ package me.sashegdev.zernmc.launcher.utils;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
@@ -25,20 +27,27 @@ public class ZHttpClient {
.version(HttpClient.Version.HTTP_1_1) .version(HttpClient.Version.HTTP_1_1)
.build(); .build();
private static final String BASE_URL = "http://87.120.187.36:1582"; private static String BASE_URL = "http://87.120.187.36:1582";
// Глобальный прокси режим (для обратной совместимости)
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
// Умное проксирование по сервисам public static void setBaseUrl(String url) {
BASE_URL = url;
}
public static String getBaseUrl() {
return BASE_URL;
}
public enum ServiceType { public enum ServiceType {
ZERN_SERVER(BASE_URL, true), ZERN_SERVER("http://87.120.187.36:1582", true),
FABRIC_META("https://meta.fabricmc.net", false), FABRIC_META("https://meta.fabricmc.net", false),
FABRIC_MAVEN("https://maven.fabricmc.net", false), FABRIC_MAVEN("https://maven.fabricmc.net", false),
MOJANG_META("https://piston-meta.mojang.com", false), MOJANG_META("https://piston-meta.mojang.com", false),
MOJANG_RESOURCES("https://resources.download.minecraft.net", false), MOJANG_RESOURCES("https://resources.download.minecraft.net", false),
FORGE_MAVEN("https://maven.minecraftforge.net", false), FORGE_MAVEN("https://maven.minecraftforge.net", false),
NEOFORGE_MAVEN("https://maven.neoforged.net", false),
GOOGLE("https://google.com", false), GOOGLE("https://google.com", false),
CLOUDFLARE("https://cloudflare.com", false); CLOUDFLARE("https://cloudflare.com", false);
@@ -54,17 +63,15 @@ public class ZHttpClient {
public boolean isAlwaysDirect() { return alwaysDirect; } public boolean isAlwaysDirect() { return alwaysDirect; }
} }
// Статусы сервисов
private static final Map<ServiceType, Boolean> serviceProxyMode = new ConcurrentHashMap<>(); private static final Map<ServiceType, Boolean> serviceProxyMode = new ConcurrentHashMap<>();
private static final Map<ServiceType, Integer> serviceFailCount = new ConcurrentHashMap<>(); private static final Map<ServiceType, Integer> serviceFailCount = new ConcurrentHashMap<>();
private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>(); private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>();
private static final Map<ServiceType, Boolean> serviceHealthy = new ConcurrentHashMap<>(); private static final Map<ServiceType, Boolean> serviceHealthy = new ConcurrentHashMap<>();
private static final int MAX_FAILS_BEFORE_PROXY = 2; private static final int MAX_FAILS_BEFORE_PROXY = 2;
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута private static final long HEALTH_CHECK_INTERVAL_MS = 60000;
private static final long CHECK_TIMEOUT_MS = 7000; // 7 секунд на проверку private static final long CHECK_TIMEOUT_MS = 7000;
// Статистика
private static int directSuccessCount = 0; private static int directSuccessCount = 0;
private static int proxySuccessCount = 0; private static int proxySuccessCount = 0;
private static int directFailCount = 0; private static int directFailCount = 0;
@@ -77,14 +84,13 @@ public class ZHttpClient {
} }
} }
/**
* Вызывать один раз при запуске лаунчера
*/
public static void checkAllServicesOnStartup() { public static void checkAllServicesOnStartup() {
checkAllServicesOnStartup(false);
}
public static void checkAllServicesOnStartup(boolean verbose) {
if (proxyTested.get()) return; if (proxyTested.get()) return;
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
List<ServiceType> servicesToCheck = List.of( List<ServiceType> servicesToCheck = List.of(
ServiceType.ZERN_SERVER, ServiceType.ZERN_SERVER,
ServiceType.GOOGLE, ServiceType.GOOGLE,
@@ -92,7 +98,8 @@ public class ZHttpClient {
ServiceType.FABRIC_MAVEN, ServiceType.FABRIC_MAVEN,
ServiceType.MOJANG_META, ServiceType.MOJANG_META,
ServiceType.MOJANG_RESOURCES, ServiceType.MOJANG_RESOURCES,
ServiceType.FORGE_MAVEN ServiceType.FORGE_MAVEN,
ServiceType.NEOFORGE_MAVEN
); );
for (ServiceType service : servicesToCheck) { for (ServiceType service : servicesToCheck) {
@@ -100,14 +107,20 @@ public class ZHttpClient {
serviceHealthy.put(service, isHealthy); serviceHealthy.put(service, isHealthy);
if (service.isAlwaysDirect()) { if (service.isAlwaysDirect()) {
System.out.println(isHealthy ? if (verbose) {
ZAnsi.green(" " + service.name() + " - OK") : System.out.println(isHealthy ?
ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); ZAnsi.green(" " + service.name() + " - OK") :
ZAnsi.red(" " + service.name() + " - NOT ACCESSIBLE (critical!)"));
}
} else { } else {
if (isHealthy) { if (isHealthy) {
System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает")); if (verbose) {
System.out.println(ZAnsi.green(" " + service.name() + " - direct connection works"));
}
} else { } else {
System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси")); if (verbose) {
System.out.println(ZAnsi.yellow(" " + service.name() + " - NOT ACCESSIBLE, proxy will be used"));
}
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
} }
@@ -115,30 +128,31 @@ public class ZHttpClient {
} }
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!")); if (verbose) {
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
}
} }
proxyTested.set(true); proxyTested.set(true);
startHealthCheckThread(); if (verbose) {
printStats(); startHealthCheckThread();
printStats();
}
} }
/**
* Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки)
*/
public static void forceCheckMojangServices() { public static void forceCheckMojangServices() {
System.out.println(ZAnsi.cyan("Принудительная проверка Mojang сервисов...")); System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) { for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) {
boolean healthy = checkServiceHealth(service); boolean healthy = checkServiceHealth(service);
serviceHealthy.put(service, healthy); serviceHealthy.put(service, healthy);
if (healthy) { if (healthy) {
System.out.println(ZAnsi.green(" " + service.name() + " доступен напрямую")); System.out.println(ZAnsi.green(" " + service.name() + " accessible directly"));
serviceProxyMode.put(service, false); serviceProxyMode.put(service, false);
serviceFailCount.put(service, 0); serviceFailCount.put(service, 0);
} else { } else {
System.out.println(ZAnsi.yellow(" " + service.name() + " недоступен → прокси режим активирован")); System.out.println(ZAnsi.yellow(" " + service.name() + " not accessible -> proxy mode activated"));
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
} }
@@ -149,9 +163,6 @@ public class ZHttpClient {
return checkDirectConnection(service.getBaseUrl()); return checkDirectConnection(service.getBaseUrl());
} }
/**
* Улучшенная проверка прямого подключения
*/
private static boolean checkDirectConnection(String baseUrl) { private static boolean checkDirectConnection(String baseUrl) {
String testUrl = baseUrl; String testUrl = baseUrl;
@@ -171,7 +182,7 @@ public class ZHttpClient {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
int code = response.statusCode(); int code = response.statusCode();
return code == 200 || code == 404; // 404 для ресурсов нормально return code == 200 || code == 404;
} catch (Exception e) { } catch (Exception e) {
return false; return false;
} }
@@ -202,7 +213,7 @@ public class ZHttpClient {
if (isHealthy && serviceProxyMode.get(service)) { if (isHealthy && serviceProxyMode.get(service)) {
serviceProxyMode.put(service, false); serviceProxyMode.put(service, false);
serviceFailCount.put(service, 0); serviceFailCount.put(service, 0);
System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение")); System.out.println(ZAnsi.green("[NET] " + service.name() + " restored, switched to direct connection"));
} else if (!isHealthy && !serviceProxyMode.get(service)) { } else if (!isHealthy && !serviceProxyMode.get(service)) {
int fails = serviceFailCount.getOrDefault(service, 0) + 1; int fails = serviceFailCount.getOrDefault(service, 0) + 1;
serviceFailCount.put(service, fails); serviceFailCount.put(service, fails);
@@ -210,7 +221,7 @@ public class ZHttpClient {
if (fails >= MAX_FAILS_BEFORE_PROXY) { if (fails >= MAX_FAILS_BEFORE_PROXY) {
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " недоступен, включен прокси режим")); System.out.println(ZAnsi.yellow("[NET] " + service.name() + " unavailable, proxy mode enabled"));
} }
} }
} }
@@ -223,6 +234,7 @@ public class ZHttpClient {
return ServiceType.MOJANG_META; return ServiceType.MOJANG_META;
if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES; if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES;
if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN; if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN;
if (url.contains("maven.neoforged.net")) return ServiceType.NEOFORGE_MAVEN;
if (url.contains("google.com")) return ServiceType.GOOGLE; if (url.contains("google.com")) return ServiceType.GOOGLE;
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE; if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
return null; return null;
@@ -260,14 +272,11 @@ public class ZHttpClient {
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) { if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " заблокирован, переключаемся на прокси")); System.out.println(ZAnsi.yellow("[NET] " + service.name() + " blocked, switching to proxy"));
} }
} }
/**
* Универсальный GET с умным прокси + автоматическим fallback
*/
public static String getWithSmartProxy(String url) throws IOException, InterruptedException { public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
// Попытка прямого подключения
if (!shouldUseProxyForUrl(url)) { if (!shouldUseProxyForUrl(url)) {
try { try {
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
@@ -292,11 +301,9 @@ public class ZHttpClient {
directFailCount++; directFailCount++;
markServiceAsBlocked(url); markServiceAsBlocked(url);
} }
// Если ошибка соединения пробуем через прокси
} }
} }
// Через прокси
try { try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/download?url=" + encodedUrl; String proxyUrl = BASE_URL + "/download?url=" + encodedUrl;
@@ -318,13 +325,10 @@ public class ZHttpClient {
return response.body(); return response.body();
} catch (Exception e) { } catch (Exception e) {
throw new IOException("Не удалось получить данные ни напрямую, ни через прокси: " + e.getMessage(), e); throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
} }
} }
/**
* Скачивание файла с умным прокси + fallback
*/
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
if (!shouldUseProxyForUrl(url)) { if (!shouldUseProxyForUrl(url)) {
try { try {
@@ -346,11 +350,9 @@ public class ZHttpClient {
directFailCount++; directFailCount++;
markServiceAsBlocked(url); markServiceAsBlocked(url);
} }
// fallback на прокси ниже
} }
} }
// Скачивание через прокси
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
@@ -370,23 +372,24 @@ public class ZHttpClient {
proxySuccessCount++; proxySuccessCount++;
} }
// ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ======================
public static String get(String endpoint) throws IOException, InterruptedException { public static String get(String endpoint) throws IOException, InterruptedException {
checkAllServicesOnStartup();
if (useProxyMode.get()) { if (useProxyMode.get()) {
return proxyGet(endpoint); return proxyGet(endpoint);
} }
try { try {
HttpRequest request = HttpRequest.newBuilder() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + endpoint)) .uri(URI.create(BASE_URL + endpoint))
.timeout(Duration.ofSeconds(15)) .timeout(Duration.ofSeconds(15))
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET() .GET();
.build();
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
}
HttpRequest request = requestBuilder.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
@@ -401,28 +404,31 @@ public class ZHttpClient {
private static String proxyGet(String endpoint) throws IOException { private static String proxyGet(String endpoint) throws IOException {
try { try {
HttpRequest request = HttpRequest.newBuilder() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/proxy" + endpoint)) .uri(URI.create(BASE_URL + "/proxy" + endpoint))
.timeout(Duration.ofSeconds(30)) .timeout(Duration.ofSeconds(30))
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET() .GET();
.build();
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
}
HttpRequest request = requestBuilder.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode()); throw new IOException("HTTP " + response.statusCode());
} }
proxySuccessCount++; proxySuccessCount++;
return response.body(); return response.body();
} catch (Exception e) { } catch (Exception e) {
throw new IOException("Ошибка прокси: " + e.getMessage(), e); throw new IOException("Proxy error: " + e.getMessage(), e);
} }
} }
// ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ======================
public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException { public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException {
String url = "https://meta.fabricmc.net/v2/versions/loader"; String url = "https://meta.fabricmc.net/v2/versions/loader";
return parseFabricVersionsFromJson(getWithSmartProxy(url)); return parseFabricVersionsFromJson(getWithSmartProxy(url));
@@ -477,19 +483,13 @@ public class ZHttpClient {
return versions; return versions;
} }
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
public static String getBaseUrl() {
return BASE_URL;
}
public static String getLauncherVersionInfo() throws IOException, InterruptedException { public static String getLauncherVersionInfo() throws IOException, InterruptedException {
return get("/launcher/version"); return get("/launcher/version");
} }
public static void forceProxyMode() { public static void forceProxyMode() {
useProxyMode.set(true); useProxyMode.set(true);
System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим")); System.out.println(ZAnsi.yellow("Global proxy mode forced on"));
} }
public static void disableProxyMode() { public static void disableProxyMode() {
@@ -500,7 +500,7 @@ public class ZHttpClient {
serviceFailCount.put(type, 0); serviceFailCount.put(type, 0);
} }
} }
System.out.println(ZAnsi.green("Режим прокси выключен")); System.out.println(ZAnsi.green("Proxy mode disabled"));
} }
public static boolean isProxyMode() { public static boolean isProxyMode() {
@@ -508,18 +508,18 @@ public class ZHttpClient {
} }
public static void printStats() { public static void printStats() {
System.out.println(ZAnsi.cyan("\n=== Статистика сети ===")); System.out.println(ZAnsi.cyan("\n=== Network Stats ==="));
System.out.println(ZAnsi.white("Глобальный прокси: ") + (useProxyMode.get() ? "ВКЛ" : "ВЫКЛ")); System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF"));
System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount); System.out.println(ZAnsi.white("Direct successes: ") + directSuccessCount);
System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount); System.out.println(ZAnsi.white("Direct failures: ") + directFailCount);
System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount); System.out.println(ZAnsi.white("Proxy successes: ") + proxySuccessCount);
System.out.println(ZAnsi.cyan("\nСтатус сервисов:")); System.out.println(ZAnsi.cyan("\nService status:"));
for (ServiceType type : ServiceType.values()) { for (ServiceType type : ServiceType.values()) {
if (type.isAlwaysDirect()) continue; if (type.isAlwaysDirect()) continue;
String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО"); String status = serviceProxyMode.get(type) ? ZAnsi.red("PROXY") : ZAnsi.green("DIRECT");
String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]"); String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]");
System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health); System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health);
} }
} }
} }
@@ -0,0 +1,423 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<canvas id="bg-canvas"></canvas>
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-container">
<div class="login-brand">
<div class="brand-icon">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none">
<rect width="56" height="56" rx="14" fill="url(#brandGrad)"/>
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
<defs>
<linearGradient id="brandGrad" x1="0" y1="0" x2="56" y2="56">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
</svg>
</div>
<h1 class="brand-title">ZernMC</h1>
<p class="brand-sub">Launcher <span id="version" data-i18n="version">1.0.9</span></p>
</div>
<form id="login-form" class="login-form">
<div class="field">
<input type="text" id="username" placeholder="Username" data-i18n-placeholder="login.username" autocomplete="username" required>
<label for="username" data-i18n="login.username">Username</label>
</div>
<div class="field">
<input type="password" id="password" placeholder="Password" data-i18n-placeholder="login.password" autocomplete="current-password" required>
<label for="password" data-i18n="login.password">Password</label>
</div>
<p id="login-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="login-btn">
<span class="btn-label" data-i18n="login.title">Sign In</span>
<div class="spinner hidden"></div>
</button>
<p class="login-hint" data-i18n="login.hint">New account will be created automatically on first login</p>
</form>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="overlay hidden">
<div class="loader-ring"></div>
<p class="loader-text" data-i18n="loading.text">Loading...</p>
</div>
<!-- Main Screen -->
<div id="main-screen" class="screen hidden">
<div class="shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-brand">
<svg width="32" height="32" viewBox="0 0 56 56" fill="none">
<rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/>
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
<defs>
<linearGradient id="brandGrad2" x1="0" y1="0" x2="56" y2="56">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
</svg>
<div class="sidebar-brand-text">
<span class="sidebar-brand-name">ZernMC</span>
<span class="sidebar-brand-ver">v<span id="header-version">1.0.9</span></span>
</div>
</div>
<nav class="sidebar-nav">
<button class="nav-btn active" data-view="packs" title="Packs">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
<span data-i18n="nav.packs">Packs</span>
</button>
<button class="nav-btn" data-view="news" title="News">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
<span data-i18n="nav.news">News</span>
</button>
<button class="nav-btn" data-view="friends" title="Friends">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="nav.friends">Friends</span>
</button>
</nav>
<div class="sidebar-section">
<div class="section-header">
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
</div>
<div id="server-packs-list" class="pack-list"></div>
</div>
<div class="sidebar-section" id="local-packs-section">
<div class="section-header">
<span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
<button class="btn-icon" id="add-pack-btn" title="Add pack">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div id="local-packs-list" class="pack-list"></div>
</div>
</div>
<div class="sidebar-bottom">
<div class="user-card">
<div class="user-avatar" id="user-avatar">Z</div>
<div class="user-info">
<span class="user-name" id="username-display">Player</span>
<span class="user-badges">
<span id="account-status" class="badge badge-free">FREE</span>
<span id="account-role" class="badge badge-role hidden"></span>
</span>
</div>
<button class="btn-icon" id="settings-btn" title="Settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
<button class="btn-icon" id="logout-btn" title="Log out">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</div>
</aside>
<main class="content">
<!-- Packs View -->
<div id="view-packs" class="view active">
<div class="view-header">
<div>
<h2 class="view-title" id="selected-pack-title">Select a pack</h2>
<p class="view-subtitle" id="selected-pack-meta">Choose a pack from the sidebar to get started</p>
</div>
<div class="view-actions">
<button id="update-btn" class="btn-secondary hidden">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
<span data-i18n="pack.update">Update</span>
</button>
<button id="delete-pack-btn" class="btn-secondary btn-danger hidden">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<span data-i18n="pack.delete">Delete</span>
</button>
</div>
</div>
<div class="pack-detail" id="pack-detail">
<div class="pack-empty" id="pack-empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<h3 data-i18n="pack.emptyState.title">No pack selected</h3>
<p data-i18n="pack.emptyState.desc">Select a pack from the sidebar or add a new one</p>
</div>
<div id="pack-detail-content" class="pack-detail-content hidden">
<div class="pack-hero">
<div class="pack-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
<div>
<h3 id="detail-name" class="detail-name">pack</h3>
<div class="detail-tags">
<span class="tag tag-mc" id="detail-mc">1.21</span>
<span class="tag tag-loader" id="detail-loader">fabric</span>
<span class="tag tag-server hidden" id="detail-server">v1</span>
</div>
</div>
</div>
<div class="pack-stats">
<div class="stat"><span class="stat-value" id="detail-loader-ver">-</span><span class="stat-label" data-i18n="stat.loaderVer">Loader Ver</span></div>
<div class="stat"><span class="stat-value" id="detail-files">0</span><span class="stat-label" data-i18n="stat.files">Files</span></div>
<div class="stat"><span class="stat-value" id="detail-size">-</span><span class="stat-label" data-i18n="stat.size">Size</span></div>
<div class="stat"><span class="stat-value" id="detail-playtime">-</span><span class="stat-label" data-i18n="playtime.label">Playtime</span></div>
</div>
<div id="pack-description" class="pack-description">
<p id="pack-description-text" class="pack-description-text" data-i18n="pack.description.loading">Loading description...</p>
<div id="pack-gallery" class="pack-gallery">
</div>
</div>
</div>
</div>
<div class="play-bar" id="play-bar">
<div class="play-bar-info">
<span id="play-bar-name">Select a pack</span>
</div>
<button id="play-btn" class="btn-play" disabled>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span data-i18n="playBar.play">Play</span>
</button>
</div>
</div>
<!-- News View -->
<div id="view-news" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="news.title">News</h2>
</div>
<div id="news-grid" class="news-grid">
<div class="news-loading" data-i18n="news.loading">Loading news...</div>
</div>
</div>
<!-- Friends View -->
<div id="view-friends" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="friends.title">Friends</h2>
<div class="view-actions">
<button id="friends-add-btn" class="btn-primary btn-sm" onclick="app.showAddFriend()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span data-i18n="friends.add">Add</span>
</button>
</div>
</div>
<div class="friends-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="friends-search-input" placeholder="Search friends..." data-i18n-placeholder="friends.search" oninput="app.filterFriends()">
</div>
<div id="friends-list" class="friends-list">
<div class="friends-empty" data-i18n="friends.empty">No friends yet</div>
</div>
<div id="friend-requests-section" class="friend-requests-section hidden">
<div class="section-header"><span data-i18n="friends.requests">Friend Requests</span></div>
<div id="friend-requests-list" class="friend-requests-list"></div>
</div>
</div>
<!-- Settings View -->
<div id="view-settings" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="settings.title">Settings</h2>
</div>
<div class="settings-grid">
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.activatePass.title">Activate Pass</h4>
<p data-i18n="settings.activatePass.desc">Enter your pass code to access server packs</p>
</div>
<div class="setting-control setting-pass">
<input type="text" id="pass-code" placeholder="Pass code" data-i18n-placeholder="settings.activatePass.placeholder" class="pass-input">
<button id="activate-pass-btn" class="btn-primary btn-sm" onclick="app.activatePass()"><span data-i18n="settings.activatePass.button">Activate</span></button>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.ram.title">Allocated RAM</h4>
<p id="ram-info" data-i18n="settings.ram.info">Loading...</p>
</div>
<div class="setting-control">
<input type="range" id="ram-slider" min="1024" max="16384" step="512" value="4096">
<span class="setting-value" id="ram-value">4 GB</span>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.resolution.title">Game Resolution</h4>
<p data-i18n="settings.resolution.desc">Width x Height</p>
</div>
<div class="setting-control" style="gap:6px">
<input type="number" id="win-width" min="640" max="7680" step="1" value="1280" class="setting-input" style="width:80px">
<span style="color:var(--text-muted)">x</span>
<input type="number" id="win-height" min="480" max="4320" step="1" value="720" class="setting-input" style="width:80px">
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.jvmArgs.title">Extra JVM Arguments</h4>
<p data-i18n="settings.jvmArgs.desc">Additional Java VM options</p>
</div>
<div class="setting-control">
<input type="text" id="jvm-args" placeholder="-XX:+UseZGC" class="setting-input" style="width:280px">
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.javaPath.title">Java Path</h4>
<p id="java-path">~/.zernmc/jre/</p>
</div>
<div class="setting-control">
<input type="text" id="java-path-input" placeholder="java" class="setting-input" style="width:280px">
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.server.title">Server</h4>
<p id="server-url">http://87.120.187.36:1582</p>
</div>
<div class="setting-control">
<span class="setting-badge" id="server-status">Checking...</span>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.language.title">Language</h4>
<p data-i18n="settings.language.desc">Interface language</p>
</div>
<div class="setting-control">
<select id="locale-select" class="setting-input" style="width:160px">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.systemJvm.title">System-based JVM Optimization</h4>
<p id="system-jvm-info">-</p>
</div>
<div class="setting-control">
<label class="toggle" id="system-jvm-toggle-wrapper">
<input type="checkbox" id="system-jvm-toggle">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.logViewer.title">Game Log</h4>
<p data-i18n="settings.logViewer.desc">View real-time game logs</p>
</div>
<div class="setting-control">
<button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Add Friend Modal -->
<div id="add-friend-modal" class="modal-backdrop hidden">
<div class="modal modal-sm">
<div class="modal-head">
<h3 data-i18n="friends.addTitle">Add Friend</h3>
<button class="modal-close" onclick="app.closeAddFriend()">&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label data-i18n="friends.addLabel">Username</label>
<input type="text" id="add-friend-input" placeholder="Enter username..." data-i18n-placeholder="friends.addPlaceholder">
</div>
<button id="add-friend-submit" class="btn-primary" onclick="app.submitAddFriend()"><span data-i18n="friends.add">Add Friend</span></button>
<p id="add-friend-error" class="error-msg hidden"></p>
</div>
</div>
</div>
<!-- Log Viewer Overlay -->
<div id="log-viewer-overlay" class="modal-backdrop hidden">
<div class="modal modal-log">
<div class="modal-head">
<h3 data-i18n="logViewer.title">Game Log</h3>
<div class="log-viewer-actions">
<button class="btn-secondary btn-sm" id="copy-log-btn" onclick="app.copyLogs()"><span data-i18n="logViewer.copy">Copy</span></button>
<button class="btn-secondary btn-sm" onclick="app.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()">&times;</button>
</div>
</div>
<div class="modal-body log-viewer-body">
<div id="log-viewer-content" class="log-viewer-content"></div>
</div>
</div>
</div>
<!-- Install Modal -->
<div id="install-modal" class="modal-backdrop hidden">
<div class="modal">
<div class="modal-head">
<h3 data-i18n="install.title">Install Pack</h3>
<button class="modal-close" id="close-modal-btn">&times;</button>
</div>
<div class="modal-body">
<div class="modal-tabs">
<button class="modal-tab active" data-tab="zernmc"><span data-i18n="install.tab.serverPack">Server Pack</span></button>
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span> <span class="tag-wip">WIP</span></button>
</div>
<div id="tab-zernmc" class="modal-tab-content active">
<div class="field">
<label data-i18n="install.serverPack.label">Server Pack</label>
<select id="zernmc-pack-select">
<option value="">Loading...</option>
</select>
</div>
<div class="field">
<label data-i18n="install.localName.label">Local Name</label>
<input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
</div>
<button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
</div>
<div id="tab-custom" class="modal-tab-content">
<div class="disabled-tab">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<h3 data-i18n="install.custom.unavailable">Not available yet</h3>
<p data-i18n="install.custom.desc">Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.</p>
</div>
</div>
<div id="install-progress" class="install-progress hidden">
<div class="progress-track">
<div class="progress-fill" id="progress-fill"></div>
</div>
<p class="progress-label" id="progress-label" data-i18n="install.progress.installing">Installing...</p>
<p class="progress-stage hidden" id="progress-stage"></p>
</div>
</div>
</div>
</div>
<!-- Notification Toast -->
<div id="toast" class="toast hidden"></div>
</div>
<script src="marked.min.js"></script>
<script src="launcher.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,759 @@
:root {
--bg-deep: #07070a;
--bg-surface: #0c0c12;
--bg-elevated: #111118;
--bg-card: #16161f;
--bg-card-hover: #1c1c28;
--bg-inset: #0a0a0f;
--accent: #e94560;
--accent-glow: rgba(233, 69, 96, 0.25);
--accent-soft: rgba(233, 69, 96, 0.1);
--text: #eeeef0;
--text-secondary: #88889a;
--text-muted: #555566;
--border: #1e1e2a;
--border-light: #2a2a3a;
--success: #4ade80;
--error: #f87171;
--warning: #fbbf24;
--info: #60a5fa;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow: 0 4px 24px rgba(0,0,0,0.5);
--shadow-glow: 0 0 40px var(--accent-glow);
--transition: 200ms ease;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', Arial, sans-serif;
--mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
font-family: var(--font);
background: var(--bg-deep);
color: var(--text);
min-height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
#bg-canvas {
position: fixed; inset: 0; width: 100%; height: 100%;
z-index: 0; opacity: 0.08; pointer-events: none;
}
#app { position: relative; z-index: 1; height: 100vh; display: flex; }
.screen {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
transition: opacity 0.4s ease, transform 0.4s ease;
}
.screen.hidden { opacity: 0; transform: scale(0.97); pointer-events: none; }
.hidden { display: none !important; }
/* ========== LOGIN ========== */
.login-container {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 48px 40px 40px;
width: 100%;
max-width: 380px;
box-shadow: var(--shadow);
animation: floatIn 0.5s ease forwards;
}
@keyframes floatIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-brand { text-align: center; margin-bottom: 36px; }
.brand-icon { margin-bottom: 16px; }
.brand-title {
font-size: 28px; font-weight: 800;
color: var(--text);
}
.brand-sub { color: var(--text-muted); font-size: 13px; margin-top: 4px; }
.login-form { display: flex; flex-direction: column; gap: 20px; }
.field { position: relative; }
.field label {
position: absolute; top: 50%; left: 14px; transform: translateY(-50%);
font-size: 13px; color: var(--text-muted);
transition: var(--transition); pointer-events: none;
background: var(--bg-elevated); padding: 0 4px;
}
.field input:focus + label,
.field input:not(:placeholder-shown) + label {
top: 0; font-size: 11px; color: var(--accent);
}
.field input {
width: 100%; padding: 14px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); transition: var(--transition);
outline: none;
}
.field input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.field select {
width: 100%; padding: 12px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); cursor: pointer; outline: none;
}
.field select:focus { border-color: var(--accent); }
.btn-primary {
width: 100%; padding: 14px; border: none; border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), #ff6b6b);
color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
font-family: var(--font); transition: var(--transition);
display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 48px; position: relative;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: var(--shadow-glow); }
.btn-primary:active { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.error-msg {
color: var(--error); font-size: 13px; text-align: center;
padding: 10px; background: rgba(248,113,113,0.1);
border-radius: var(--radius-sm); animation: shake 0.4s ease;
}
@keyframes shake {
0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}
}
.login-hint { text-align: center; font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.spinner {
position: absolute; width: 20px; height: 20px;
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
border-radius: 50%; animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ========== OVERLAY ========== */
.overlay {
position: fixed; inset: 0; background: rgba(7,7,10,0.92);
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 100; animation: fadeIn 0.3s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.loader-ring {
width: 48px; height: 48px;
border: 3px solid var(--border-light); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px;
}
.loader-text { color: var(--text-secondary); font-size: 14px; }
/* ========== MAIN SHELL ========== */
.shell {
display: flex; width: 100%; height: 100vh;
background: var(--bg-surface);
}
/* ========== SIDEBAR ========== */
.sidebar {
width: 260px; min-width: 260px;
background: var(--bg-deep);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
padding: 16px 12px;
}
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 20px; overflow: hidden; }
.sidebar-brand {
display: flex; align-items: center; gap: 10px;
padding: 4px 8px 16px; border-bottom: 1px solid var(--border);
}
.sidebar-brand-text { display: flex; flex-direction: column; }
.sidebar-brand-name { font-size: 16px; font-weight: 700; }
.sidebar-brand-ver { font-size: 11px; color: var(--text-muted); }
.sidebar-nav {
display: flex; gap: 4px;
padding-bottom: 16px; border-bottom: 1px solid var(--border);
}
.nav-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px; background: transparent; border: 1px solid transparent;
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 11px;
font-weight: 500; cursor: pointer; font-family: var(--font);
transition: var(--transition);
}
.nav-btn:hover { color: var(--text-secondary); background: var(--bg-card); }
.nav-btn.active { color: var(--accent); background: var(--accent-soft); border-color: rgba(233,69,96,0.2); }
.section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px; padding: 0 4px;
}
.section-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;
font-size: 12px; color: var(--text-muted); text-align: center;
}
.pack-entry {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: var(--radius-sm);
cursor: pointer; transition: var(--transition);
border: 1px solid transparent;
}
.pack-entry:hover { background: var(--bg-card); }
.pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); }
.pack-entry-icon {
width: 32px; height: 32px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.pack-entry-icon.server { background: rgba(251,191,36,0.15); color: var(--warning); }
.pack-entry-icon.local { background: var(--accent-soft); color: var(--accent); }
.pack-entry-info { flex: 1; min-width: 0; }
.pack-entry-name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pack-entry-meta { font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.btn-icon {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid transparent; border-radius: var(--radius-sm);
color: var(--text-muted); cursor: pointer; transition: var(--transition); flex-shrink: 0;
}
.btn-icon:hover { color: var(--text); background: var(--bg-card); border-color: var(--border-light); }
/* Sidebar bottom */
.sidebar-bottom { padding-top: 12px; border-top: 1px solid var(--border); }
.user-card {
display: flex; align-items: center; gap: 10px;
padding: 8px; border-radius: var(--radius-sm);
}
.user-avatar {
width: 32px; height: 32px; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #ff6b6b);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 14px; color: #fff; flex-shrink: 0;
}
.user-info { flex: 1; min-width: 0; }
.user-name { font-size: 13px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-badges { display: flex; gap: 4px; margin-top: 2px; }
.badge {
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.badge-pro { background: rgba(74,222,128,0.15); color: var(--success); }
.badge-free { background: rgba(248,113,113,0.12); color: var(--error); }
.badge-role { background: rgba(96,165,250,0.15); color: var(--info); }
/* ========== CONTENT ========== */
.content {
flex: 1; display: flex; flex-direction: column;
padding: 24px 32px; min-width: 0;
position: relative;
overflow-y: auto;
}
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
.view.active { display: flex; }
.view-header {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 24px; gap: 16px;
}
.view-title { font-size: 22px; font-weight: 700; }
.view-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 4px; }
.view-actions { display: flex; gap: 8px; flex-shrink: 0; }
.btn-secondary {
display: flex; align-items: center; gap: 6px;
padding: 8px 16px; background: var(--bg-card); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 13px;
font-weight: 500; cursor: pointer; font-family: var(--font);
transition: var(--transition);
}
.btn-secondary:hover { background: var(--bg-card-hover); color: var(--text); border-color: var(--border); }
.btn-secondary.btn-danger:hover { color: var(--error); border-color: rgba(248,113,113,0.3); background: rgba(248,113,113,0.08); }
/* ========== PACK DETAIL ========== */
.pack-detail { flex: 1; display: flex; }
.pack-empty {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; color: var(--text-muted);
}
.pack-empty h3 { font-size: 18px; font-weight: 600; color: var(--text-secondary); }
.pack-empty p { font-size: 13px; }
.pack-detail-content { flex: 1; display: flex; flex-direction: column; gap: 24px; }
.pack-hero { display: flex; align-items: center; gap: 16px; }
.pack-icon {
width: 56px; height: 56px; border-radius: var(--radius-md);
background: var(--bg-card); border: 1px solid var(--border-light);
display: flex; align-items: center; justify-content: center; color: var(--accent);
}
.detail-name { font-size: 20px; font-weight: 700; }
.detail-tags { display: flex; gap: 6px; margin-top: 6px; }
.tag {
font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 4px;
}
.tag-mc { background: var(--bg-card); color: var(--text-secondary); }
.tag-loader { background: rgba(99,102,241,0.15); color: #818cf8; }
.tag-server { background: rgba(251,191,36,0.15); color: var(--warning); }
.pack-stats {
display: flex; gap: 24px; padding: 16px;
background: var(--bg-card); border-radius: var(--radius-md);
border: 1px solid var(--border);
}
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-value { font-size: 18px; font-weight: 700; color: var(--text); }
.stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
/* ========== PLAY BAR ========== */
.play-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; margin-top: auto;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.play-bar-info { font-size: 14px; font-weight: 500; color: var(--text-secondary); }
/* ========== PACK DESCRIPTION ========== */
.pack-description {
padding: 16px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: var(--radius-md);
}
.pack-description-text {
font-size: 13px; color: var(--text-secondary); line-height: 1.6;
}
.pack-gallery {
display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap;
}
.pack-gallery-item {
width: 120px; height: 80px; border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border-light);
display: flex; align-items: center; justify-content: center;
color: var(--text-muted); font-size: 11px;
overflow: hidden;
}
.pack-gallery-item img {
width: 100%; height: 100%; object-fit: cover;
}
.pack-description-text .news-link {
color: var(--accent); text-decoration: underline; cursor: pointer;
}
.pack-description-text .news-link:hover {
color: var(--accent-hover);
}
.btn-play {
display: flex; align-items: center; gap: 8px;
padding: 12px 28px; border: none; border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--success), #22c55e);
color: #07070a; font-size: 15px; font-weight: 700; cursor: pointer;
font-family: var(--font); transition: var(--transition);
box-shadow: 0 4px 20px rgba(74,222,128,0.35);
}
.btn-play:hover:not(:disabled) { transform: translateY(-2px) scale(1.02); box-shadow: 0 8px 32px rgba(74,222,128,0.45); }
.btn-play:active:not(:disabled) { transform: translateY(0); }
.btn-play:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
/* ========== CUSTOM SELECT ========== */
.custom-select-wrap {
position: relative;
width: 100%;
}
.custom-select-trigger {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 10px 12px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-size: 13px; font-family: var(--font); cursor: pointer;
transition: var(--transition); user-select: none;
gap: 8px;
}
.custom-select-trigger:hover { border-color: var(--text-muted); }
.custom-select-trigger.open { border-color: var(--accent); }
.custom-select-trigger .arrow {
width: 16px; height: 16px; flex-shrink: 0;
transition: transform 0.2s ease; opacity: 0.5;
}
.custom-select-trigger.open .arrow { transform: rotate(180deg); }
.custom-select-trigger .placeholder { color: var(--text-muted); }
.custom-select-dropdown {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm); box-shadow: var(--shadow);
z-index: 100; max-height: 240px; display: none;
flex-direction: column;
}
.custom-select-dropdown.open { display: flex; }
.custom-select-search {
padding: 8px; border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--bg-elevated);
z-index: 1;
}
.custom-select-search input {
width: 100%; padding: 6px 10px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: 4px; color: var(--text); font-size: 12px;
font-family: var(--font); outline: none;
}
.custom-select-search input:focus { border-color: var(--accent); }
.custom-select-options {
overflow-y: auto; flex: 1;
}
.custom-select-option {
padding: 8px 12px; cursor: pointer; font-size: 13px;
color: var(--text-secondary); transition: var(--transition);
}
.custom-select-option:hover { background: var(--bg-card); color: var(--text); }
.custom-select-option.selected { background: var(--accent-soft); color: var(--accent); }
.custom-select-option.hidden { display: none; }
/* ========== NEWS ========== */
.news-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px; overflow-y: auto; padding-bottom: 24px;
}
.news-loading {
grid-column: 1 / -1; text-align: center; padding: 60px 20px;
color: var(--text-muted); font-size: 14px;
}
.news-empty {
grid-column: 1 / -1; text-align: center; padding: 60px 20px;
color: var(--text-muted); font-size: 14px;
}
.news-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 24px; display: flex;
flex-direction: column; gap: 12px; transition: var(--transition);
cursor: pointer;
}
.news-card:hover { border-color: var(--border-light); transform: translateY(-1px); }
.news-preview {
font-size: 13px; color: var(--text-secondary); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden;
}
.news-card-badge {
align-self: flex-start; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 1px; padding: 4px 10px; border-radius: 4px;
}
.news-placeholder .news-card-badge { background: var(--accent-soft); color: var(--accent); }
.news-card h3 { font-size: 16px; font-weight: 600; }
.news-card p { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
.news-card time { font-size: 11px; color: var(--text-muted); margin-top: auto; }
.news-card-badge.type-Update { background: rgba(96,165,250,0.15); color: var(--info); }
.news-card-badge.type-Announcement { background: rgba(251,191,36,0.15); color: var(--warning); }
.news-card-badge.type-Event { background: rgba(74,222,128,0.15); color: var(--success); }
.news-modal-body {
max-height: 60vh; overflow-y: auto; line-height: 1.7;
font-size: 14px; color: var(--text-secondary);
}
.news-modal-body .news-text-line { display: inline; }
.news-modal-body .news-link {
color: var(--info); text-decoration: underline; cursor: pointer;
}
.news-modal-body .news-link:hover { color: var(--accent); }
.news-modal-body .news-photo {
display: block; max-width: 100%; border-radius: var(--radius-sm);
margin: 12px 0; cursor: pointer; border: 1px solid var(--border);
transition: var(--transition);
}
.news-modal-body .news-photo:hover { opacity: 0.9; }
.modal-news { max-width: 640px; }
/* ========== SETTINGS ========== */
.settings-grid { display: flex; flex-direction: column; gap: 12px; }
.setting-card {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); gap: 24px;
}
.setting-info h4 { font-size: 14px; font-weight: 600; }
.setting-info p { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.setting-control { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.setting-control input[type="range"] {
width: 160px; height: 4px; -webkit-appearance: none; appearance: none;
background: var(--border); border-radius: 2px; outline: none;
}
.setting-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); cursor: pointer; border: 2px solid var(--bg-deep);
}
.setting-value { font-size: 14px; font-weight: 600; color: var(--text); min-width: 48px; text-align: right; }
.setting-badge {
font-size: 12px; padding: 4px 10px; border-radius: 4px;
background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-light);
}
.setting-pass { display: flex; align-items: center; gap: 8px; }
.pass-input {
width: 160px; padding: 6px 12px; border-radius: var(--radius-sm);
background: var(--bg-inset); border: 1px solid var(--border-light);
color: var(--text); font-size: 13px; outline: none;
}
.pass-input:focus { border-color: var(--accent); }
.setting-input {
padding: 6px 10px; border-radius: var(--radius-sm);
background: var(--bg-inset); border: 1px solid var(--border-light);
color: var(--text); font-size: 13px; outline: none; font-family: var(--mono);
}
.setting-input:focus { border-color: var(--accent); }
.btn-sm { padding: 6px 14px !important; font-size: 12px !important; }
/* ========== TOGGLE ========== */
.toggle {
position: relative; display: inline-block; width: 44px; height: 24px; cursor: pointer;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; background: var(--border-light); border-radius: 12px; transition: var(--transition);
}
.toggle-slider::before {
content: ''; position: absolute; left: 3px; bottom: 3px; width: 18px; height: 18px;
background: var(--text-secondary); border-radius: 50%; transition: var(--transition);
}
.toggle input:checked + .toggle-slider { background: var(--accent); }
.toggle input:checked + .toggle-slider::before { transform: translateX(20px); background: #fff; }
/* ========== LOCALE SELECT ========== */
#locale-select { font-family: var(--font); cursor: pointer; }
/* ========== MODAL ========== */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(7,7,10,0.85);
display: flex; align-items: center; justify-content: center; z-index: 50;
animation: fadeIn 0.2s ease;
}
.modal {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); width: 90%; max-width: 480px;
max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);
animation: floatIn 0.3s ease;
}
.modal-head {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 24px; border-bottom: 1px solid var(--border);
}
.modal-head h3 { font-size: 17px; font-weight: 600; }
.modal-close {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
background: transparent; border: none; color: var(--text-muted);
font-size: 22px; cursor: pointer; border-radius: var(--radius-sm); transition: var(--transition);
}
.modal-close:hover { color: var(--text); background: var(--bg-card); }
.modal-body { padding: 20px 24px 24px; }
.modal-tabs { display: flex; gap: 8px; margin-bottom: 20px; }
.modal-tab {
flex: 1; padding: 10px; background: transparent; border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 13px;
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
}
.modal-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); }
.modal-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); }
.modal-tab-content { display: none; flex-direction: column; gap: 16px; }
.modal-tab-content.active { display: flex; }
.modal-tab-content .field label {
display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary);
margin-bottom: 6px; position: static; transform: none;
background: none; padding: 0;
}
.tag-wip {
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
background: rgba(251,191,36,0.15); color: var(--warning); vertical-align: middle; margin-left: 4px;
}
.disabled-tab {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 40px 20px; text-align: center; color: var(--text-muted);
}
.disabled-tab h3 { font-size: 18px; font-weight: 600; color: var(--text-secondary); }
.disabled-tab p { font-size: 13px; line-height: 1.5; max-width: 300px; }
.select-wrap select {
width: 100%; padding: 10px 12px; font-size: 13px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); cursor: pointer; outline: none;
}
.select-wrap select:focus { border-color: var(--accent); }
.install-progress { padding-top: 16px; border-top: 1px solid var(--border); }
.progress-track {
height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden;
}
.progress-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, var(--accent), #ff6b6b);
border-radius: 3px; transition: width 0.3s ease;
}
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
.progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; }
/* ========== LOG VIEWER ========== */
.modal-log { max-width: 800px; }
.log-viewer-actions { display: flex; align-items: center; gap: 8px; }
.log-viewer-body { padding: 0 !important; }
.log-viewer-content {
font-family: var(--mono);
font-size: 11px;
line-height: 1.5;
background: var(--bg-deep);
padding: 12px 16px;
max-height: 50vh;
overflow-y: auto;
user-select: text;
-webkit-user-select: text;
white-space: pre-wrap;
word-break: break-all;
}
.log-line { padding: 1px 0; }
.log-empty { color: var(--text-muted); font-family: var(--font); font-size: 13px; padding: 20px; text-align: center; }
.log-error { color: #f87171; }
.log-warn { color: #fbbf24; }
.log-info { color: #4ade80; }
.log-debug { color: #60a5fa; }
/* ========== TOAST ========== */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 12px 24px; border-radius: var(--radius-sm);
font-size: 13px; font-weight: 500; z-index: 200;
background: var(--bg-elevated); border: 1px solid var(--border);
color: var(--text); box-shadow: var(--shadow);
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
}
.toast.error { border-color: rgba(248,113,113,0.3); color: var(--error); }
.toast.success { border-color: rgba(74,222,128,0.3); color: var(--success); }
.toast.warning { border-color: rgba(251,191,36,0.3); color: var(--warning); }
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
/* ========== SCROLLBAR ========== */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ========== RESPONSIVE ========== */
@media (max-width: 900px) {
.sidebar { width: 200px; min-width: 200px; }
.content { padding: 16px; }
}
@media (max-width: 700px) {
.sidebar { width: 56px; min-width: 56px; }
.sidebar-brand-text, .sidebar-nav .nav-btn span,
.section-header, .pack-entry-info, .user-info,
.sidebar-bottom .user-card .btn-icon:first-child { display: none; }
.sidebar-brand { justify-content: center; padding: 8px; }
.sidebar-nav { flex-direction: column; }
.nav-btn { padding: 8px; }
.pack-entry { justify-content: center; padding: 8px; }
.content { padding: 12px; }
.play-bar { flex-direction: column; gap: 12px; }
.view-header { flex-direction: column; }
}
/* ========== FRIENDS ========== */
.friends-search {
display: flex; align-items: center; gap: 8px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 12px;
}
.friends-search svg { flex-shrink: 0; color: var(--text-muted); }
.friends-search input {
flex: 1; background: transparent; border: none; outline: none;
color: var(--text); font-size: 13px; font-family: var(--font);
}
.friends-search input::placeholder { color: var(--text-muted); }
.friends-list { display: flex; flex-direction: column; gap: 4px; }
.friends-empty {
text-align: center; padding: 40px 20px; color: var(--text-muted); font-size: 13px;
}
.friends-group-label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); padding: 8px 4px 4px; letter-spacing: 0.5px;
}
.friend-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius-sm); transition: var(--transition);
}
.friend-item:hover { background: var(--bg-card-hover); }
.friend-item:hover .friend-remove-btn { opacity: 1; }
.friend-avatar {
width: 36px; height: 36px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-weight: 600; font-size: 14px;
flex-shrink: 0; position: relative;
background: var(--accent-soft); color: var(--accent);
}
.friend-avatar.online::after {
content: ''; position: absolute; bottom: 0; right: 0;
width: 10px; height: 10px; border-radius: 50%;
background: var(--success); border: 2px solid var(--bg-surface);
}
.friend-avatar.offline { opacity: 0.6; }
.friend-info { flex: 1; min-width: 0; }
.friend-name-row { display: flex; align-items: center; gap: 6px; }
.friend-name { font-size: 13px; font-weight: 500; color: var(--text); }
.friend-status { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.friend-status-dot {
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
}
.friend-status-dot.online { background: var(--success); }
.friend-status-dot.offline { background: var(--text-muted); }
.friend-pack { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.friend-remove-btn {
opacity: 0; transition: var(--transition); flex-shrink: 0;
width: 28px; height: 28px; color: var(--text-muted);
}
.friend-remove-btn:hover { color: var(--error); }
.friend-requests-section {
margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border);
}
.friend-requests-section .section-header {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px;
}
.friend-request-item {
display: flex; align-items: center; gap: 10px; padding: 10px;
background: var(--bg-card); border-radius: var(--radius-sm);
margin-bottom: 6px;
}
.friend-request-avatar {
width: 36px; height: 36px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-weight: 600; font-size: 14px;
flex-shrink: 0; background: var(--accent-soft); color: var(--accent);
}
.friend-request-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; }
.friend-request-name { font-size: 13px; font-weight: 500; }
.friend-request-text { font-size: 11px; color: var(--text-muted); }
.friend-request-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ========== MODAL SM ========== */
.modal-sm { max-width: 360px; }
/* ========== BADGE SM ========== */
.badge-sm { font-size: 10px; padding: 2px 6px; }
@@ -0,0 +1,90 @@
package me.sashegdev.zernmc.launcher.auth;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for AuthManager error extraction and response parsing.
* Tests the contract between server error responses and Java client parsing.
*/
class AuthManagerParsingTest {
@Test
void extractError_simpleStringDetail() {
// Server: raise HTTPException(401, "Неверное имя пользователя или пароль")
String body = "{\"detail\":\"Неверное имя пользователя или пароль\"}";
String error = extractError(body);
assertEquals("Неверное имя пользователя или пароль", error);
}
@Test
void extractError_validationErrorArray() {
// FastAPI 422: {"detail": [{"loc": ["body", "username"], "msg": "...", "type": "..."}]}
String body = "{" +
"\"detail\":[" +
"{\"loc\":[\"body\",\"username\"],\"msg\":\"String should have at least 3 characters\",\"type\":\"string_too_short\"}" +
"]" +
"}";
String error = extractError(body);
assertEquals("String should have at least 3 characters", error);
}
@Test
void extractError_multipleValidationErrors_returnsFirst() {
String body = "{" +
"\"detail\":[" +
"{\"loc\":[\"body\",\"username\"],\"msg\":\"Username error\",\"type\":\"value_error\"}," +
"{\"loc\":[\"body\",\"password\"],\"msg\":\"Password error\",\"type\":\"value_error\"}" +
"]" +
"}";
String error = extractError(body);
assertEquals("Username error", error);
}
@Test
void extractError_plainTextBody() {
// Non-JSON error body
String body = "Internal Server Error";
String error = extractError(body);
assertEquals("Internal Server Error", error);
}
@Test
void extractError_longBody_truncated() {
String longBody = "A".repeat(300);
String error = extractError(longBody);
assertEquals(203, error.length()); // 200 + "..."
assertTrue(error.endsWith("..."));
}
@Test
void extractError_emptyDetail() {
String body = "{\"detail\":\"\"}";
String error = extractError(body);
assertEquals("", error);
}
@Test
void extractError_noDetailField_returnsBody() {
String body = "{\"error\":\"something went wrong\"}";
String error = extractError(body);
assertEquals("{\"error\":\"something went wrong\"}", error);
}
/**
* Replicates AuthManager.extractError() logic for testing.
* If this passes, the real method in AuthManager works correctly.
*/
private static String extractError(String body) {
try {
com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(body).getAsJsonObject();
if (json.has("detail")) {
if (json.get("detail").isJsonArray()) {
return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
}
return json.get("detail").getAsString();
}
} catch (Exception ignored) {}
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
}
}
@@ -0,0 +1,274 @@
package me.sashegdev.zernmc.launcher.auth;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
class AuthManagerPassTest {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static AuthManager.AuthSession createSession(String token, int role) {
AuthManager.AuthSession s = new AuthManager.AuthSession();
s.accessToken = token;
s.role = role;
s.expiresAt = System.currentTimeMillis() / 1000L + 3600;
return s;
}
@Test
void hasPass_returnsFalse_whenNotLoggedIn() {
AuthManager.resetForTest();
assertFalse(AuthManager.hasPass());
assertFalse(AuthManager.hasActivePass());
}
@Test
void hasPass_usesUserInfo_whenAvailable() {
AuthManager.UserInfo info = new AuthManager.UserInfo();
info.has_pass = true;
info.role = 1;
AuthManager.setTestUserInfo(info);
AuthManager.setTestSession(createSession("tok", 1));
assertTrue(AuthManager.hasPass());
assertTrue(AuthManager.hasActivePass());
}
@Test
void hasPass_usesRole_whenUserInfoNull() {
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
assertTrue(AuthManager.hasPass());
assertTrue(AuthManager.hasActivePass());
}
@Test
void hasPass_returnsFalse_whenRoleTooLow() {
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_USER));
assertFalse(AuthManager.hasPass());
assertFalse(AuthManager.hasActivePass());
}
@Test
void hasPass_userInfoTakesPriorityOverRole() {
AuthManager.UserInfo info = new AuthManager.UserInfo();
info.has_pass = false;
info.role = 1;
AuthManager.setTestUserInfo(info);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
assertFalse(AuthManager.hasPass());
assertFalse(AuthManager.hasActivePass());
}
@Test
void canViewPacks_usesPermissions_whenAvailable() {
AuthManager.UserInfo info = new AuthManager.UserInfo();
info.permissions = List.of("view_packs", "download_pack");
info.has_pass = true;
AuthManager.setTestUserInfo(info);
AuthManager.setTestSession(createSession("tok", 1));
assertTrue(AuthManager.canViewPacks());
assertTrue(AuthManager.canDownloadPacks());
}
@Test
void canViewPacks_fallsBackToHasPass_whenNoPermissions() {
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
assertTrue(AuthManager.canViewPacks());
assertTrue(AuthManager.canDownloadPacks());
}
@Test
void authSession_parsesFromLoginResponse() {
String json = """
{
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test",
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh",
"expires_in": 86400,
"token_type": "bearer",
"username": "testuser",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"role": 1,
"role_name": "PASS_HOLDER"
}
""";
AuthManager.AuthSession session = GSON.fromJson(json, AuthManager.AuthSession.class);
assertNotNull(session);
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test", session.accessToken);
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh", session.refreshToken);
assertEquals(86400, session.expiresIn);
assertEquals("testuser", session.username);
assertEquals("550e8400-e29b-41d4-a716-446655440000", session.uuid);
assertEquals(1, session.role);
}
@Test
void authSession_roundTrip() {
AuthManager.AuthSession original = new AuthManager.AuthSession();
original.accessToken = "access123";
original.refreshToken = "refresh123";
original.expiresIn = 86400;
original.expiresAt = System.currentTimeMillis() / 1000L + 86400;
original.username = "testuser";
original.uuid = "550e8400-e29b-41d4-a716-446655440000";
original.role = 1;
String json = GSON.toJson(original);
AuthManager.AuthSession parsed = GSON.fromJson(json, AuthManager.AuthSession.class);
assertEquals(original.accessToken, parsed.accessToken);
assertEquals(original.refreshToken, parsed.refreshToken);
assertEquals(original.expiresIn, parsed.expiresIn);
assertEquals(original.expiresAt, parsed.expiresAt);
assertEquals(original.username, parsed.username);
assertEquals(original.uuid, parsed.uuid);
assertEquals(original.role, parsed.role);
}
@Test
void userInfo_parsesFromMeEndpoint() {
String json = """
{
"id": 1,
"username": "testuser",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"role": 1,
"role_name": "PASS_HOLDER",
"has_pass": true,
"permissions": ["view_packs", "download_pack"]
}
""";
AuthManager.UserInfo info = GSON.fromJson(json, AuthManager.UserInfo.class);
assertNotNull(info);
assertEquals(1, info.id);
assertEquals("testuser", info.username);
assertEquals(1, info.role);
assertEquals("PASS_HOLDER", info.role_name);
assertTrue(info.has_pass);
assertTrue(info.permissions.contains("view_packs"));
assertTrue(info.permissions.contains("download_pack"));
assertTrue(info.hasPermission("view_packs"));
assertFalse(info.hasPermission("admin"));
}
@Test
void updateRole_updatesSessionRole() {
AuthManager.resetForTest();
AuthManager.setTestSession(createSession("tok", 0));
AuthManager.setTestUserInfo(null);
assertEquals(0, AuthManager.getRole());
assertFalse(AuthManager.hasPass());
AuthManager.updateRole(1);
assertEquals(1, AuthManager.getRole());
}
@Test
void isLoggedIn_returnsTrue_whenSessionExists() {
AuthManager.resetForTest();
assertFalse(AuthManager.isLoggedIn());
AuthManager.AuthSession s = createSession("tok", 0);
s.username = "testuser";
AuthManager.setTestSession(s);
assertTrue(AuthManager.isLoggedIn());
assertEquals("testuser", AuthManager.getUsername());
}
@Test
void getUsername_returnsSessionUsername() {
AuthManager.AuthSession s = createSession("tok", 0);
s.username = "testuser";
AuthManager.setTestSession(s);
assertEquals("testuser", AuthManager.getUsername());
}
@Test
void getRole_returnsZero_whenSessionNull() {
AuthManager.resetForTest();
assertEquals(0, AuthManager.getRole());
}
@Test
void getRoleName_fallsBackToUSER_whenUserInfoNull() {
AuthManager.resetForTest();
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", 0));
assertEquals("USER", AuthManager.getRoleName());
}
@Test
void getAccessToken_returnsToken_whenSessionValid() {
AuthManager.resetForTest();
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("valid-token", 1));
String token = AuthManager.getAccessToken();
assertEquals("valid-token", token);
}
@Test
void getAccessToken_doesNotInvalidate_whenNoRefreshToken() {
AuthManager.resetForTest();
AuthManager.setTestUserInfo(null);
AuthManager.AuthSession s = createSession("tok", 1);
s.refreshToken = null;
AuthManager.setTestSession(s);
String token = AuthManager.getAccessToken();
assertEquals("tok", token);
assertTrue(AuthManager.isLoggedIn());
}
@Test
void getAccessToken_returnsZero_whenSessionNull() {
AuthManager.resetForTest();
assertEquals("0", AuthManager.getAccessToken());
}
@Test
void invalidateSession_clearsState() {
AuthManager.resetForTest();
AuthManager.setTestSession(createSession("tok", 1));
AuthManager.setTestUserInfo(new AuthManager.UserInfo());
assertTrue(AuthManager.isLoggedIn());
AuthManager.logout();
assertFalse(AuthManager.isLoggedIn());
assertEquals(0, AuthManager.getRole());
}
@Test
void loadSavedSession_returnsFalse_whenNoAuthFile() {
AuthManager.resetForTest();
AuthManager.logout();
assertFalse(AuthManager.loadSavedSession());
}
}
@@ -0,0 +1,469 @@
package me.sashegdev.zernmc.launcher.integration;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
/**
* Integration tests: real Java client ↔ real Python server.
*
* These tests:
* 1. Start the FastAPI test server via Python subprocess
* 2. Use actual Java HTTP client code to make requests
* 3. Verify JSON parsing and response handling
*
* Requires: Python 3, pytest, and the server/.venv to be available.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ServerIntegrationTest {
private static Process serverProcess;
private static String serverBaseUrl;
private static Path testDir;
private static final Gson gson = new GsonBuilder().setPrettyPrinting().create();
@BeforeAll
static void startTestServer() throws Exception {
// Create temp directory for test data
testDir = Files.createTempDirectory("zern_integration_test_");
// Find the server directory
String serverDir = findServerDir();
if (serverDir == null) {
System.out.println("WARNING: Server directory not found, skipping integration tests");
serverBaseUrl = null;
return;
}
// Start the test server on a random port
int port = findFreePort();
serverBaseUrl = "http://127.0.0.1:" + port;
System.out.println("Starting test server on " + serverBaseUrl);
System.out.println("Server directory: " + serverDir);
// Find Python executable (prefer venv python)
String pythonPath = findPythonPath(serverDir);
if (pythonPath == null) {
System.out.println("WARNING: Python not found, skipping integration tests");
serverBaseUrl = null;
return;
}
// Create a Python startup script that properly sets up paths
String startupScript =
"import sys, os, tempfile\n" +
"from pathlib import Path\n" +
"sys.path.insert(0, '" + serverDir + "')\n" +
"os.chdir('" + serverDir + "')\n" +
"import auth\n" +
"db_dir = tempfile.mkdtemp()\n" +
"auth.AUTH_DB = Path(db_dir) / 'auth.db'\n" +
"auth.SECRET_KEY = Path(db_dir) / '.secret_key'\n" +
"auth.init_db()\n" +
"import uvicorn\n" +
"import main\n" +
"uvicorn.run(main.app, host='127.0.0.1', port=" + port + ", log_level='error')\n";
ProcessBuilder pb = new ProcessBuilder(pythonPath, "-c", startupScript);
pb.directory(new File(serverDir));
pb.redirectErrorStream(true);
try {
serverProcess = pb.start();
System.out.println("Server process started, PID: " + serverProcess.pid());
} catch (IOException e) {
System.out.println("WARNING: Could not start server process: " + e.getMessage());
System.out.println("Skipping integration tests");
serverBaseUrl = null;
return;
}
// Wait for server to start
Thread.sleep(4000);
// Verify server is running
try {
URL url = new URL(serverBaseUrl + "/health");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.connect();
if (conn.getResponseCode() != 200) {
System.out.println("WARNING: Server health check failed: " + conn.getResponseCode());
System.out.println("Skipping integration tests");
serverBaseUrl = null;
if (serverProcess != null) serverProcess.destroy();
conn.disconnect();
return;
}
conn.disconnect();
System.out.println("Test server started successfully");
} catch (Exception e) {
System.out.println("WARNING: Server failed to start: " + e.getMessage());
System.out.println("Skipping integration tests");
serverBaseUrl = null;
if (serverProcess != null) serverProcess.destroy();
}
}
@AfterAll
static void stopTestServer() {
if (serverProcess != null) {
serverProcess.destroy();
try {
serverProcess.waitFor(5000, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {}
}
// Cleanup temp dir
if (testDir != null) {
try {
Files.walk(testDir)
.sorted(java.util.Comparator.reverseOrder())
.forEach(path -> {
try { Files.delete(path); } catch (IOException ignored) {}
});
} catch (IOException ignored) {}
}
}
@BeforeEach
void setUp() {
if (serverBaseUrl != null) {
ZHttpClient.setBaseUrl(serverBaseUrl);
}
}
// ===== Auth flow tests =====
@Test
@Order(1)
void testRegister() throws Exception {
assumeServerRunning();
String response = httpPost("/auth/register", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("access_token"));
assertTrue(json.has("refresh_token"));
assertTrue(json.has("expires_in"));
assertTrue(json.has("uuid"));
assertEquals("integration_test_user", json.get("username").getAsString());
assertTrue(json.has("role"));
}
@Test
@Order(2)
void testLogin() throws Exception {
assumeServerRunning();
String response = httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("access_token"));
assertTrue(json.has("refresh_token"));
assertEquals("integration_test_user", json.get("username").getAsString());
assertTrue(json.has("role"));
assertTrue(json.has("uuid"));
}
@Test
@Order(3)
void testDuplicateRegistration() throws Exception {
assumeServerRunning();
try {
httpPost("/auth/register", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"AnotherPassword123\"" +
"}");
fail("Should have thrown IOException for duplicate registration");
} catch (IOException e) {
assertTrue(e.getMessage().contains("409") || e.getMessage().contains("409"),
"Expected 409 conflict, got: " + e.getMessage());
}
}
@Test
@Order(4)
void testLoginWrongPassword() throws Exception {
assumeServerRunning();
try {
httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"WrongPassword\"" +
"}");
fail("Should have thrown IOException for wrong password");
} catch (IOException e) {
assertTrue(e.getMessage().contains("401"),
"Expected 401, got: " + e.getMessage());
}
}
@Test
@Order(5)
void testGetAdminMe() throws Exception {
assumeServerRunning();
// Login to get token
String loginResp = httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
String token = loginJson.get("access_token").getAsString();
// Get user info
String response = httpGet("/admin/me", token);
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("id"));
assertEquals("integration_test_user", json.get("username").getAsString());
assertTrue(json.has("uuid"));
assertTrue(json.has("role"));
assertTrue(json.has("role_name"));
assertTrue(json.has("has_pass"));
assertTrue(json.has("permissions"));
}
@Test
@Order(6)
void testValidateToken() throws Exception {
assumeServerRunning();
String loginResp = httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
String token = loginJson.get("access_token").getAsString();
String uuid = loginJson.get("uuid").getAsString();
// Validate
String response = httpPost("/auth/validate",
"{\"username\":\"integration_test_user\",\"uuid\":\"" + uuid + "\"}",
token);
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("valid"));
assertTrue(json.get("valid").getAsBoolean());
assertEquals("integration_test_user", json.get("username").getAsString());
}
@Test
@Order(7)
void testRefreshToken() throws Exception {
assumeServerRunning();
String loginResp = httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
String refreshToken = loginJson.get("refresh_token").getAsString();
// Refresh
String response = httpPost("/auth/refresh",
"{\"refresh_token\":\"" + refreshToken + "\"}");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("access_token"));
assertTrue(json.has("refresh_token"));
assertTrue(json.has("expires_in"));
assertEquals("integration_test_user", json.get("username").getAsString());
}
// ===== Pack endpoint tests =====
@Test
@Order(8)
void testPacksNoAuth() throws Exception {
assumeServerRunning();
try {
httpGet("/packs");
fail("Should have thrown IOException for unauthenticated access");
} catch (IOException e) {
assertTrue(e.getMessage().contains("401") || e.getMessage().contains("403"));
}
}
@Test
@Order(9)
void testPackManifestPublic() throws Exception {
assumeServerRunning();
// /pack/{name} is public
try {
String response = httpGet("/pack/nonexistent-pack");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
fail("Should have thrown IOException for non-existent pack");
} catch (IOException e) {
assertTrue(e.getMessage().contains("404"),
"Expected 404, got: " + e.getMessage());
}
}
@Test
@Order(10)
void testLauncherVersion() throws Exception {
assumeServerRunning();
String response = httpGet("/launcher/version");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("version") || json.has("latest"));
}
// ===== Helper methods =====
private static void assumeServerRunning() {
org.junit.jupiter.api.Assumptions.assumeTrue(
serverBaseUrl != null && serverProcess != null && serverProcess.isAlive(),
"Test server is not running"
);
}
private static String httpPost(String endpoint, String body) throws IOException {
return httpPost(endpoint, body, null);
}
private static String httpPost(String endpoint, String body, String token) throws IOException {
URL url = new URL(serverBaseUrl + endpoint);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
if (token != null) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
conn.setDoOutput(true);
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
byte[] input = body.getBytes(StandardCharsets.UTF_8);
conn.setFixedLengthStreamingMode(input.length);
try (var os = conn.getOutputStream()) {
os.write(input);
}
int code = conn.getResponseCode();
String response = readResponse(conn, code);
if (code >= 400) {
throw new IOException("HTTP " + code + ": " + response);
}
conn.disconnect();
return response;
}
private static String httpGet(String endpoint) throws IOException {
return httpGet(endpoint, null);
}
private static String httpGet(String endpoint, String token) throws IOException {
URL url = new URL(serverBaseUrl + endpoint);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
if (token != null) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
int code = conn.getResponseCode();
String response = readResponse(conn, code);
if (code >= 400) {
throw new IOException("HTTP " + code + ": " + response);
}
conn.disconnect();
return response;
}
private static String readResponse(HttpURLConnection conn, int code) throws IOException {
var is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
if (is == null) {
return "";
}
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
return scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
}
}
private static String findPythonPath(String serverDir) {
String[] paths = {
serverDir + "/.venv/bin/python3",
serverDir + "/.venv/bin/python",
"python3",
"python"
};
for (String path : paths) {
File f = new File(path);
if (f.exists() && f.canExecute()) {
return path;
}
// Try which command
try {
Process p = new ProcessBuilder(path, "--version").start();
int exit = p.waitFor();
if (exit == 0) return path;
} catch (Exception ignored) {}
}
return null;
}
private static String findServerDir() {
String[] paths = {
"../server",
"server",
System.getenv("SERVER_DIR")
};
for (String path : paths) {
if (path != null && new File(path).exists() && new File(path, "main.py").exists()) {
return path;
}
}
return null;
}
private static int findFreePort() throws IOException {
try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) {
return socket.getLocalPort();
}
}
private static String readProcessOutput() throws IOException {
if (serverProcess == null) return "";
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(serverProcess.getInputStream(), StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
return sb.toString();
}
}
}
@@ -0,0 +1,287 @@
package me.sashegdev.zernmc.launcher.minecraft;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for PackDownloader JSON parsing.
* Tests that the Java client correctly parses server JSON responses.
*/
class PackDownloaderParsingTest {
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
// ===== /packs response parsing =====
@Test
void parsePacksResponse_singlePack() {
String body = "{" +
"\"packs\":[" +
"{" +
"\"name\":\"test-modpack\"," +
"\"version\":3," +
"\"files_count\":15," +
"\"updated_at\":\"2024-01-15T10:30:00\"," +
"\"minecraft_version\":\"1.20.4\"," +
"\"loader_type\":\"fabric\"," +
"\"loader_version\":\"0.15.6\"" +
"}" +
"]" +
"}";
List<ServerPack> packs = parsePacksResponse(body);
assertEquals(1, packs.size());
ServerPack pack = packs.get(0);
assertEquals("test-modpack", pack.getName());
assertEquals(3, pack.getVersion());
assertEquals(15, pack.getFilesCount());
assertEquals("1.20.4", pack.getMinecraftVersion());
assertEquals("fabric", pack.getLoaderType());
assertEquals("0.15.6", pack.getLoaderVersion());
assertNotNull(pack.getUpdatedAt());
}
@Test
void parsePacksResponse_multiplePacks() {
String body = "{" +
"\"packs\":[" +
"{\"name\":\"survival\",\"version\":1,\"files_count\":5,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
"{\"name\":\"pvp\",\"version\":10,\"files_count\":50,\"minecraft_version\":\"1.20.4\",\"loader_type\":\"fabric\",\"loader_version\":\"0.15.6\",\"updated_at\":\"2024-02-01T00:00:00\"}" +
"]" +
"}";
List<ServerPack> packs = parsePacksResponse(body);
assertEquals(2, packs.size());
assertEquals("survival", packs.get(0).getName());
assertEquals("pvp", packs.get(1).getName());
}
@Test
void parsePacksResponse_skipsErroredPacks() {
String body = "{" +
"\"packs\":[" +
"{\"name\":\"good-pack\",\"version\":1,\"files_count\":1,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
"{\"name\":\"bad-pack\",\"error\":\"scan failed\"}," +
"{\"name\":\"not-scanned\",\"status\":\"not_scanned\"}" +
"]" +
"}";
List<ServerPack> packs = parsePacksResponse(body);
assertEquals(1, packs.size());
assertEquals("good-pack", packs.get(0).getName());
}
@Test
void parsePacksResponse_missingFields_defaults() {
String body = "{" +
"\"packs\":[" +
"{\"name\":\"minimal-pack\"}" +
"]" +
"}";
List<ServerPack> packs = parsePacksResponse(body);
assertEquals(1, packs.size());
ServerPack pack = packs.get(0);
assertEquals("minimal-pack", pack.getName());
assertEquals(0, pack.getVersion()); // default
assertEquals("unknown", pack.getMinecraftVersion()); // default
assertEquals("vanilla", pack.getLoaderType()); // default
assertEquals("", pack.getLoaderVersion()); // default
assertEquals(0, pack.getFilesCount()); // default
assertNull(pack.getUpdatedAt()); // default
}
@Test
void parsePacksResponse_emptyList() {
String body = "{\"packs\":[]}";
List<ServerPack> packs = parsePacksResponse(body);
assertTrue(packs.isEmpty());
}
// ===== PackManifest parsing =====
@Test
void parsePackManifest_withFiles() {
String body = "{" +
"\"pack_name\":\"my-pack\"," +
"\"version\":5," +
"\"minecraft_version\":\"1.20.4\"," +
"\"loader_type\":\"fabric\"," +
"\"loader_version\":\"0.15.6\"," +
"\"asset_index\":\"1.20.4\"," +
"\"files\":{" +
"\"mods/sodium.jar\":{\"path\":\"mods/sodium.jar\",\"url\":\"/pack/my-pack/file/mods/sodium.jar\",\"size\":1024000,\"hash\":\"abc123\"}," +
"\"mods/fabric-api.jar\":{\"path\":\"mods/fabric-api.jar\",\"url\":\"/pack/my-pack/file/mods/fabric-api.jar\",\"size\":2048000,\"hash\":\"def456\"}" +
"}" +
"}";
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
assertEquals("my-pack", manifest.getPackName());
assertEquals(5, manifest.getVersion());
assertEquals("1.20.4", manifest.getMinecraftVersion());
assertEquals("fabric", manifest.getLoaderType());
assertEquals("0.15.6", manifest.getLoaderVersion());
assertEquals("1.20.4", manifest.getAssetIndex());
assertFalse(manifest.isEmpty());
assertEquals(2, manifest.getFiles().size());
}
@Test
void parsePackManifest_nullAssetIndex_defaultsToMinecraftVersion() {
String body = "{" +
"\"pack_name\":\"no-asset\"," +
"\"version\":1," +
"\"minecraft_version\":\"1.19.4\"," +
"\"loader_type\":\"vanilla\"," +
"\"loader_version\":null" +
"}";
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
assertEquals("1.19.4", manifest.getAssetIndex()); // defaults to minecraft_version
}
@Test
void parsePackManifest_noFiles_isEmpty() {
String body = "{" +
"\"pack_name\":\"empty-pack\"," +
"\"version\":1," +
"\"minecraft_version\":\"1.20.1\"," +
"\"loader_type\":\"vanilla\"," +
"\"loader_version\":null" +
"}";
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
assertTrue(manifest.isEmpty());
}
// ===== DiffResponse parsing =====
@Test
void parseDiffResponse_allFields() {
String body = "{" +
"\"version\":6," +
"\"to_download\":[" +
"{\"path\":\"mods/new-mod.jar\",\"url\":\"/pack/test/file/mods/new-mod.jar\",\"size\":512000,\"hash\":\"aaa111\"}" +
"]," +
"\"to_delete\":[\"mods/old-mod.jar\"]," +
"\"to_update\":[\"mods/updated-mod.jar\"]" +
"}";
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
assertEquals(6, diff.getVersion());
assertEquals(1, diff.getToDownload().size());
assertEquals(1, diff.getToDelete().size());
assertEquals(1, diff.getToUpdate().size());
PackDownloader.FileInfo fileInfo = diff.getToDownload().get(0);
assertEquals("mods/new-mod.jar", fileInfo.getPath());
assertEquals("/pack/test/file/mods/new-mod.jar", fileInfo.getUrl());
assertEquals(512000, fileInfo.getSize());
assertEquals("aaa111", fileInfo.getHash());
}
@Test
void parseDiffResponse_emptyArrays() {
String body = "{" +
"\"version\":1," +
"\"to_download\":[]," +
"\"to_delete\":[]," +
"\"to_update\":[]" +
"}";
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
assertTrue(diff.getToDownload().isEmpty());
assertTrue(diff.getToDelete().isEmpty());
assertTrue(diff.getToUpdate().isEmpty());
}
@Test
void parseDiffResponse_nullArrays_returnsEmpty() {
String body = "{\"version\":1}";
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
assertNotNull(diff.getToDownload());
assertNotNull(diff.getToDelete());
assertNotNull(diff.getToUpdate());
assertTrue(diff.getToDownload().isEmpty());
assertTrue(diff.getToDelete().isEmpty());
}
// ===== ServerPack toString =====
@Test
void serverPack_toString_withDate() {
java.time.LocalDateTime date = java.time.LocalDateTime.of(2024, 3, 15, 12, 0);
ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", date, 25);
String str = pack.toString();
assertTrue(str.contains("my-pack"));
assertTrue(str.contains("1.20.4"));
assertTrue(str.contains("fabric"));
assertTrue(str.contains("25 файлов"));
assertTrue(str.contains("15.03.2024"));
}
@Test
void serverPack_toString_withoutDate() {
ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", null, 25);
String str = pack.toString();
assertTrue(str.contains("my-pack"));
assertTrue(str.contains("25 файлов"));
assertFalse(str.contains("обновлен"));
}
// ===== Helper: replicates PackDownloader.parsePacksResponse() =====
private static List<ServerPack> parsePacksResponse(String responseBody) {
JsonObject root = com.google.gson.JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray packsArray = root.getAsJsonArray("packs");
List<ServerPack> result = new ArrayList<>();
for (var elem : packsArray) {
JsonObject pack = elem.getAsJsonObject();
if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
continue;
}
try {
String name = pack.get("name").getAsString();
int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull()
? pack.get("loader_version").getAsString() : "";
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
java.time.LocalDateTime updatedAt = null;
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
try {
updatedAt = java.time.LocalDateTime.parse(pack.get("updated_at").getAsString(),
java.time.format.DateTimeFormatter.ISO_DATE_TIME);
} catch (Exception ignored) {}
}
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
loaderVersion, updatedAt, filesCount));
} catch (Exception e) {
System.err.println("Ошибка парсинга пака: " + e.getMessage());
}
}
return result;
}
}
+122 -109
View File
@@ -6,56 +6,110 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId> <groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId> <artifactId>ZernMCLauncher</artifactId>
<version>1.0.7</version> <version>1.0.9</version>
<packaging>jar</packaging> <packaging>pom</packaging>
<name>ZernMC Launcher Parent</name>
<description>ZernMC Launcher - Multi-module project</description>
<modules>
<module>bootstrap</module>
<module>launcher</module>
</modules>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass> <project.organization.name>ZernMC</project.organization.name>
<project.inceptionYear>2026</project.inceptionYear>
<project.description>ZernMC Launcher - Multi-module project</project.description>
</properties> </properties>
<dependencies> <dependencyManagement>
<dependency> <dependencies>
<groupId>org.apache.httpcomponents</groupId> <dependency>
<artifactId>httpclient</artifactId> <groupId>org.apache.httpcomponents</groupId>
<version>4.5.14</version> <artifactId>httpclient</artifactId>
</dependency> <version>4.5.14</version>
<dependency> </dependency>
<groupId>com.fasterxml.jackson.core</groupId> <dependency>
<artifactId>jackson-databind</artifactId> <groupId>com.fasterxml.jackson.core</groupId>
<version>2.15.2</version> <artifactId>jackson-databind</artifactId>
</dependency> <version>2.15.2</version>
<dependency> </dependency>
<groupId>com.google.code.gson</groupId> <dependency>
<artifactId>gson</artifactId> <groupId>com.google.code.gson</groupId>
<version>2.10.1</version> <artifactId>gson</artifactId>
</dependency> <version>2.10.1</version>
<dependency> </dependency>
<groupId>org.json</groupId> <dependency>
<artifactId>json</artifactId> <groupId>org.json</groupId>
<version>20231013</version> <artifactId>json</artifactId>
</dependency> <version>20231013</version>
<dependency> </dependency>
<groupId>org.fusesource.jansi</groupId> <dependency>
<artifactId>jansi</artifactId> <groupId>org.fusesource.jansi</groupId>
<version>2.4.1</version> <artifactId>jansi</artifactId>
</dependency> <version>2.4.1</version>
<dependency> </dependency>
<groupId>me.tongfei</groupId> <dependency>
<artifactId>progressbar</artifactId> <groupId>org.jline</groupId>
<version>0.9.5</version> <artifactId>jline</artifactId>
</dependency> <version>3.24.1</version>
<dependency> </dependency>
<groupId>commons-io</groupId> <dependency>
<artifactId>commons-io</artifactId> <groupId>me.tongfei</groupId>
<version>2.15.1</version> <artifactId>progressbar</artifactId>
</dependency> <version>0.9.5</version>
</dependencies> </dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
<!-- JavaFX for Windows -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
</dependencies>
</dependencyManagement>
<build> <build>
<plugins> <plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
<!-- Shade Plugin --> <!-- Shade Plugin -->
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
@@ -68,7 +122,7 @@
<goal>shade</goal> <goal>shade</goal>
</goals> </goals>
<configuration> <configuration>
<outputFile>../server/builds/ZernMCLauncher.jar</outputFile> <outputFile>../../server/builds/ZernMCLauncher.jar</outputFile>
<transformers> <transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass> <mainClass>${mainClass}</mainClass>
@@ -76,81 +130,40 @@
<Implementation-Version>${project.version}</Implementation-Version> <Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title> <Implementation-Title>ZernMC Launcher</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor> <Implementation-Vendor>SashegDev</Implementation-Vendor>
<Implementation-Description>Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном)</Implementation-Description> <Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL> <Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
</manifestEntries> </manifestEntries>
</transformer> </transformer>
</transformers> </transformers>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>
</plugin>
<!-- Launch4j для создания .exe -->
<plugin>
<groupId>com.akathist.maven.plugins.launch4j</groupId>
<artifactId>launch4j-maven-plugin</artifactId>
<version>2.5.0</version>
<executions>
<execution>
<id>l4j</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../server/builds/ZernMCLauncher.exe</outfile>
<jar>../server/builds/ZernMCLauncher.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<jre>
<path>jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC Launcher</productName>
<companyName>ZernMC(SashegDev)</companyName>
<internalName>ZernMCLauncher</internalName>
<originalFilename>ZernMCLauncher.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
</executions>
</plugin>
<!-- Antrun: копирование JRE и создание build.version + zip -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>run</goal></goals>
<configuration>
<target>
<echo file="../server/builds/build.version">${project.version}</echo>
<!-- Копируем содержимое jre/jre21 в папку jre21 (без лишней вложенности) -->
<copy todir="../server/builds/jre21" overwrite="true">
<fileset dir="${user.home}/launcher/jre/jre21"/>
</copy>
<!-- Создаём zip только с .exe и jre21 (без .jar и build.version) -->
<zip destfile="../server/builds/ZernMCLauncher-${project.version}.zip"
basedir="../server/builds"
includes="ZernMCLauncher.exe,jre21/**"
excludes="*.jar,build.version"/>
</target>
</configuration>
</execution>
</executions>
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
<profiles>
<!-- ==================== GLOBAL ==================== -->
<profile>
<id>global</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<build.profile>global</build.profile>
<launcher.title>ZernMC Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url>
<!-- Можно добавить флаги для отключения некоторых фич -->
</properties>
</profile>
<!-- ==================== ZERNMC ==================== -->
<profile>
<id>zernmc</id>
<properties>
<build.profile>zernmc</build.profile>
<launcher.title>ZernMC Private Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
</profiles>
</project> </project>
@@ -1,182 +0,0 @@
package me.sashegdev.zernmc.launcher;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.menu.*;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.*;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
public class Main {
private static final String CURRENT_VERSION = Version.getCurrentVersion();
public static void main(String[] args) throws IOException {
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
System.setProperty("file.encoding", "UTF-8");
System.setProperty("sun.err.encoding", "UTF-8");
System.setProperty("sun.stdout.encoding", "UTF-8");
java.nio.charset.Charset.defaultCharset();
ZAnsi.install();
System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
//проверка всех сервисов при старте
ZHttpClient.checkAllServicesOnStartup();
checkAndAutoUpdateLauncher();
// === АВТОРИЗАЦИЯ ===
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
boolean sessionRestored = AuthManager.loadSavedSession();
if (!sessionRestored) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.out.println(ZAnsi.yellow("До свидания!"));
ZAnsi.uninstall();
System.exit(0);
}
} else {
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
}
// === КОНЕЦ АВТОРИЗАЦИИ ===
try {
mainLoop();
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage()));
e.printStackTrace();
} finally {
ZAnsi.uninstall();
}
}
private static void checkAndAutoUpdateLauncher() {
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
try {
String json = ZHttpClient.getLauncherVersionInfo();
String serverVersion = extractVersion(json);
System.out.println(ZAnsi.white("Текущая версия: ") + CURRENT_VERSION);
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion);
if (Version.isNewer(CURRENT_VERSION, serverVersion)) {
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
performAutoUpdate(serverVersion);
restartLauncher();
} else {
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
}
}
private static void performAutoUpdate(String newVersion) throws Exception {
String downloadUrl = ZHttpClient.getBaseUrl() + "/launcher/download?type=jar";
Path currentJar = getCurrentJarPath();
Path tempJar = currentJar.getParent().resolve("zernmc-launcher-new.jar");
System.out.println(ZAnsi.cyan("Скачивание версии " + newVersion + "..."));
HttpClient client = HttpClient.newBuilder().build();
HttpRequest request = HttpRequest.newBuilder()
.uri(java.net.URI.create(downloadUrl))
.GET()
.build();
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(tempJar));
if (response.statusCode() != 200) {
throw new IOException("Сервер вернул код: " + response.statusCode());
}
long size = Files.size(tempJar);
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
// Заменяем текущий jar
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
}
private static void restartLauncher() {
try {
String javaPath = System.getProperty("java.home") + "/bin/java";
String jarPath = getCurrentJarPath().toAbsolutePath().toString();
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
new ProcessBuilder(javaPath, "-jar", jarPath)
.inheritIO()
.start();
System.exit(0);
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Не удалось перезапустить лаунчер."));
System.exit(1);
}
}
private static String extractVersion(String json) {
try {
return json.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
} catch (Exception e) {
return "unknown";
}
}
private static Path getCurrentJarPath() {
try {
return Path.of(Main.class.getProtectionDomain()
.getCodeSource()
.getLocation()
.toURI());
} catch (Exception e) {
return Path.of("zernmc-launcher-1.0-jar-with-dependencies.jar");
}
}
private static void mainLoop() throws Exception {
while (true) {
List<String> options = List.of(
"Запустить игру",
"Проверка обновлений",
"Настройки",
"Проверка подключения к серверам Zern",
"Выход"
);
ArrowMenu menu = new ArrowMenu("Главное меню", options);
int choice = menu.show();
if (choice == -1 || choice == 4) {
System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.yellow("До свидания!"));
break;
}
switch (choice) {
case 0 -> new LaunchMenu().show();
case 1 -> new UpdateMenu().show();
case 2 -> new SettingsMenu().show();
case 3 -> new ServerCheckMenu().show();
}
}
}
}
@@ -1,272 +0,0 @@
package me.sashegdev.zernmc.launcher.auth;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.annotations.SerializedName;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class AuthManager {
private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json");
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static volatile AuthSession session = null;
public static boolean loadSavedSession() {
if (!Files.exists(AUTH_FILE)) return false;
try {
String json = Files.readString(AUTH_FILE);
AuthSession loaded = GSON.fromJson(json, AuthSession.class);
if (loaded == null || loaded.accessToken == null) return false;
session = loaded;
if (isAccessTokenExpired()) {
return tryRefresh();
}
return true;
} catch (Exception e) {
return false;
}
}
public static AuthResult login(String username, String password) {
return authRequest("/auth/login", username, password);
}
public static AuthResult register(String username, String password) {
return authRequest("/auth/register", username, password);
}
private static AuthResult authRequest(String endpoint, String username, String password) {
try {
String body = GSON.toJson(new LoginRequest(username, password));
//System.out.println(ZAnsi.cyan("[AUTH] Отправка запроса: " + endpoint));
SimpleHttpResponse resp = post(endpoint, body);
//System.out.println(ZAnsi.cyan("[AUTH] Ответ: HTTP " + resp.statusCode()));
if (resp.statusCode() == 200) {
session = GSON.fromJson(resp.body(), AuthSession.class);
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
saveSession();
return AuthResult.ok();
} else if (resp.statusCode() == 422) {
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
} else {
return AuthResult.fail(extractError(resp.body()));
}
} catch (Exception e) {
//System.err.println(ZAnsi.red("[AUTH] Исключение: " + e.getMessage()));
e.printStackTrace();
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
}
}
public static void logout() {
if (session != null && session.refreshToken != null) {
try {
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
} catch (Exception ignored) {}
}
session = null;
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
}
public static boolean isLoggedIn() {
return session != null && session.accessToken != null;
}
public static String getUsername() {
return session != null ? session.username : "Player";
}
public static String getUuid() {
return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000";
}
public static String getAccessToken() {
if (session == null) return "0";
if (isAccessTokenExpired()) {
tryRefresh();
}
return session != null && session.accessToken != null ? session.accessToken : "0";
}
private static boolean isAccessTokenExpired() {
if (session == null) return true;
return System.currentTimeMillis() / 1000L >= session.expiresAt - 300;
}
private static boolean tryRefresh() {
if (session == null || session.refreshToken == null) return false;
try {
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
SimpleHttpResponse resp = post("/auth/refresh", body);
if (resp.statusCode() == 200) {
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
session = newSession;
saveSession();
return true;
}
} catch (Exception ignored) {}
session = null;
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
return false;
}
private static void saveSession() {
try {
Files.createDirectories(AUTH_FILE.getParent());
Files.writeString(AUTH_FILE, GSON.toJson(session));
} catch (IOException e) {
System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage()));
}
}
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
java.net.HttpURLConnection conn = null;
try {
java.net.URL url = java.net.URI.create(fullUrl).toURL();
conn = (java.net.HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
// Добавляем токен авторизации, если есть сессия
if (session != null && session.accessToken != null) {
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
}
conn.setDoOutput(true);
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
try (java.io.OutputStream os = conn.getOutputStream()) {
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
os.write(input, 0, input.length);
}
int statusCode = conn.getResponseCode();
java.io.InputStream is = (statusCode >= 200 && statusCode < 300)
? conn.getInputStream()
: conn.getErrorStream();
String responseBody;
try (java.util.Scanner scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
}
return new SimpleHttpResponse(statusCode, responseBody);
} finally {
if (conn != null) conn.disconnect();
}
}
private static String extractError(String body) {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("detail")) {
if (json.get("detail").isJsonArray()) {
return json.getAsJsonArray("detail").get(0).getAsJsonObject()
.get("msg").getAsString();
}
return json.get("detail").getAsString();
}
if (json.has("error")) {
return json.get("error").getAsString();
}
} catch (Exception ignored) {}
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
}
public static boolean hasActivePass() {
if (!isLoggedIn()) return false;
try {
String response = ZHttpClient.get("/auth/pass/my");
return response.contains("\"is_active\":true");
} catch (Exception e) {
System.err.println("Не удалось проверить проходки: " + e.getMessage());
return false;
}
}
public static String activatePass(String passCode) {
try {
String json = "{\"pass_code\":\"" + passCode.toUpperCase() + "\"}";
SimpleHttpResponse resp = post("/auth/pass/activate", json);
System.out.println(ZAnsi.cyan("[AUTH] Активация проходки: HTTP " + resp.statusCode()));
if (resp.statusCode() == 200) {
return "Проходка успешно активирована!";
} else if (resp.statusCode() == 401) {
return "Ошибка: Требуется авторизация. Перезайдите в аккаунт.";
} else {
String error = extractError(resp.body());
return "Ошибка: " + error;
}
} catch (Exception e) {
e.printStackTrace();
return "Ошибка соединения: " + e.getMessage();
}
}
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
public static class AuthSession {
@SerializedName("access_token") public String accessToken;
@SerializedName("refresh_token") public String refreshToken;
@SerializedName("expires_in") public int expiresIn;
public transient long expiresAt;
public String username;
public String uuid;
}
private static class LoginRequest {
final String username;
final String password;
LoginRequest(String u, String p) { this.username = u; this.password = p; }
}
public static class AuthResult {
public final boolean success;
public final String error;
private AuthResult(boolean s, String e) { success = s; error = e; }
public static AuthResult ok() { return new AuthResult(true, null); }
public static AuthResult fail(String msg) { return new AuthResult(false, msg); }
}
}
// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ======================
class SimpleHttpResponse {
final int statusCode;
final String body;
SimpleHttpResponse(int statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
int statusCode() { return statusCode; }
String body() { return body; }
}
@@ -1,581 +0,0 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.Input;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class LaunchMenu {
public void show() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
List<Instance> instances = InstanceManager.getAllInstances();
List<String> options = instances.stream()
.map(Instance::toString)
.collect(Collectors.toList());
options.add("Установить новую сборку");
options.add("Назад в главное меню");
ArrowMenu menu = new ArrowMenu("Управление сборками", options);
int choice = menu.show();
if (choice == -1) break;
if (choice == options.size() - 1) break;
if (choice == instances.size()) {
installNewPack();
continue;
}
Instance selected = instances.get(choice);
manageInstance(selected);
}
}
private void installNewPack() throws Exception {
ConsoleUtils.clearScreen();
List<String> options = List.of(
"Установить сборку с сервера ZernMC",
"Установить Vanilla Minecraft",
"Создать сборку вручную (Fabric/Forge)",
"Назад"
);
ArrowMenu menu = new ArrowMenu("Установка новой сборки", options);
int choice = menu.show();
if (choice == -1 || choice == 3) return;
switch (choice) {
case 0 -> {
try {
installServerPack();
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage()));
e.printStackTrace();
ConsoleUtils.pause();
}
}
case 1 -> createVanillaInstance();
case 2 -> createCustomInstance();
}
}
private void installServerPack() throws Exception {
if (!AuthManager.hasActivePass()) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
System.out.println(ZAnsi.white("Чтобы скачивать сборки с сервера ZernMC, необходимо активировать проходку."));
System.out.println();
System.out.print(ZAnsi.white("Введите код проходки (ZERN-XXXXXXX) или Enter для отмены: "));
String code = Input.readLine();
if (code.isEmpty()) return;
String result = AuthManager.activatePass(code);
System.out.println(ZAnsi.cyan(result));
if (!result.contains("успешно")) {
ConsoleUtils.pause();
return;
}
// Повторная проверка
if (!AuthManager.hasActivePass()) {
System.out.println(ZAnsi.brightRed("Не удалось активировать проходку."));
ConsoleUtils.pause();
return;
}
}
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Получение списка доступных сборок с сервера..."));
PackDownloader tempDownloader = new PackDownloader(null);
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("Нет доступных сборок на сервере."));
ConsoleUtils.pause();
return;
}
// Исправлено: убраны спецсимволы для Windows
List<String> options = availablePacks.stream()
.map(p -> String.format("%s [%s + %s v%d] - %d файлов",
p.getName(),
p.getMinecraftVersion(),
p.getLoaderType(),
p.getVersion(),
p.getFilesCount()))
.collect(Collectors.toList());
options.add("Назад");
ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return;
ServerPack selected = availablePacks.get(choice);
// Запрашиваем имя для локальной сборки
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion());
System.out.println(ZAnsi.white(" Лоадер: ") + selected.getLoaderType() + " " + selected.getLoaderVersion());
System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion());
System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount());
System.out.println();
System.out.print(ZAnsi.white("Введите название локальной сборки (Enter = использовать имя пака): "));
String localName = Input.readLine();
if (localName.isEmpty()) {
localName = selected.getName();
}
// Проверяем, существует ли уже такая сборка
if (InstanceManager.getInstance(localName) != null) {
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause();
return;
}
// Создаем инстанс
InstanceManager.createInstanceFolder(localName);
Instance newInstance = InstanceManager.getInstance(localName);
// Устанавливаем сборку
PackDownloader packDownloader = new PackDownloader(newInstance);
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
} else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
}
ConsoleUtils.pause();
}
private void createVanillaInstance() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList());
versionOptions.add("Назад");
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
MinecraftVersion selectedMc = allVersions.get(versionChoice);
String mcVersion = selectedMc.getId();
String packName = askPackName();
if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause();
return;
}
InstanceManager.createInstanceFolder(packName);
Instance newInstance = InstanceManager.getInstance(packName);
MinecraftLib lib = new MinecraftLib(newInstance);
boolean success = lib.installMinecraft(mcVersion);
if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!"));
} else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку."));
}
ConsoleUtils.pause();
}
private void createCustomInstance() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList());
versionOptions.add("Назад");
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
MinecraftVersion selectedMc = allVersions.get(versionChoice);
String mcVersion = selectedMc.getId();
// === Выбор лоадера с правильной проверкой поддержки ===
List<String> loaderOptions = buildLoaderOptions(mcVersion);
ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions);
int loaderChoice = loaderMenu.show();
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
String selectedLoader = loaderOptions.get(loaderChoice);
if (selectedLoader.contains("Vanilla")) {
createVanillaInstance();
return;
}
String loaderType = selectedLoader.contains("Fabric") ? "fabric" : "forge";
String loaderVersion;
if (loaderType.equals("fabric")) {
loaderVersion = askFabricLoaderVersion();
} else {
loaderVersion = askForgeVersion(mcVersion);
}
if (loaderVersion == null) return;
String packName = askPackName();
if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause();
return;
}
InstanceManager.createInstanceFolder(packName);
Instance newInstance = InstanceManager.getInstance(packName);
MinecraftLib lib = new MinecraftLib(newInstance);
boolean success = loaderType.equals("fabric")
? lib.installFabric(mcVersion, loaderVersion)
: lib.installForge(mcVersion, loaderVersion);
if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!"));
} else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
}
ConsoleUtils.pause();
}
// ====================== Вспомогательные методы ======================
private List<String> buildLoaderOptions(String mcVersion) {
List<String> options = new ArrayList<>();
if (isFabricSupported(mcVersion)) {
options.add("Fabric");
}
if (isForgeSupported(mcVersion)) {
options.add("Forge");
}
options.add("Vanilla");
options.add("Назад");
return options;
}
private boolean isFabricSupported(String version) {
return version.matches("^1\\.(1[4-9]|[2-9]\\d).*");
}
private boolean isForgeSupported(String version) {
if (version.matches("^1\\.2[2-9].*") || version.matches("^\\d{2}.*")) {
return false;
}
return version.matches("^1\\.(1[2-9]|[2-9]\\d).*") ||
version.matches("^1\\.20.*") || version.matches("^1\\.21.*");
}
private void manageInstance(Instance instance) throws Exception {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName()));
System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion()));
System.out.println(ZAnsi.white("Лоадер: " + instance.getLoaderType() +
(instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : "")));
if (instance.isServerPack()) {
System.out.println(ZAnsi.green("Серверная сборка: v" + instance.getServerVersion()));
}
List<String> options = new ArrayList<>();
options.add("Запустить сборку");
if (instance.isServerPack()) {
options.add("Проверить обновления");
}
options.add("Изменить версию лоадера");
options.add("Удалить сборку");
options.add("Назад");
ArrowMenu menu = new ArrowMenu("Действия", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return;
switch (choice) {
case 0 -> launchExistingInstance(instance);
case 1 -> {
if (instance.isServerPack()) {
checkAndUpdateServerPack(instance);
} else {
changeLoaderVersion(instance);
}
}
case 2 -> {
if (instance.isServerPack()) {
changeLoaderVersion(instance);
} else {
deleteInstance(instance);
}
}
case 3 -> deleteInstance(instance);
}
}
}
private void checkAndUpdateServerPack(Instance instance) throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName()));
PackDownloader downloader = new PackDownloader(instance);
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
if (!hasUpdate) {
System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")"));
ConsoleUtils.pause();
return;
}
System.out.println(ZAnsi.brightYellow("Доступно обновление!"));
if (Input.confirm("Обновить сборку")) {
boolean success = downloader.updatePack(instance.getServerPackName());
if (success) {
System.out.println(ZAnsi.brightGreen("Сборка успешно обновлена!"));
} else {
System.out.println(ZAnsi.brightRed("Не удалось обновить сборку."));
}
} else {
System.out.println(ZAnsi.yellow("Обновление отменено."));
}
ConsoleUtils.pause();
}
private void changeLoaderVersion(Instance instance) throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Изменение версии лоадера для " + instance.getName()));
String currentLoader = instance.getLoaderType();
String mcVersion = instance.getMinecraftVersion();
if ("vanilla".equalsIgnoreCase(currentLoader)) {
System.out.println(ZAnsi.yellow("Это vanilla сборка. Нельзя изменить лоадер."));
ConsoleUtils.pause();
return;
}
String newLoaderVersion;
if ("fabric".equalsIgnoreCase(currentLoader)) {
newLoaderVersion = askFabricLoaderVersion();
} else {
newLoaderVersion = askForgeVersion(mcVersion);
}
if (newLoaderVersion == null) return;
System.out.println(ZAnsi.cyan("Переустановка лоадера " + currentLoader + " -> " + newLoaderVersion + "..."));
MinecraftLib lib = new MinecraftLib(instance);
boolean success;
try {
if ("fabric".equalsIgnoreCase(currentLoader)) {
success = lib.installFabric(mcVersion, newLoaderVersion);
} else {
success = lib.installForge(mcVersion, newLoaderVersion);
}
if (success) {
System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!"));
} else {
System.out.println(ZAnsi.brightRed("Не удалось изменить версию лоадера."));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage()));
}
ConsoleUtils.pause();
}
private void deleteInstance(Instance instance) throws IOException {
ConsoleUtils.clearScreen();
List<String> confirmOptions = List.of(
"Да, удалить сборку",
"Нет, отменить"
);
ArrowMenu confirmMenu = new ArrowMenu(
"Вы действительно хотите удалить сборку '" + instance.getName() + "'?",
confirmOptions
);
int choice = confirmMenu.show();
if (choice == 0) {
boolean deleted = InstanceManager.deleteInstance(instance.getName());
if (deleted) {
System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена."));
} else {
System.out.println(ZAnsi.brightRed("Не удалось удалить сборку."));
}
} else {
System.out.println(ZAnsi.yellow("Удаление отменено."));
}
ConsoleUtils.pause();
}
private String askFabricLoaderVersion() throws Exception {
System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader..."));
List<String> versions = ZHttpClient.getFabricLoaderVersions();
List<String> options = versions.stream()
.limit(30)
.map(v -> "Fabric Loader " + v)
.collect(Collectors.toList());
options.add("Назад");
ArrowMenu menu = new ArrowMenu("Выбор версии Fabric Loader", options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null;
return versions.get(choice);
}
private String askForgeVersion(String mcVersion) throws Exception {
System.out.println(ZAnsi.cyan("Получение списка версий Forge для " + mcVersion + "..."));
List<String> allForgeVersions = getAllForgeVersions();
List<String> compatibleVersions = allForgeVersions.stream()
.filter(v -> v.startsWith(mcVersion + "-"))
.map(v -> v.substring(mcVersion.length() + 1))
.collect(Collectors.toList());
if (compatibleVersions.isEmpty()) {
System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion));
ConsoleUtils.pause();
return null;
}
List<String> options = compatibleVersions.stream()
.limit(30)
.map(v -> "Forge " + v)
.collect(Collectors.toList());
options.add("Назад");
ArrowMenu menu = new ArrowMenu("Выбор версии Forge для " + mcVersion, options);
int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null;
return compatibleVersions.get(choice);
}
private String askPackName() {
System.out.print(ZAnsi.white("\nВведите название новой сборки: "));
String name = Input.readLine();
if (name.isEmpty()) {
System.out.println(ZAnsi.yellow("Отменено."));
return null;
}
return name;
}
private void launchExistingInstance(Instance instance) {
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
ConsoleUtils.pause();
return;
}
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
MinecraftLib lib = new MinecraftLib(instance);
LaunchOptions options = new LaunchOptions();
// Авторизация Minecraft
options.setUsername(AuthManager.getUsername());
options.setUuid(AuthManager.getUuid());
options.setAccessToken(AuthManager.getAccessToken());
try {
lib.launch(options);
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage()));
e.printStackTrace();
}
ConsoleUtils.pause();
}
private List<String> getAllForgeVersions() throws Exception {
String metadataUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml";
String xml = ZHttpClient.downloadString(metadataUrl);
List<String> versions = new ArrayList<>();
int index = 0;
while ((index = xml.indexOf("<version>", index)) != -1) {
int start = index + 9;
int end = xml.indexOf("</version>", start);
if (end == -1) break;
String version = xml.substring(start, end).trim();
versions.add(version);
index = end;
}
versions.sort((a, b) -> b.compareTo(a));
return versions;
}
}
@@ -1,167 +0,0 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.auth.AuthManager.AuthResult;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.Input;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.IOException;
import java.util.List;
/**
* Экран входа/регистрации.
* Показывается при старте лаунчера, если нет сохранённой сессии.
*
* show() возвращает true — пользователь вошёл/зарегистрировался
* false — пользователь выбрал выход из лаунчера
*/
public class LoginMenu {
/**
* Главный экран выбора действия.
*/
public boolean show() throws IOException {
while (true) {
ConsoleUtils.clearScreen();
printBanner();
List<String> options = List.of(
"Войти в аккаунт",
"Создать аккаунт",
"Выйти из лаунчера"
);
ArrowMenu menu = new ArrowMenu("Добро пожаловать в ZernMC!", options);
int choice = menu.show();
if (choice == -1 || choice == 2) return false;
boolean success = switch (choice) {
case 0 -> doLogin();
case 1 -> doRegister();
default -> false;
};
if (success) return true;
// Если не успех — покажем меню снова (ошибка уже напечатана внутри методов)
}
}
/**
* Показывается когда пользователь уже вошёл — предлагает выйти из аккаунта.
*/
public void showAccountMenu() throws IOException {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== Аккаунт ==="));
System.out.println();
System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid()));
System.out.println();
List<String> options = List.of(
"Выйти из аккаунта",
"Назад"
);
ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options);
int choice = menu.show();
if (choice == 0) {
AuthManager.logout();
System.out.println(ZAnsi.yellow("Вы вышли из аккаунта."));
ConsoleUtils.pause();
}
}
// ====================== ПРИВАТНЫЕ МЕТОДЫ ======================
private boolean doLogin() throws IOException {
ConsoleUtils.clearScreen();
printBanner();
System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]"));
System.out.println();
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
if (username.isEmpty()) return false;
String password = readPassword(" Пароль: ");
if (password.isEmpty()) return false;
System.out.println();
System.out.print(ZAnsi.cyan(" Выполняем вход..."));
AuthResult result = AuthManager.login(username, password);
if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause();
return true;
} else {
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
ConsoleUtils.pause();
return false;
}
}
private boolean doRegister() throws IOException {
ConsoleUtils.clearScreen();
printBanner();
System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]"));
System.out.println();
System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _"));
System.out.println(ZAnsi.yellow(" Длина имени: 3-16 символов | Длина пароля: от 6 символов"));
System.out.println();
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
if (username.isEmpty()) return false;
String password = readPassword(" Пароль: ");
if (password.isEmpty()) return false;
String confirm = readPassword(" Повторите пароль: ");
if (!password.equals(confirm)) {
System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!"));
ConsoleUtils.pause();
return false;
}
System.out.println();
System.out.print(ZAnsi.cyan(" Создаём аккаунт..."));
AuthResult result = AuthManager.register(username, password);
if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause();
return true;
} else {
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
ConsoleUtils.pause();
return false;
}
}
/**
* Читаем пароль — стараемся скрыть вывод через Console,
* если недоступно (IDE/терминал без TTY) — читаем обычным способом.
*/
private String readPassword(String prompt) {
java.io.Console console = System.console();
if (console != null) {
char[] chars = console.readPassword(prompt);
return chars != null ? new String(chars) : "";
}
// Fallback: в IDE пароль будет виден
return Input.readLine(prompt);
}
private void printBanner() {
System.out.println(ZAnsi.header("╔══════════════════════════════╗"));
System.out.println(ZAnsi.header("║ ZernMC Launcher ║"));
System.out.println(ZAnsi.header("╚══════════════════════════════╝"));
System.out.println();
}
}
@@ -1,103 +0,0 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
public class ServerCheckMenu {
public void show() throws IOException {
List<String> options = List.of(
"Проверить подключение к ZernMC серверу",
"Проверить доступ к Mojang (Minecraft)",
"Проверить доступ к Fabric Meta",
"Назад в главное меню"
);
ArrowMenu menu = new ArrowMenu("Диагностика подключения", options);
int choice = menu.show();
if (choice == -1 || choice == 4) return;
ConsoleUtils.clearScreen();
switch (choice) {
case 0 -> checkZernServer();
case 1 -> checkMojang();
case 2 -> checkFabric();
}
ConsoleUtils.pause();
}
private void checkZernServer() {
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
try {
String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("Сервер успешно подключён!"));
System.out.println("Ответ: " + response);
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Не удалось подключиться к ZernMC серверу"));
System.out.println("Ошибка: " + e.getMessage());
}
}
private void checkMojang() {
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("Mojang доступен"));
} else {
System.out.println(ZAnsi.brightRed("Mojang вернул код " + response.statusCode()));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Нет доступа к Mojang"));
System.out.println("Ошибка: " + e.getMessage());
}
}
private void checkFabric() {
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://meta.fabricmc.net/v2/versions"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("Fabric Meta доступен"));
} else {
System.out.println(ZAnsi.brightRed("Fabric Meta вернул код " + response.statusCode()));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("Нет доступа к Fabric Meta"));
System.out.println("Ошибка: " + e.getMessage());
}
}
}
@@ -1,68 +0,0 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.Input;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.IOException;
import java.util.List;
public class SettingsMenu {
public void show() throws IOException {
List<String> options = List.of(
"Настроить путь к Java",
"Настроить выделенную память (RAM)",
"Дополнительные JVM параметры",
"Назад в главное меню"
);
ArrowMenu menu = new ArrowMenu("Настройки лаунчера", options);
int choice = menu.show();
if (choice == -1 || choice == 3) return;
ConsoleUtils.clearScreen();
switch (choice) {
case 0 -> configureJava();
case 1 -> configureRam();
case 2 -> configureJvmArgs();
}
ConsoleUtils.pause();
}
private void configureJava() {
System.out.println(ZAnsi.cyan("Путь к Java:"));
System.out.println(" " + Config.getJreDir().toAbsolutePath());
System.out.println(ZAnsi.white("\nJava будет искаться автоматически в папке ~/.zernmc/jre/"));
System.out.println("Если нужно — положите туда свою версию Java.");
}
private void configureRam() {
System.out.println(ZAnsi.cyan("Настройка выделенной памяти"));
System.out.println(Config.getRamInfo());
int newRam = Input.readInt(
ZAnsi.white("\nВведите новое значение RAM в MB (или 0 для отмены): "),
0, 32768
);
if (newRam == 0) {
System.out.println(ZAnsi.yellow("Настройка отменена."));
return;
}
Config.setMaxMemory(newRam);
System.out.println(ZAnsi.brightGreen("Выделенная память изменена на " + newRam + " MB"));
}
private void configureJvmArgs() {
System.out.println(ZAnsi.yellow("Дополнительные JVM параметры"));
System.out.println("Пока в разработке.");
System.out.println("В будущем здесь будет список предустановленных оптимизаций.");
}
}
@@ -1,373 +0,0 @@
package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public class LaunchCommandBuilder {
private final Instance instance;
public LaunchCommandBuilder(Instance instance) {
this.instance = instance;
}
public List<String> build(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "..."));
List<String> command = new ArrayList<>();
// 1. Путь к Java
String javaPath = getJavaPath();
command.add(javaPath);
// 2. JVM аргументы
command.addAll(getJvmArguments(options));
// 3. Natives
Path nativesDir = instance.getPath().resolve("natives");
if (!Files.exists(nativesDir)) {
Files.createDirectories(nativesDir);
}
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
String loaderType = instance.getLoaderType().toLowerCase();
if ("forge".equals(loaderType)) {
command.addAll(getForgeJvmArguments());
command.add("-cp");
command.add(buildForgeClasspath());
command.add("cpw.mods.modlauncher.Launcher");
command.addAll(getForgeArguments(options));
} else {
command.add("-cp");
command.add(buildClasspath());
command.add(getMainClass());
command.addAll(getMinecraftArguments(options));
}
return command;
}
private String getJavaPath() {
return "java";
}
private List<String> getJvmArguments(LaunchOptions options) {
List<String> jvmArgs = new ArrayList<>();
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
jvmArgs.add("-Xmx" + ramMB + "M");
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
jvmArgs.add("-XX:+UseG1GC");
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
jvmArgs.add("-XX:G1NewSizePercent=20");
jvmArgs.add("-XX:G1ReservePercent=20");
jvmArgs.add("-XX:MaxGCPauseMillis=50");
jvmArgs.add("-XX:G1HeapRegionSize=32M");
String loaderType = instance.getLoaderType().toLowerCase();
if ("fabric".equals(loaderType)) {
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
}
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
jvmArgs.addAll(options.getExtraJvmArgs());
}
return jvmArgs;
}
private List<String> getForgeJvmArguments() {
List<String> jvmArgs = new ArrayList<>();
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
jvmArgs.add("-Dforge.logging.console.level=debug");
jvmArgs.add("-Dforge.logging.mojang.level=info");
jvmArgs.add("-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-");
jvmArgs.add("-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar");
return jvmArgs;
}
private String buildClasspath() throws Exception {
List<String> paths = new ArrayList<>();
String versionId = getVersionId();
Path versionJar = instance.getPath()
.resolve("versions")
.resolve(versionId)
.resolve(versionId + ".jar");
if (Files.exists(versionJar)) {
paths.add(versionJar.toAbsolutePath().toString());
} else {
String mcVersion = instance.getMinecraftVersion();
Path fallbackJar = instance.getPath()
.resolve("versions")
.resolve(mcVersion)
.resolve(mcVersion + ".jar");
if (Files.exists(fallbackJar)) {
paths.add(fallbackJar.toAbsolutePath().toString());
}
}
Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) {
try (var stream = Files.walk(librariesDir)) {
stream.filter(p -> p.toString().endsWith(".jar"))
.map(p -> p.toAbsolutePath().toString())
.forEach(paths::add);
}
}
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
return String.join(separator, paths);
}
private String buildForgeClasspath() throws Exception {
List<String> paths = new ArrayList<>();
String versionId = getVersionId();
String mcVersion = instance.getMinecraftVersion();
String forgeVersion = instance.getLoaderVersion();
Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) {
try (var stream = Files.walk(librariesDir)) {
stream.filter(p -> p.toString().endsWith(".jar"))
.map(p -> p.toAbsolutePath().toString())
.forEach(paths::add);
}
}
Path versionJar = instance.getPath()
.resolve("versions")
.resolve(versionId)
.resolve(versionId + ".jar");
if (Files.exists(versionJar)) {
paths.add(0, versionJar.toAbsolutePath().toString());
} else {
Path vanillaJar = instance.getPath()
.resolve("versions")
.resolve(mcVersion)
.resolve(mcVersion + ".jar");
if (Files.exists(vanillaJar)) {
paths.add(0, vanillaJar.toAbsolutePath().toString());
}
}
Path forgeUniversal = instance.getPath()
.resolve("libraries")
.resolve("net")
.resolve("minecraftforge")
.resolve("forge")
.resolve(mcVersion + "-" + forgeVersion)
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-universal.jar");
if (Files.exists(forgeUniversal)) {
paths.add(forgeUniversal.toAbsolutePath().toString());
}
Path forgeClient = instance.getPath()
.resolve("libraries")
.resolve("net")
.resolve("minecraftforge")
.resolve("forge")
.resolve(mcVersion + "-" + forgeVersion)
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-client.jar");
if (Files.exists(forgeClient)) {
paths.add(forgeClient.toAbsolutePath().toString());
}
String[] forgeModules = {"fmlcore", "javafmllanguage", "lowcodelanguage", "mclanguage"};
for (String module : forgeModules) {
Path modulePath = instance.getPath()
.resolve("libraries")
.resolve("net")
.resolve("minecraftforge")
.resolve(module)
.resolve(mcVersion + "-" + forgeVersion)
.resolve(module + "-" + mcVersion + "-" + forgeVersion + ".jar");
if (Files.exists(modulePath)) {
paths.add(modulePath.toAbsolutePath().toString());
}
}
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
return String.join(separator, paths);
}
private String getMainClass() {
String loaderType = instance.getLoaderType().toLowerCase();
if ("fabric".equals(loaderType)) {
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
}
else if ("forge".equals(loaderType)) {
return "cpw.mods.modlauncher.Launcher";
}
else {
return "net.minecraft.client.main.Main";
}
}
/**
* ИСПРАВЛЕНО: используем instance.getAssetIndex() вместо minecraftVersion
*/
private List<String> getMinecraftArguments(LaunchOptions options) {
List<String> args = new ArrayList<>();
args.add("--version");
args.add(instance.getName());
args.add("--gameDir");
args.add(instance.getPath().toAbsolutePath().toString());
args.add("--assetsDir");
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
// FIXED: Используем правильный assetIndex
args.add("--assetIndex");
String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion();
System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex));
} else {
System.out.println(ZAnsi.green("Использую asset index: " + assetIndex));
}
args.add(assetIndex);
args.add("--username");
args.add(options.getUsername() != null ? options.getUsername() : "Player");
args.add("--accessToken");
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
args.add("--uuid");
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
args.add("--userType");
args.add("legacy");
if (options.getWidth() > 0) {
args.add("--width");
args.add(String.valueOf(options.getWidth()));
}
if (options.getHeight() > 0) {
args.add("--height");
args.add(String.valueOf(options.getHeight()));
}
return args;
}
/**
* ИСПРАВЛЕНО: для Forge тоже используем правильный assetIndex
*/
private List<String> getForgeArguments(LaunchOptions options) {
List<String> args = new ArrayList<>();
args.add("--launchTarget");
args.add("forgeclient");
args.add("--fml.forgeVersion");
args.add(instance.getLoaderVersion());
args.add("--fml.mcVersion");
args.add(instance.getMinecraftVersion());
args.add("--fml.forgeGroup");
args.add("net.minecraftforge");
args.add("--gameDir");
args.add(instance.getPath().toAbsolutePath().toString());
args.add("--assetsDir");
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
// FIXED: Используем правильный assetIndex для Forge
args.add("--assetIndex");
String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion();
}
args.add(assetIndex);
args.add("--username");
args.add(options.getUsername() != null ? options.getUsername() : "Player");
args.add("--accessToken");
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
args.add("--uuid");
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
args.add("--userType");
args.add("legacy");
if (options.getWidth() > 0) {
args.add("--width");
args.add(String.valueOf(options.getWidth()));
}
if (options.getHeight() > 0) {
args.add("--height");
args.add(String.valueOf(options.getHeight()));
}
return args;
}
/**
* ИСПРАВЛЕНО: для Fabric используем сохраненный fabricVersionId
*/
private String getVersionId() {
String loaderType = instance.getLoaderType().toLowerCase();
String mcVersion = instance.getMinecraftVersion();
String loaderVer = instance.getLoaderVersion();
if ("vanilla".equals(loaderType)) {
return mcVersion;
}
else if ("fabric".equals(loaderType)) {
// Используем сохраненный fabricVersionId если есть
String fabricId = instance.getFabricVersionId();
if (fabricId != null && !fabricId.isEmpty()) {
return fabricId;
}
// fallback
return "fabric-loader-" + loaderVer + "-" + mcVersion;
}
else if ("forge".equals(loaderType)) {
return mcVersion + "-forge-" + loaderVer;
}
return mcVersion;
}
}
@@ -1,90 +0,0 @@
package me.sashegdev.zernmc.launcher.ui;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
import org.jline.utils.InfoCmp;
import java.io.IOException;
import java.util.List;
public class ArrowMenu {
private final String title;
private final List<String> options;
private int selected = 0;
private final Terminal terminal;
private static final int VISIBLE_ITEMS = 7; // сколько строк показывать в списке
public ArrowMenu(String title, List<String> options) throws IOException {
this.title = title;
this.options = options;
this.terminal = TerminalBuilder.builder()
.system(true)
.jna(true)
.build();
}
public int show() throws IOException {
terminal.enterRawMode();
terminal.puts(InfoCmp.Capability.clear_screen);
terminal.puts(InfoCmp.Capability.cursor_invisible);
try {
while (true) {
printPagedMenu();
int key = terminal.reader().read();
if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц') { // Up
selected = (selected - 1 + options.size()) % options.size();
}
else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы') { // Down
selected = (selected + 1) % options.size();
}
else if (key == 13 || key == 10) { // Enter
return selected;
}
else if (key == 27) { // Esc
return -1;
}
}
} finally {
terminal.puts(InfoCmp.Capability.cursor_visible);
terminal.close();
}
}
private void printPagedMenu() {
StringBuilder sb = new StringBuilder();
sb.append("\033[H\033[2J");
// Заголовок (фиксированный)
sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n");
sb.append(ZAnsi.yellow(title)).append("\n\n");
// Вычисляем диапазон отображаемых элементов
int start = Math.max(0, selected - (VISIBLE_ITEMS / 2));
int end = Math.min(options.size(), start + VISIBLE_ITEMS);
// Если в конце списка — подтягиваем вверх
if (end - start < VISIBLE_ITEMS && start > 0) {
start = Math.max(0, end - VISIBLE_ITEMS);
}
for (int i = start; i < end; i++) {
String line = options.get(i);
if (i == selected) {
sb.append(ZAnsi.selected(line)).append("\n");
} else {
sb.append(ZAnsi.white(" " + line)).append("\n");
}
}
// Подсказка внизу (фиксированная)
sb.append("\n")
.append(ZAnsi.white("W/S (Ц/Ы) - перемещение | Enter - выбрать | Esc - назад"));
System.out.print(sb);
}
}
@@ -1,127 +0,0 @@
package me.sashegdev.zernmc.launcher.utils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
public class Config {
private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc");
private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties");
private static final Properties props = new Properties();
// Настройки
private static int maxMemory = 4096; // будет перезаписано умной логикой
private static String serverUrl = "http://87.120.187.36:1582";
private static String lastUsername = "Player";
static {
load();
applySmartRamRecommendation();
}
private static void load() {
try {
Files.createDirectories(CONFIG_DIR);
if (Files.exists(CONFIG_FILE)) {
try (var is = Files.newInputStream(CONFIG_FILE)) {
props.load(is);
}
}
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
serverUrl = props.getProperty("serverUrl", serverUrl);
lastUsername = props.getProperty("lastUsername", lastUsername);
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Не удалось загрузить конфиг: ") + e.getMessage());
}
}
public static void save() {
try {
props.setProperty("maxMemory", String.valueOf(maxMemory));
props.setProperty("serverUrl", serverUrl);
props.setProperty("lastUsername", lastUsername);
try (var os = Files.newOutputStream(CONFIG_FILE)) {
props.store(os, "ZernMC Launcher Configuration");
}
} catch (IOException e) {
System.err.println(ZAnsi.brightRed("Не удалось сохранить конфиг: ") + e.getMessage());
}
}
/**
* Умная рекомендация RAM:
* - минимум 1.5 GB
* - рекомендуется totalRAM - 30%
* - максимум 70% от доступной RAM
*/
private static void applySmartRamRecommendation() {
long totalRamMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); // в MB
// Рекомендуемое значение = total - 30%
long recommended = (long) (totalRamMB * 0.70); // 70% от доступной
// Ограничения
recommended = Math.max(1536, recommended); // минимум 1.5 GB
recommended = Math.min(recommended, totalRamMB - 1024); // оставляем минимум 1 GB системе
// Если текущее значение сильно отличается от рекомендуемого — корректируем
if (Math.abs(maxMemory - recommended) > 1024) { // разница больше 1 GB
maxMemory = (int) recommended;
save(); // сохраняем умную рекомендацию
System.out.println(ZAnsi.cyan("Автоматически рекомендовано RAM: " + maxMemory + " MB"));
}
}
// Getters & Setters
public static int getMaxMemory() {
return maxMemory;
}
public static void setMaxMemory(int memory) {
// Защита от слишком маленьких/больших значений
if (memory < 1024) memory = 1536;
if (memory > 32768) memory = 32768;
maxMemory = memory;
save();
}
public static String getServerUrl() {
return serverUrl;
}
public static String getLastUsername() {
return lastUsername;
}
public static void setLastUsername(String username) {
lastUsername = username;
save();
}
public static Path getInstancesDir() {
return CONFIG_DIR.resolve("instances");
}
public static Path getJreDir() {
return CONFIG_DIR.resolve("jre");
}
public static Path getConfigDir() {
return CONFIG_DIR;
}
/**
* Полезная информация для пользователя
*/
public static String getRamInfo() {
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
return "Доступно RAM: " + totalMB + " MB | Рекомендуется: " + maxMemory + " MB";
}
}
@@ -1,80 +0,0 @@
package me.sashegdev.zernmc.launcher.utils;
import org.fusesource.jansi.Ansi;
import org.fusesource.jansi.AnsiConsole;
public class ZAnsi {
//поддержка ANSI епта
public static void install() {
AnsiConsole.systemInstall();
}
public static void uninstall() {
AnsiConsole.systemUninstall();
}
// === Основные цвета ===
public static String green(String text) {
return Ansi.ansi().fg(Ansi.Color.GREEN).a(text).reset().toString();
}
public static String brightGreen(String text) {
return Ansi.ansi().fgBright(Ansi.Color.GREEN).a(text).reset().toString();
}
public static String cyan(String text) {
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
}
public static String yellow(String text) {
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
}
public static String brightYellow(String text) {
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).a(text).reset().toString();
}
public static String red(String text) {
return Ansi.ansi().fg(Ansi.Color.RED).a(text).reset().toString();
}
public static String brightRed(String text) {
return Ansi.ansi().fgBright(Ansi.Color.RED).a(text).reset().toString();
}
public static String blue(String text) {
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
}
public static String white(String text) {
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
}
public static String brightWhite(String text) {
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
}
// Стили
public static String bold(String text) {
return Ansi.ansi().bold().a(text).reset().toString();
}
public static String reset() {
return Ansi.ansi().reset().toString();
}
// Комбинированные удобные методы
public static String header(String text) {
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
}
public static String selected(String text) {
return Ansi.ansi()
.bgBright(Ansi.Color.WHITE)
.fgBlack()
.a(" > " + text + " ")
.reset()
.toString();
}
}
+582
View File
@@ -0,0 +1,582 @@
# admin_router.py
from fastapi import APIRouter, HTTPException, Depends, Request, status
from pydantic import BaseModel, Field
from typing import Optional, List
import structlog
import time
import secrets
from datetime import datetime
from auth import get_db, require_role, log_audit, get_current_user
from roles import (
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"])
# ====================== МОДЕЛИ ======================
class UpdateRoleRequest(BaseModel):
user_id: int
role: int = Field(..., ge=0, le=4)
class PassRequest(BaseModel):
username: str
reason: Optional[str] = None
class PassDecision(BaseModel):
request_id: int
approved: bool
reason: Optional[str] = None
class CreatePassDirectRequest(BaseModel):
username: str
expires_days: Optional[int] = Field(None, ge=1, le=365)
max_uses: int = Field(1, ge=1, le=10)
class BanUserRequest(BaseModel):
user_id: int
days: int = Field(..., ge=1, le=365)
reason: str
# ====================== ЭНДПОИНТЫ ======================
@router.get("/users")
async def list_users(
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
search: Optional[str] = None
):
"""Список пользователей (модераторы видят всех, но без sensitive данных)"""
with get_db() as conn:
query = "SELECT id, username, uuid, role, created_at, last_login, is_active"
params = []
if current_user["role"] < ROLE_ELDER:
# Модераторы не видят забаненных
query += " FROM users WHERE is_active = 1"
else:
query += " FROM users"
if search:
query += " AND username LIKE ?"
params.append(f"%{search}%")
query += " ORDER BY role DESC, username"
rows = conn.execute(query, params).fetchall()
users = []
for row in rows:
user_data = {
"id": row["id"],
"username": row["username"],
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
"created_at": row["created_at"],
"last_login": row["last_login"],
}
# Elder и Creator видят больше информации
if current_user["role"] >= ROLE_ELDER:
user_data["is_active"] = row["is_active"]
# Получаем информацию о проходке
pass_info = conn.execute("""
SELECT p.code, p.expires_at, up.activated_at
FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
LIMIT 1
""", (row["id"], time.time())).fetchone()
if pass_info:
user_data["has_pass"] = True
user_data["pass_expires"] = pass_info["expires_at"]
users.append(user_data)
return {"users": users, "total": len(users)}
@router.get("/users/{user_id}")
async def get_user_detail(
user_id: int,
current_user: dict = Depends(require_role(ROLE_MODERATOR))
):
"""Детальная информация о пользователе"""
with get_db() as conn:
row = conn.execute("""
SELECT id, username, uuid, role, created_at, last_login, is_active, banned_until
FROM users WHERE id = ?
""", (user_id,)).fetchone()
if not row:
raise HTTPException(404, "Пользователь не найден")
# Получаем активную проходку
pass_info = None
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
pass_row = conn.execute("""
SELECT p.code, p.expires_at, up.activated_at
FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
LIMIT 1
""", (user_id, time.time())).fetchone()
if pass_row:
pass_info = {
"code": pass_row["code"][:8] + "..." if current_user["role"] < ROLE_ELDER else pass_row["code"],
"expires_at": pass_row["expires_at"],
"activated_at": pass_row["activated_at"]
}
# Логи действий (только для Elder+)
actions = []
if current_user["role"] >= ROLE_ELDER:
action_rows = conn.execute("""
SELECT action, details, timestamp FROM audit_log
WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20
""", (user_id,)).fetchall()
actions = [dict(row) for row in action_rows]
return {
"id": row["id"],
"username": row["username"],
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
"created_at": row["created_at"],
"last_login": row["last_login"],
"is_active": row["is_active"],
"banned_until": row["banned_until"],
"has_pass": pass_info is not None,
"pass_info": pass_info,
"recent_actions": actions if current_user["role"] >= ROLE_ELDER else None
}
@router.put("/users/{user_id}/role")
async def update_user_role(
user_id: int,
body: UpdateRoleRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Изменение роли пользователя"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
raise HTTPException(404, "Пользователь не найден")
# Проверки прав
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Нельзя изменить роль создателя")
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Нельзя изменять роль пользователя с равным или высшим уровнем")
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, f"Нельзя назначить роль выше своей ({ROLE_NAMES[current_user['role']]})")
# Elder не может создавать других Elder (только Creator)
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Только создатель может назначать Elder Moderator")
# Проверяем, нужно ли выдать/отозвать проходку
old_role = target["role"]
new_role = body.role
conn.execute(
"UPDATE users SET role = ? WHERE id = ?",
(new_role, user_id)
)
# Управление проходками при изменении роли
now = time.time()
if new_role >= ROLE_PASS_HOLDER and old_role < ROLE_PASS_HOLDER:
# Выдаем проходку если её нет
existing = conn.execute("""
SELECT 1 FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (user_id, now)).fetchone()
if not existing:
# Создаем автоматическую проходку
pass_code = f"AUTO_{secrets.token_hex(8).upper()}"
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
VALUES (?, ?, NULL, 1, 1)
""", (pass_code, target["username"]))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
VALUES (?, ?, ?)
""", (user_id, pass_code, now))
logger.info("Auto-pass issued", user=target["username"], role=new_role)
elif new_role < ROLE_PASS_HOLDER and old_role >= ROLE_PASS_HOLDER:
# Отзываем проходку
conn.execute("""
UPDATE passes SET is_active = 0
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
""", (user_id,))
logger.info("Auto-pass revoked", user=target["username"])
conn.commit()
log_audit(
current_user["id"],
"role_change",
f"Changed role of {target['username']} from {old_role} to {new_role}",
ip
)
logger.info("Role updated", admin=current_user["username"], target=target["username"], new_role=new_role)
return {
"success": True,
"user_id": user_id,
"username": target["username"],
"old_role": old_role,
"old_role_name": ROLE_NAMES.get(old_role, "Неизвестно"),
"new_role": new_role,
"new_role_name": ROLE_NAMES.get(new_role, "Неизвестно")
}
@router.post("/pass/grant")
async def grant_pass(
body: CreatePassDirectRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Выдача проходки пользователю (Elder+ могут выдавать)"""
ip = request.client.host if request.client else "unknown"
# Проверяем право на прямую выдачу
if current_user["role"] < ROLE_CREATOR and not has_permission(current_user["role"], Permissions.APPROVE_PASS):
raise HTTPException(403, "Недостаточно прав для выдачи проходки")
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
(body.username,)
).fetchone()
if not target:
raise HTTPException(404, f"Пользователь {body.username} не найден")
# Проверяем, есть ли уже активная проходка
existing = conn.execute("""
SELECT p.code FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (target["id"], time.time())).fetchone()
if existing:
raise HTTPException(409, f"У пользователя {body.username} уже есть активная проходка")
# Создаем проходку
pass_code = secrets.token_hex(12).upper()
now = time.time()
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
VALUES (?, ?, ?, ?, 1)
""", (pass_code, target["username"], expires_at, body.max_uses))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
VALUES (?, ?, ?)
""", (target["id"], pass_code, now))
# Обновляем роль если нужно
if target["role"] < ROLE_PASS_HOLDER:
conn.execute(
"UPDATE users SET role = ? WHERE id = ?",
(ROLE_PASS_HOLDER, target["id"])
)
conn.commit()
log_audit(
current_user["id"],
"grant_pass",
f"Granted pass to {target['username']} (expires: {body.expires_days}d, max_uses: {body.max_uses})",
ip
)
logger.info("Pass granted", admin=current_user["username"], target=target["username"], code=pass_code)
return {
"success": True,
"pass_code": pass_code,
"username": target["username"],
"expires_at": expires_at,
"expires_days": body.expires_days,
"max_uses": body.max_uses
}
@router.delete("/pass/revoke/{username}")
async def revoke_pass(
username: str,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Отзыв проходки у пользователя"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
(username,)
).fetchone()
if not target:
raise HTTPException(404, f"Пользователь {username} не найден")
# Отзываем проходку
conn.execute("""
UPDATE passes SET is_active = 0
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
""", (target["id"],))
# Понижаем роль если она была только из-за проходки
if target["role"] == ROLE_PASS_HOLDER:
conn.execute(
"UPDATE users SET role = ? WHERE id = ?",
(ROLE_USER, target["id"])
)
conn.commit()
log_audit(current_user["id"], "revoke_pass", f"Revoked pass from {username}", ip)
logger.info("Pass revoked", admin=current_user["username"], target=username)
return {"success": True, "message": f"Проходка {username} отозвана"}
@router.post("/user/ban")
async def ban_user(
body: BanUserRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Бан пользователя (Elder+ могут банить)"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE id = ?",
(body.user_id,)
).fetchone()
if not target:
raise HTTPException(404, "Пользователь не найден")
# Нельзя забанить создателя
if target["role"] == ROLE_CREATOR:
raise HTTPException(403, "Нельзя забанить создателя")
# Elder не может банить других Elder
if target["role"] >= ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Недостаточно прав для бана этого пользователя")
banned_until = time.time() + (body.days * 86400)
conn.execute(
"UPDATE users SET is_active = 0, banned_until = ? WHERE id = ?",
(banned_until, target["id"])
)
# Отзываем проходку при бане
conn.execute("""
UPDATE passes SET is_active = 0
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
""", (target["id"],))
conn.commit()
log_audit(
current_user["id"],
"ban_user",
f"Banned {target['username']} for {body.days} days. Reason: {body.reason}",
ip
)
logger.info("User banned", admin=current_user["username"], target=target["username"], days=body.days)
return {
"success": True,
"username": target["username"],
"banned_until": banned_until,
"days": body.days
}
@router.post("/user/unban/{user_id}")
async def unban_user(
user_id: int,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Разбан пользователя"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
raise HTTPException(404, "Пользователь не найден")
conn.execute(
"UPDATE users SET is_active = 1, banned_until = NULL WHERE id = ?",
(user_id,)
)
conn.commit()
log_audit(current_user["id"], "unban_user", f"Unbanned {target['username']}", ip)
logger.info("User unbanned", admin=current_user["username"], target=target["username"])
return {"success": True, "username": target["username"]}
@router.get("/audit")
async def get_audit_log(
current_user: dict = Depends(require_role(ROLE_ELDER)),
limit: int = 50,
offset: int = 0,
user_id: Optional[int] = None
):
"""Просмотр логов аудита (только Elder+)"""
with get_db() as conn:
query = """
SELECT al.*, u.username
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
"""
params = []
if user_id:
query += " WHERE al.user_id = ?"
params.append(user_id)
query += " ORDER BY al.timestamp DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(query, params).fetchall()
total = conn.execute("SELECT COUNT(*) as count FROM audit_log").fetchone()["count"]
return {
"logs": [dict(row) for row in rows],
"total": total,
"limit": limit,
"offset": offset
}
@router.get("/stats")
async def get_admin_stats(
current_user: dict = Depends(require_role(ROLE_MODERATOR))
):
"""Статистика для админов"""
with get_db() as conn:
# Общая статистика
total_users = conn.execute("SELECT COUNT(*) as count FROM users").fetchone()["count"]
# Статистика по ролям
role_stats = conn.execute("""
SELECT role, COUNT(*) as count
FROM users
GROUP BY role
ORDER BY role DESC
""").fetchall()
# Активные проходки
active_passes = conn.execute("""
SELECT COUNT(*) as count FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE p.expires_at IS NULL OR p.expires_at > ?
""", (time.time(),)).fetchone()["count"]
# Забаненные пользователи
banned_users = conn.execute("""
SELECT COUNT(*) as count FROM users
WHERE is_active = 0 AND banned_until > ?
""", (time.time(),)).fetchone()["count"]
# Недавние регистрации (последние 7 дней)
week_ago = time.time() - (7 * 86400)
recent_registrations = conn.execute("""
SELECT COUNT(*) as count FROM users WHERE created_at > ?
""", (week_ago,)).fetchone()["count"]
return {
"total_users": total_users,
"active_passes": active_passes,
"banned_users": banned_users,
"recent_registrations_7d": recent_registrations,
"roles_distribution": [
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
for r in role_stats
],
"my_info": {
"role": current_user["role"],
"role_name": ROLE_NAMES.get(current_user["role"], "Неизвестно"),
"username": current_user["username"]
}
}
@router.get("/me")
async def get_my_info(current_user: dict = Depends(get_current_user)):
"""Информация о текущем пользователе с правами"""
with get_db() as conn:
row = conn.execute("""
SELECT id, username, uuid, role, created_at, last_login
FROM users WHERE id = ?
""", (current_user["id"],)).fetchone()
# Проверяем наличие активной проходки
has_pass = False
if row["role"] >= ROLE_PASS_HOLDER:
pass_row = conn.execute("""
SELECT 1 FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (current_user["id"], time.time())).fetchone()
has_pass = pass_row is not None
permissions = list(ROLE_PERMISSIONS.get(row["role"], set()))
return {
"id": row["id"],
"username": row["username"],
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
"created_at": row["created_at"],
"last_login": row["last_login"],
"has_pass": has_pass,
"permissions": permissions
}
+620 -263
View File
File diff suppressed because it is too large Load Diff
+40 -2
View File
@@ -15,11 +15,12 @@ def parse_args():
mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload") mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload")
mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers") mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers")
mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests") mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests")
mode_group.add_argument("--sync", action="store_true", help="Sync mode - sync with main server as mirror")
# Additional options # Additional options
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)") parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
parser.add_argument("--workers", type=int, default=4, help="Number of workers for production mode") parser.add_argument("--workers", type=int, default=1, help="Number of workers for production mode (default: 1, more causes file download slowdown)")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)") parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
return parser.parse_args() return parser.parse_args()
@@ -53,6 +54,43 @@ async def run_test_mode():
logger.info("All packs validated successfully") logger.info("All packs validated successfully")
sys.exit(0) sys.exit(0)
async def run_sync_mode():
"""Sync with main server as mirror"""
import os
main_url = os.environ.get("MAIN_SERVER_URL")
if not main_url:
logger.error("MAIN_SERVER_URL not set. Run: MAIN_SERVER_URL=http://main:1582 python cli.py --sync")
sys.exit(1)
logger.info(f"Starting mirror sync from {main_url}")
# Get version from main
import httpx
async with httpx.AsyncClient() as client:
# Get version
try:
resp = await client.get(f"{main_url}/launcher/version")
data = resp.json()
version = data.get("version")
logger.info(f"Main server version: {version}")
except Exception as e:
logger.error(f"Failed to get version from main: {e}")
sys.exit(1)
# Get sync manifest
try:
resp = await client.get(f"{main_url}/launcher/sync/{version}")
sync_data = resp.json()
logger.info(f"Files to sync: {len(sync_data.get('files', []))}")
except Exception as e:
logger.error(f"Failed to get sync manifest: {e}")
sys.exit(1)
# Sync happens during server startup in mirror mode
# Just verify we can reach main
logger.info("Mirror sync configured. Server will sync on startup.")
def run_production_mode(host: str, port: int, workers: int): def run_production_mode(host: str, port: int, workers: int):
"""Run with multiple workers""" """Run with multiple workers"""
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}") logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
+176
View File
@@ -0,0 +1,176 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
import structlog
import time
from auth import get_db, get_current_user
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api", tags=["friends"])
def init_friends_db():
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS friendships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(requester_id, target_id),
FOREIGN KEY (requester_id) REFERENCES users(id),
FOREIGN KEY (target_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS user_status (
user_id INTEGER PRIMARY KEY,
is_online INTEGER DEFAULT 0,
current_pack TEXT DEFAULT '',
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id);
CREATE INDEX IF NOT EXISTS idx_friendships_target ON friendships(target_id);
""")
class AddFriendRequest(BaseModel):
username: str
class RemoveFriendRequest(BaseModel):
user_id: int
class AcceptFriendRequest(BaseModel):
user_id: int
class StatusUpdateRequest(BaseModel):
online: bool = True
current_pack: Optional[str] = None
@router.post("/friends/add")
async def add_friend(
req: AddFriendRequest,
current_user: dict = Depends(get_current_user)
):
with get_db() as conn:
cursor = conn.execute("SELECT id FROM users WHERE username = ?", (req.username,))
target = cursor.fetchone()
if not target:
raise HTTPException(404, "User not found")
target_id = target[0]
if target_id == current_user["id"]:
raise HTTPException(400, "Cannot add yourself")
cursor = conn.execute(
"SELECT status FROM friendships WHERE requester_id = ? AND target_id = ?",
(current_user["id"], target_id)
)
existing = cursor.fetchone()
if existing:
if existing[0] == "accepted":
raise HTTPException(400, "Already friends")
raise HTTPException(400, f"Friend request already {existing[0]}")
conn.execute(
"INSERT INTO friendships (requester_id, target_id, status) VALUES (?, ?, 'pending')",
(current_user["id"], target_id)
)
logger.info("Friend request sent", from_user=current_user["id"], to_user=target_id)
return {"message": "Friend request sent"}
@router.post("/friends/accept")
async def accept_friend(
req: AcceptFriendRequest,
current_user: dict = Depends(get_current_user)
):
with get_db() as conn:
cursor = conn.execute(
"SELECT id, requester_id FROM friendships WHERE target_id = ? AND requester_id = ? AND status = 'pending'",
(current_user["id"], req.user_id)
)
row = cursor.fetchone()
if not row:
raise HTTPException(404, "No pending friend request from this user")
conn.execute("UPDATE friendships SET status = 'accepted' WHERE id = ?", (row[0],))
logger.info("Friend request accepted", from_user=req.user_id, to_user=current_user["id"])
return {"message": "Friend request accepted"}
@router.post("/friends/remove")
async def remove_friend(
req: RemoveFriendRequest,
current_user: dict = Depends(get_current_user)
):
with get_db() as conn:
cursor = conn.execute(
"SELECT id FROM friendships WHERE (requester_id = ? AND target_id = ?) OR (requester_id = ? AND target_id = ?)",
(current_user["id"], req.user_id, req.user_id, current_user["id"])
)
row = cursor.fetchone()
if not row:
raise HTTPException(404, "Not friends")
conn.execute("DELETE FROM friendships WHERE id = ?", (row[0],))
logger.info("Friend removed", user=current_user["id"], target=req.user_id)
return {"message": "Friend removed"}
@router.get("/friends/list")
async def list_friends(current_user: dict = Depends(get_current_user)):
friends = []
with get_db() as conn:
rows = conn.execute("""
SELECT u.id, u.username, u.role,
COALESCE(us.is_online, 0) as online,
COALESCE(us.current_pack, '') as current_pack,
us.last_seen
FROM friendships f
JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id
LEFT JOIN user_status us ON u.id = us.user_id
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
""", (current_user["id"], current_user["id"], current_user["id"]))
for row in rows:
friends.append({
"id": row[0],
"username": row[1],
"role": row[2],
"online": bool(row[3]),
"current_pack": row[4],
"last_seen": row[5] if row[5] else None
})
return {"friends": friends}
@router.get("/friends/requests")
async def list_friend_requests(current_user: dict = Depends(get_current_user)):
requests = []
with get_db() as conn:
rows = conn.execute("""
SELECT u.id, u.username, u.role, f.created_at
FROM friendships f
JOIN users u ON f.requester_id = u.id
WHERE f.target_id = ? AND f.status = 'pending'
""", (current_user["id"],))
for row in rows:
requests.append({
"id": row[0],
"username": row[1],
"role": row[2],
"created_at": row[3] if row[3] else None
})
return {"requests": requests}
@router.post("/friends/status")
async def update_status(
req: StatusUpdateRequest,
current_user: dict = Depends(get_current_user)
):
with get_db() as conn:
conn.execute("""
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
is_online = excluded.is_online,
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
last_seen = CURRENT_TIMESTAMP
""", (current_user["id"], int(req.online), req.current_pack or ""))
return {"status": "ok"}
-25
View File
@@ -1,25 +0,0 @@
# http_logger.py
import logging
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
import time
import uuid
logger = logging.getLogger("uvicorn.error")
class HTTPLogger:
"""Custom HTTP logger to catch invalid requests"""
@staticmethod
def log_invalid_request(data: bytes, client_addr: tuple):
"""Log invalid HTTP requests"""
try:
# Try to decode as much as possible
request_str = data.decode('utf-8', errors='replace')[:500]
logger.warning(
f"Invalid HTTP request received\n"
f"Client: {client_addr[0]}:{client_addr[1]}\n"
f"Data: {request_str}"
)
except Exception as e:
logger.warning(f"Invalid HTTP request from {client_addr}, could not decode: {e}")
+1396 -97
View File
File diff suppressed because it is too large Load Diff
+176 -17
View File
@@ -5,43 +5,202 @@ import logging
import time import time
import uuid import uuid
import traceback import traceback
import httpx
import re
from collections import defaultdict
from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Public blocklist URLs
BLOCKLIST_URLS = [
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset",
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/iblocklist_isp.netset",
]
def load_blocklist_from_url(url: str, timeout: int = 10) -> set[str]:
"""Download and parse IP blocklist from URL"""
ips = set()
try:
response = httpx.get(url, timeout=timeout, follow_redirects=True)
if response.status_code == 200:
for line in response.text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if re.match(r"^\d+\.\d+\.\d+\.\d+(/\d+)?$", line):
ip = line.split("/")[0]
ips.add(ip)
logger.info(f"Loaded {len(ips)} IPs from blocklist: {url}")
except Exception as e:
logger.warning(f"Failed to load blocklist from {url}: {e}")
return ips
def load_public_blocklists() -> set[str]:
"""Load all public blocklists"""
all_ips = set()
for url in BLOCKLIST_URLS:
all_ips.update(load_blocklist_from_url(url))
logger.info(f"Total blocked IPs from public lists: {len(all_ips)}")
return all_ips
# Rate limiting config
RATE_LIMIT_REQUESTS = 60 # Max requests per window
RATE_LIMIT_WINDOW = 60 # Window in seconds
_ip_request_counts: dict[str, list[float]] = defaultdict(list)
# IP blocking config (set from main.py)
BLOCKED_IPS: set[str] = set()
# Request stats (for summary logging)
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
_stats_last_log = time.time()
STATS_LOG_INTERVAL = 60 # Log stats every 60 seconds
# Suspicious paths that indicate bot scanning
SUSPICIOUS_PATHS = {
".env", ".env.local", ".env.production", ".env.development", ".env.bak",
".env.old", ".env.backup", ".env.orig", ".env.save", ".env~", ".env.swp",
".env.copy", ".env.1", ".ENV",
"appsettings.json", "appsettings.Development.json", "appsettings.Production.json",
"appsettings.Staging.json", "web.config",
"phpinfo.php", "info.php", "test.php", "i.php", "phpi.php", "php.php",
"phptest.php", "server-info.php", "phpinformation.php", "infophp.php",
"php_info.php", "config.php",
"actuator/env", "actuator/configprops", "actuator",
"manage/env", "admin/env", "env",
"actuator/env/aws", "actuator/env/cloud",
"_layouts/15/", "_layouts/15/ToolPane.aspx",
"wp-admin", "wp-login.php", "wordpress",
"administrator", "phpmyadmin",
".git", ".svn", ".hg",
}
def get_client_ip(request: Request) -> str:
"""Extract client IP from request"""
client_ip = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
return client_ip
def is_ip_blocked(client_ip: str) -> bool:
"""Check if IP is blocked"""
return client_ip in BLOCKED_IPS
def check_rate_limit(client_ip: str) -> bool:
"""Check if IP has exceeded rate limit"""
now = time.time()
# Clean old requests
_ip_request_counts[client_ip] = [
t for t in _ip_request_counts[client_ip]
if now - t < RATE_LIMIT_WINDOW
]
if len(_ip_request_counts[client_ip]) >= RATE_LIMIT_REQUESTS:
return False
_ip_request_counts[client_ip].append(now)
return True
def is_suspicious_path(path: str) -> bool:
"""Check if path is suspicious (bot scanning)"""
path_lower = path.lower()
# Direct match
if path_lower in SUSPICIOUS_PATHS:
return True
# Contains suspicious patterns
suspicious_patterns = [
".env", "phpinfo", "actuator", "wp-", "phpmyadmin",
".git", ".svn",
]
for pattern in suspicious_patterns:
if pattern in path_lower:
return True
# Path traversal attempts
if ".." in path or ".." in path.replace("%2e%2e", "").replace("%252e", ""):
return True
return False
def set_ip_config(blocked: Optional[set[str]] = None):
"""Configure IP blocking (call from main.py)"""
global BLOCKED_IPS
if blocked is not None:
BLOCKED_IPS = blocked
class LoggingMiddleware(BaseHTTPMiddleware): class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# Generate request ID
request_id = str(uuid.uuid4())[:8] request_id = str(uuid.uuid4())[:8]
global _stats, _stats_last_log
# Get client IP client_ip = get_client_ip(request)
client_ip = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
# Log incoming request # Check if IP is blocked (silent)
logger.info(f"{request.method} {request.url.path} (IP: {client_ip}, ID: {request_id})") if is_ip_blocked(client_ip):
_stats["blocked"] += 1
return Response(status_code=404, content="")
# Start timer # Check rate limit
if not check_rate_limit(client_ip):
_stats["rate_limited"] += 1
# Periodic stats logging instead of every warning
if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
logger.warning(f"Stats: {_stats}")
_stats_last_log = time.time()
return Response(status_code=429, content="Too many requests")
# Check suspicious path (silent 404 for bots)
path = request.url.path
if is_suspicious_path(path):
# Return 404 without logging - confuse the bots
return Response(status_code=404, content="")
# Skip logging for large file downloads (don't spam logs)
is_file_download = path.startswith("/pack/") and "/file/" in path
# Track total requests for stats
_stats["total"] += 1
# Log legitimate requests (except file downloads)
start_time = time.time() start_time = time.time()
if not is_file_download:
logger.info(f"{request.method} {path} (IP: {client_ip}, ID: {request_id})")
try: try:
response = await call_next(request) response = await call_next(request)
# Calculate duration
duration = (time.time() - start_time) * 1000 duration = (time.time() - start_time) * 1000
# Log response if not is_file_download:
logger.info(f"{request.method} {request.url.path}{response.status_code} ({duration:.0f}ms) [ID: {request_id}]") logger.info(f"{request.method} {path}{response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
# Periodic stats logging (only log if there were blocked/rate-limited)
now = time.time()
if now - _stats_last_log > STATS_LOG_INTERVAL:
if _stats["blocked"] > 0 or _stats["rate_limited"] > 0:
logger.warning(f"Blocked requests: IP_blocked={_stats['blocked']}, rate_limited={_stats['rate_limited']}")
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
_stats_last_log = now
# Add request ID to response headers
response.headers["X-Request-ID"] = request_id response.headers["X-Request-ID"] = request_id
return response return response
except Exception as e: except Exception as e:
duration = (time.time() - start_time) * 1000 duration = (time.time() - start_time) * 1000
# Log full traceback
error_traceback = traceback.format_exc() error_traceback = traceback.format_exc()
logger.error(f"{request.method} {request.url.path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}") logger.error(f"{request.method} {path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
raise raise
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Lightweight Mirror Server - only serves static files
"""
import os
import asyncio
from pathlib import Path
import structlog
import httpx
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582")
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta")
PORT = int(os.environ.get("PORT", "1582"))
BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
PACKS_DIR = Path("packs")
BUILDS_DIR.mkdir(exist_ok=True)
PACKS_DIR.mkdir(exist_ok=True)
logging = structlog.get_logger()
async def sync_with_main():
"""Sync files from main server"""
logging.info(f"Syncing from {MAIN_SERVER_URL}")
client = httpx.AsyncClient(timeout=120.0)
headers = {"X-Master-Key": MASTER_KEY}
try:
# Get launcher info
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers)
if resp.status_code != 200:
logging.error(f"Failed to get launcher info: {resp.status_code}")
return
data = resp.json()
current_version = data.get("current_version", "1.0.9")
files = data.get("files", {})
zips = files.get("zips", [])
logging.info(f"Current version: {current_version}, zips: {len(zips)}")
# Download latest ZIP
for z in zips:
if not z.get("is_legacy"):
zip_filename = z.get("filename")
zip_path = BUILDS_DIR / zip_filename
if not zip_path.exists():
logging.info(f"Downloading {zip_filename}...")
# Try direct download
download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}"
resp = await client.get(download_url, headers=headers)
if resp.status_code == 200:
zip_path.write_bytes(resp.content)
logging.info(f"Downloaded {zip_filename}")
# Extract
version = z.get("version")
extract_to = VERSIONS_DIR / version
extract_to.mkdir(parents=True, exist_ok=True)
import zipfile
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(extract_to)
logging.info(f"Extracted {version}")
# Get launcher meta
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers)
if resp.status_code == 200:
(BUILDS_DIR / "meta.json").write_text(resp.text)
logging.info("Meta synced")
# Sync packs list
resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers)
if resp.status_code == 200:
packs_data = resp.json()
packs = packs_data.get("packs", [])
logging.info(f"Found {len(packs)} packs")
for pack in packs:
pack_name = pack.get("name")
pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}"
resp = await client.get(pack_meta_url, headers=headers)
if resp.status_code == 200:
pack_dir = PACKS_DIR / pack_name
pack_dir.mkdir(parents=True, exist_ok=True)
(pack_dir / "meta.json").write_text(resp.text)
logging.info(f"Synced pack: {pack_name}")
finally:
await client.aclose()
logging.info("Sync complete")
async def run_server():
"""Run static server"""
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
import aiofiles
import mimetypes
import re
import uvicorn
app = FastAPI(title="ZernMC Mirror")
async def send_file(file_path: Path, request: Request):
if not file_path.exists():
raise HTTPException(404, "Not found")
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
file_size = file_path.stat().st_size
range_header = request.headers.get("range")
if range_header:
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
if match:
start = int(match.group(1))
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
content_length = end - start + 1
async with aiofiles.open(file_path, "rb") as f:
await f.seek(start)
chunk = await f.read(content_length)
return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type,
headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)})
async def file_iter():
async with aiofiles.open(file_path, "rb") as f:
while True:
chunk = await f.read(65536)
if not chunk:
break
yield chunk
return StreamingResponse(file_iter(), media_type=content_type,
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)})
@app.get("/launcher/info")
async def get_launcher_info():
meta_path = BUILDS_DIR / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
return {"current_version": "unknown", "files": {}}
@app.get("/launcher/version")
async def get_version():
return await get_launcher_info()
@app.get("/launcher/file/{version}/{file_path:path}")
async def get_launcher_file(version: str, file_path: str, request: Request):
full_path = BUILDS_DIR / "versions" / version / file_path
if ".." in file_path:
raise HTTPException(403, "Invalid path")
if not full_path.exists():
raise HTTPException(404, f"File not found: {file_path}")
return await send_file(full_path, request)
@app.get("/launcher/download/zip/{filename}")
async def download_zip(filename: str, request: Request):
return await send_file(BUILDS_DIR / filename, request)
@app.get("/launcher/meta/{version}")
async def get_meta(version: str):
meta_path = BUILDS_DIR / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
raise HTTPException(404, "Meta not found")
@app.get("/launcher/mirrors")
async def get_mirrors():
return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]}
@app.get("/packs")
async def list_packs():
import json
packs = []
for pack_dir in PACKS_DIR.iterdir():
if pack_dir.is_dir():
meta_path = pack_dir / "meta.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text())
packs.append({
"name": pack_dir.name,
"version": meta.get("version", 1),
"files_count": len(meta.get("files", {}))
})
except:
packs.append({"name": pack_dir.name, "error": "invalid"})
return {"packs": packs}
@app.get("/pack/{pack_name}")
async def get_pack(pack_name: str):
meta_path = PACKS_DIR / pack_name / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
raise HTTPException(404, "Pack not found")
@app.get("/pack/meta/{pack_name}")
async def get_pack_meta(pack_name: str):
return await get_pack(pack_name)
@app.get("/pack/{pack_name}/diff")
async def get_pack_diff(pack_name: str):
# For mirror, just return empty diff (no local changes)
return {"added": [], "removed": [], "changed": []}
@app.get("/pack/{pack_name}/file/{file_path:path}")
async def get_pack_file(pack_name: str, file_path: str, request: Request):
return await send_file(PACKS_DIR / pack_name / file_path, request)
config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info")
server = uvicorn.Server(config)
await server.serve()
async def main():
logging.info("Starting ZernMC Mirror Server")
await sync_with_main()
await run_server()
if __name__ == "__main__":
asyncio.run(main())
+1 -13
View File
@@ -28,16 +28,4 @@ class PackMeta(BaseModel):
minecraft_version: str minecraft_version: str
loader_type: str loader_type: str
loader_version: Optional[str] = None loader_version: Optional[str] = None
asset_index: Optional[str] = None
class MinecraftVersion(BaseModel):
version: str
type: str # release, snapshot, old_alpha, old_beta
release_time: datetime
url: Optional[str] = None
class ModLoader(BaseModel):
type: str
version: str
minecraft_version: str
installer_url: Optional[str] = None
libraries: List[str] = Field(default_factory=list)
+16 -13
View File
@@ -5,6 +5,8 @@ from pathlib import Path
import json import json
from typing import Optional, Dict from typing import Optional, Dict
import structlog import structlog
import asyncio
import aiofiles
from models import PackMeta, FileEntry from models import PackMeta, FileEntry
@@ -33,9 +35,9 @@ def calculate_sha256_sync(file_path: Path) -> str:
return hash_sha.hexdigest() return hash_sha.hexdigest()
async def calculate_sha256(file_path: Path) -> str: async def calculate_sha256(file_path: Path) -> str:
"""Calculate SHA256 hash of a file (async wrapper)""" """Calculate SHA256 hash of a file (async)"""
# Используем синхронную версию для простоты loop = asyncio.get_running_loop()
return calculate_sha256_sync(file_path) return await loop.run_in_executor(None, calculate_sha256_sync, file_path)
async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
"""Scan pack directory and update manifest if needed""" """Scan pack directory and update manifest if needed"""
@@ -51,11 +53,11 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
if not force_rescan and pack_name in _manifest_cache: if not force_rescan and pack_name in _manifest_cache:
return _manifest_cache[pack_name] return _manifest_cache[pack_name]
# Load existing meta if available (синхронно) # Load existing meta if available
if meta_path.exists(): if meta_path.exists():
try: try:
with open(meta_path, 'r', encoding='utf-8') as f: async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.loads(await f.read())
current_meta = PackMeta.model_validate(data) current_meta = PackMeta.model_validate(data)
except Exception as e: except Exception as e:
logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}") logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}")
@@ -109,16 +111,17 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
minecraft_version = "1.20.4" minecraft_version = "1.20.4"
loader_type = "vanilla" loader_type = "vanilla"
loader_version = None loader_version = None
asset_index = None
pack_config_path = pack_path / "instance.json" pack_config_path = pack_path / "instance.json"
if pack_config_path.exists(): if pack_config_path.exists():
try: try:
# Синхронное чтение конфига async with aiofiles.open(pack_config_path, 'r', encoding='utf-8') as f:
with open(pack_config_path, 'r', encoding='utf-8') as f: config = json.loads(await f.read())
config = json.load(f)
minecraft_version = config.get("minecraftVersion", minecraft_version) minecraft_version = config.get("minecraftVersion", minecraft_version)
loader_type = config.get("loaderType", loader_type) loader_type = config.get("loaderType", loader_type)
loader_version = config.get("loaderVersion") loader_version = config.get("loaderVersion")
asset_index = config.get("assetIndex")
except Exception as e: except Exception as e:
logger.warning(f"Failed to load instance.json for {pack_name}: {e}") logger.warning(f"Failed to load instance.json for {pack_name}: {e}")
@@ -131,12 +134,12 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
ignored_dirs=ignored_dirs, ignored_dirs=ignored_dirs,
minecraft_version=minecraft_version, minecraft_version=minecraft_version,
loader_type=loader_type, loader_type=loader_type,
loader_version=loader_version loader_version=loader_version,
asset_index=asset_index
) )
# Save to disk (синхронно) async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f:
with open(meta_path, 'w', encoding='utf-8') as f: await f.write(new_meta.model_dump_json(indent=2))
f.write(new_meta.model_dump_json(indent=2))
# Update cache # Update cache
_manifest_cache[pack_name] = new_meta _manifest_cache[pack_name] = new_meta
-77
View File
@@ -1,77 +0,0 @@
import json
from pathlib import Path
from datetime import datetime
import structlog
logger = structlog.get_logger(__name__)
PASSES_FILE = Path("data/passes.json")
def load_passes():
if not PASSES_FILE.exists():
PASSES_FILE.parent.mkdir(exist_ok=True)
default = {"passes": {}}
PASSES_FILE.write_text(json.dumps(default, indent=2, ensure_ascii=False))
return default
try:
return json.loads(PASSES_FILE.read_text(encoding="utf-8"))
except:
return {"passes": {}}
def save_passes(data):
PASSES_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
def activate_pass(pass_code: str, username: str, user_id: int) -> dict:
data = load_passes()
pass_code = pass_code.upper().strip()
if pass_code not in data["passes"]:
return {"success": False, "error": "Проходка не найдена"}
p = data["passes"][pass_code]
if not p.get("is_active", True):
return {"success": False, "error": "Проходка деактивирована"}
if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
return {"success": False, "error": "Проходка истекла"}
if p.get("owner") is not None:
if p.get("owner") != username:
return {"success": False, "error": "Проходка уже активирована другим пользователем"}
return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"}
# Активация
now = datetime.now().timestamp()
p["owner"] = username
p["activated_at"] = now
p["uses"] = p.get("uses", 0) + 1
save_passes(data)
logger.info("Pass activated", pass_code=pass_code, username=username)
return {"success": True, "message": "Проходка успешно активирована!"}
def has_active_pass(username: str) -> bool:
data = load_passes()
for p in data["passes"].values():
if p.get("owner") == username:
if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
continue
if p.get("is_active", True):
return True
return False
def get_user_passes(username: str) -> list:
data = load_passes()
result = []
now = datetime.now().timestamp()
for p in data["passes"].values():
if p.get("owner") == username:
result.append({
"code": p["code"],
"activated_at": p.get("activated_at"),
"expires_at": p.get("expires_at"),
"is_active": p.get("is_active", True) and (not p.get("expires_at") or p.get("expires_at") > now)
})
return result
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
import structlog
from auth import get_db, get_current_user
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api", tags=["playtime"])
def init_playtime_db():
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS playtime (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pack_name TEXT DEFAULT '',
minutes INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_playtime_user ON playtime(user_id);
""")
class SyncPlaytimeRequest(BaseModel):
minutes: int
pack_name: Optional[str] = ""
@router.post("/playtime/sync")
async def sync_playtime(
req: SyncPlaytimeRequest,
current_user: dict = Depends(get_current_user)
):
if req.minutes < 0 or req.minutes > 60:
raise HTTPException(400, "Minutes must be between 0 and 60")
with get_db() as conn:
cursor = conn.execute(
"SELECT id, minutes FROM playtime WHERE user_id = ? AND pack_name = ?",
(current_user["id"], req.pack_name)
)
existing = cursor.fetchone()
if existing:
conn.execute(
"UPDATE playtime SET minutes = minutes + ?, last_updated = CURRENT_TIMESTAMP WHERE id = ?",
(req.minutes, existing[0])
)
else:
conn.execute(
"INSERT INTO playtime (user_id, pack_name, minutes) VALUES (?, ?, ?)",
(current_user["user_id"], req.pack_name, req.minutes)
)
logger.info("Playtime synced", user=current_user["user_id"], minutes=req.minutes)
return {"status": "ok"}
@router.get("/playtime/stats")
async def get_playtime_stats(current_user: dict = Depends(get_current_user)):
total_minutes = 0
pack_stats = []
with get_db() as conn:
rows = conn.execute(
"SELECT COALESCE(SUM(minutes), 0) FROM playtime WHERE user_id = ?",
(current_user["user_id"],)
)
total_minutes = rows.fetchone()[0]
rows = conn.execute(
"SELECT pack_name, minutes FROM playtime WHERE user_id = ? AND pack_name != '' ORDER BY minutes DESC",
(current_user["user_id"],)
)
for row in rows:
pack_stats.append({
"pack_name": row[0],
"minutes": row[1]
})
return {
"total_minutes": total_minutes,
"total_hours": round(total_minutes / 60, 1),
"packs": pack_stats
}
+114
View File
@@ -0,0 +1,114 @@
# roles.py
from enum import IntEnum
from typing import Dict, Set
class UserRole(IntEnum):
USER = 0 # Обычный пользователь
PASS_HOLDER = 1 # Пользователь с проходкой
MODERATOR = 2 # Модератор
ELDER = 3 # Elder Moderator
CREATOR = 4 # Создатель
# Aliases for backwards compatibility with admin_router.py
ROLE_USER = UserRole.USER
ROLE_PASS_HOLDER = UserRole.PASS_HOLDER
ROLE_MODERATOR = UserRole.MODERATOR
ROLE_ELDER = UserRole.ELDER
ROLE_CREATOR = UserRole.CREATOR
__all__ = [
"UserRole", "ROLE_USER", "ROLE_PASS_HOLDER", "ROLE_MODERATOR",
"ROLE_ELDER", "ROLE_CREATOR", "ROLE_NAMES", "Permissions",
"ROLE_PERMISSIONS", "has_permission", "require_permission",
]
ROLE_NAMES: Dict[int, str] = {
UserRole.USER: "Игрок",
UserRole.PASS_HOLDER: "Игрок [Проходка]",
UserRole.MODERATOR: "Модератор",
UserRole.ELDER: "Elder Moderator",
UserRole.CREATOR: "Создатель"
}
# Права доступа
class Permissions:
# Базовые права
DOWNLOAD_PACK = "download_pack" # Скачивание сборок
VIEW_PACKS = "view_packs" # Просмотр списка сборок
# Права модератора
REQUEST_PASS = "request_pass" # Запрос проходки для игрока
VIEW_USER_LIST = "view_user_list" # Просмотр списка пользователей
# Права Elder Moderator
APPROVE_PASS = "approve_pass" # Одобрение проходок
REJECT_PASS = "reject_pass" # Отклонение проходок
VIEW_PASS_REQUESTS = "view_pass_requests" # Просмотр запросов проходок
MANAGE_MODERATORS = "manage_moderators" # Управление модераторами
# Права создателя
DIRECT_PASS = "direct_pass" # Прямая выдача проходки
MANAGE_ELDER = "manage_elder" # Управление Elder
MANAGE_SERVER = "manage_server" # Управление сервером
VIEW_AUDIT_LOG = "view_audit_log" # Просмотр логов
# Маппинг ролей на права
ROLE_PERMISSIONS: Dict[int, Set[str]] = {
UserRole.USER: {
# Обычный игрок НЕ может даже смотреть сборки!
# Только авторизоваться и смотреть свой профиль
},
UserRole.PASS_HOLDER: {
Permissions.VIEW_PACKS, # Может видеть список сборок
Permissions.DOWNLOAD_PACK, # Может скачивать сборки
},
UserRole.MODERATOR: {
Permissions.VIEW_PACKS,
Permissions.DOWNLOAD_PACK,
Permissions.REQUEST_PASS, # Может запрашивать проходки для игроков
Permissions.VIEW_USER_LIST, # Может видеть список пользователей
},
UserRole.ELDER: {
Permissions.VIEW_PACKS,
Permissions.DOWNLOAD_PACK,
Permissions.REQUEST_PASS,
Permissions.VIEW_USER_LIST,
Permissions.APPROVE_PASS, # Может одобрять проходки
Permissions.REJECT_PASS, # Может отклонять проходки
Permissions.VIEW_PASS_REQUESTS,
Permissions.MANAGE_MODERATORS, # Может управлять модераторами
},
UserRole.CREATOR: {
Permissions.VIEW_PACKS,
Permissions.DOWNLOAD_PACK,
Permissions.REQUEST_PASS,
Permissions.VIEW_USER_LIST,
Permissions.APPROVE_PASS,
Permissions.REJECT_PASS,
Permissions.VIEW_PASS_REQUESTS,
Permissions.MANAGE_MODERATORS,
Permissions.DIRECT_PASS, # Прямая выдача проходки
Permissions.MANAGE_ELDER, # Управление Elder
Permissions.MANAGE_SERVER, # Управление сервером
Permissions.VIEW_AUDIT_LOG, # Просмотр логов
}
}
def has_permission(role: int, permission: str) -> bool:
"""Проверка наличия права у роли"""
return permission in ROLE_PERMISSIONS.get(role, set())
def require_permission(permission: str):
"""Декоратор для проверки права"""
from functools import wraps
from fastapi import HTTPException, Depends
from auth import get_current_user
def decorator(func):
@wraps(func)
async def wrapper(*args, current_user: dict = Depends(get_current_user), **kwargs):
if not has_permission(current_user["role"], permission):
raise HTTPException(403, f"Недостаточно прав. Требуется право: {permission}")
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
+125
View File
@@ -0,0 +1,125 @@
import os
import sys
import pytest
import tempfile
import shutil
from pathlib import Path
def auth_headers(token):
"""Create Authorization headers."""
return {"Authorization": f"Bearer {token}"}
@pytest.fixture(scope="session")
def test_db_dir():
"""Temporary directory for test databases."""
d = tempfile.mkdtemp(prefix="zern_test_")
yield Path(d)
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture(scope="session")
def test_app(test_db_dir):
"""Create FastAPI app with test database."""
# Patch auth module paths BEFORE importing anything
import auth
auth.AUTH_DB = test_db_dir / "auth.db"
auth.SECRET_KEY = test_db_dir / ".secret_key"
auth._rate_limit_cache.clear()
# Initialize test database
auth.init_db()
from main import app
return app
@pytest.fixture
def client(test_app):
"""TestClient instance."""
from fastapi.testclient import TestClient
return TestClient(test_app)
@pytest.fixture
def registered_user(client):
"""Register a unique test user."""
import secrets
username = f"testuser_{secrets.token_hex(4)}"
password = "TestPassword123"
resp = client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 200, f"Registration failed: {resp.text}"
return {"username": username, "password": password}
@pytest.fixture
def logged_in_user(client, registered_user):
"""Login and return tokens."""
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200, f"Login failed: {resp.text}"
data = resp.json()
return {
"username": registered_user["username"],
"password": registered_user["password"],
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
"expires_in": data["expires_in"],
"uuid": data["uuid"],
"role": data["role"],
}
@pytest.fixture
def logged_in_user_with_pass(client, registered_user):
"""Login user and give them role 1 (pass holder)."""
# Promote to pass holder
import sqlite3
import auth
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],))
conn.commit()
conn.close()
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200, f"Login failed: {resp.text}"
data = resp.json()
return {
"username": registered_user["username"],
"password": registered_user["password"],
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
"expires_in": data["expires_in"],
"uuid": data["uuid"],
"role": data["role"],
}
@pytest.fixture
def admin_user(client):
"""Create and login a creator/admin user."""
import secrets
import sqlite3
import auth
username = f"admin_{secrets.token_hex(4)}"
password = "AdminPassword123"
resp = client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 200
# Promote to creator
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute("UPDATE users SET role = 4 WHERE username = ?", (username,))
conn.commit()
conn.close()
resp = client.post("/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200
data = resp.json()
return {
"username": username,
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
}
+128
View File
@@ -0,0 +1,128 @@
"""Tests for admin endpoints."""
import pytest
import sqlite3
import time
from tests.conftest import auth_headers
from auth import AUTH_DB
class TestAdminMe:
"""Test /admin/me endpoint."""
def test_admin_me_success(self, client, logged_in_user):
resp = client.get("/admin/me", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "id" in data
assert "username" in data
assert "uuid" in data
assert "role" in data
assert "role_name" in data
assert "has_pass" in data
assert "permissions" in data
def test_admin_me_no_auth(self, client):
resp = client.get("/admin/me")
assert resp.status_code in (401, 403) # Either is acceptable
class TestAdminUsersList:
"""Test /admin/users endpoint."""
def test_admin_users_list(self, client, admin_user):
resp = client.get("/admin/users", headers=auth_headers(admin_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "users" in data
assert isinstance(data["users"], list)
assert len(data["users"]) >= 1 # At least the admin user
def test_admin_users_list_no_admin(self, client, logged_in_user):
"""Regular user should not access admin endpoints."""
resp = client.get("/admin/users", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code in (401, 403)
def test_admin_users_list_no_auth(self, client):
resp = client.get("/admin/users")
assert resp.status_code in (401, 403)
class TestAdminBan:
"""Test ban functionality via admin endpoints."""
def test_ban_user(self, client, logged_in_user, admin_user):
"""Admin bans a user."""
# Get user ID first
import sqlite3
from auth import AUTH_DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT id FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row is not None
resp = client.post("/admin/user/ban", json={
"user_id": row[0],
"days": 1,
"reason": "Test ban"
}, headers=auth_headers(admin_user["access_token"]))
assert resp.status_code == 200
# Verify ban in DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT banned_until FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row is not None
assert row[0] is not None
assert row[0] > time.time()
def test_ban_nonexistent_user(self, client, admin_user):
resp = client.post("/admin/user/ban", json={
"user_id": 99999,
"days": 1,
"reason": "Test ban"
}, headers=auth_headers(admin_user["access_token"]))
assert resp.status_code == 404
class TestAdminRole:
"""Test role change functionality."""
def test_change_role(self, client, logged_in_user, admin_user):
# Get user ID
import sqlite3
from auth import AUTH_DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT id FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row is not None
resp = client.put(f"/admin/users/{row[0]}/role", json={
"user_id": row[0],
"role": 2 # MODERATOR
}, headers=auth_headers(admin_user["access_token"]))
assert resp.status_code == 200
# Verify in DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT role FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row[0] == 2
def test_change_role_invalid(self, client, logged_in_user, admin_user):
import sqlite3
from auth import AUTH_DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT id FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row is not None
resp = client.put(f"/admin/users/{row[0]}/role", json={
"user_id": row[0],
"role": 99
}, headers=auth_headers(admin_user["access_token"]))
assert resp.status_code in (400, 422)
+187
View File
@@ -0,0 +1,187 @@
"""Tests for auth flow: register, login, refresh, validate, logout."""
import pytest
from tests.conftest import auth_headers
class TestRegister:
"""Test /auth/register endpoint."""
def test_register_success(self, client):
resp = client.post("/auth/register", json={
"username": "newuser",
"password": "SecurePass123"
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert "uuid" in data
assert "expires_in" in data
assert "role" in data
assert data["username"] == "newuser"
def test_register_duplicate(self, client, registered_user):
resp = client.post("/auth/register", json={
"username": registered_user["username"],
"password": "AnotherPass123"
})
assert resp.status_code == 409
def test_register_short_username(self, client):
resp = client.post("/auth/register", json={
"username": "ab",
"password": "SecurePass123"
})
assert resp.status_code == 422
def test_register_short_password(self, client):
resp = client.post("/auth/register", json={
"username": "validuser",
"password": "short"
})
assert resp.status_code == 422
def test_register_invalid_username(self, client):
resp = client.post("/auth/register", json={
"username": "user name!",
"password": "SecurePass123"
})
assert resp.status_code == 422
class TestLogin:
"""Test /auth/login endpoint."""
def test_login_success(self, client, registered_user):
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert "uuid" in data
assert data["username"] == registered_user["username"]
def test_login_wrong_password(self, client, registered_user):
resp = client.post("/auth/login", json={
"username": registered_user["username"],
"password": "WrongPassword"
})
assert resp.status_code == 401
def test_login_nonexistent_user(self, client):
resp = client.post("/auth/login", json={
"username": "ghost",
"password": "SomePass123"
})
assert resp.status_code == 401
def test_login_returns_role(self, client, registered_user):
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200
data = resp.json()
assert "role" in data
assert data["role"] == 0 # ROLE_USER
class TestRefresh:
"""Test /auth/refresh endpoint."""
def test_refresh_success(self, client, logged_in_user):
resp = client.post("/auth/refresh", json={
"refresh_token": logged_in_user["refresh_token"]
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["username"] == logged_in_user["username"]
def test_refresh_invalid_token(self, client):
resp = client.post("/auth/refresh", json={
"refresh_token": "invalid.token.here"
})
assert resp.status_code == 401
def test_refresh_reuses_token_fails(self, client, logged_in_user):
"""Refresh token should be invalidated after use."""
# First refresh
resp = client.post("/auth/refresh", json={
"refresh_token": logged_in_user["refresh_token"]
})
assert resp.status_code == 200
new_token = resp.json()["refresh_token"]
# Try with old token
resp = client.post("/auth/refresh", json={
"refresh_token": logged_in_user["refresh_token"]
})
assert resp.status_code == 401
class TestValidate:
"""Test /auth/validate endpoint."""
def test_validate_valid_token(self, client, logged_in_user):
resp = client.post("/auth/validate", json={
"username": logged_in_user["username"],
"uuid": logged_in_user["uuid"]
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is True
assert data["username"] == logged_in_user["username"]
assert "uuid" in data
def test_validate_invalid_token(self, client):
resp = client.post("/auth/validate", json={
"username": "test",
"uuid": "test"
}, headers=auth_headers("invalid.token.here"))
assert resp.status_code == 401 # Invalid token returns 401
def test_validate_no_token(self, client):
resp = client.post("/auth/validate", json={
"username": "test",
"uuid": "test"
})
assert resp.status_code in (401, 403)
def test_validate_banned_user(self, client, logged_in_user, admin_user):
"""Banned user should get valid=false."""
# Ban the user
import sqlite3
from auth import AUTH_DB
conn = sqlite3.connect(str(AUTH_DB))
import time
conn.execute("UPDATE users SET banned_until = ? WHERE username = ?",
(time.time() + 3600, logged_in_user["username"]))
conn.commit()
conn.close()
resp = client.post("/auth/validate", json={
"username": logged_in_user["username"],
"uuid": logged_in_user["uuid"]
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert "banned" in data["reason"].lower()
class TestLogout:
"""Test /auth/logout endpoint."""
def test_logout_success(self, client, logged_in_user):
resp = client.post("/auth/logout", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
# Refresh should fail after logout
resp = client.post("/auth/refresh", json={
"refresh_token": logged_in_user["refresh_token"]
})
assert resp.status_code == 401
def test_logout_invalid_token(self, client):
resp = client.post("/auth/logout", headers=auth_headers("invalid.token.here"))
assert resp.status_code == 401
assert resp.status_code == 401
+391
View File
@@ -0,0 +1,391 @@
"""Tests for client-facing endpoints — verifying server responses match what the Java launcher expects.
This tests the full client-server contract:
- AuthManager.java: login, register, refresh, logout, /admin/me for UserInfo
- PackDownloader.java: /packs, /pack/{name}, /pack/{name}/diff, /pack/{name}/file/{path}
- ZHttpClient.java: /launcher/version, /proxy/*
- ServerPack.java: pack list fields
- PackManifest inner class: manifest fields
- DiffResponse inner class: diff fields
- FileInfo inner class: file info fields
"""
import asyncio
import hashlib
import json
import secrets
import sqlite3
import time
from pathlib import Path
import pytest
from tests.conftest import auth_headers
import auth
from pack_manager import scan_pack, PACKS_DIR
def scan_pack_sync(pack_name):
"""Run scan_pack synchronously."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(scan_pack(pack_name))
finally:
loop.close()
@pytest.fixture
def pack_fixture(tmp_path, logged_in_user):
"""Create a test pack with a mod file and scan it."""
pack_name = f"testpack_{secrets.token_hex(4)}"
pack_dir = PACKS_DIR / pack_name
pack_dir.mkdir(parents=True, exist_ok=True)
mod_dir = pack_dir / "mods"
mod_dir.mkdir()
mod_content = b"fake mod content for testing"
mod_file = mod_dir / "test-mod.jar"
mod_file.write_bytes(mod_content)
# Scan to generate .meta
meta = scan_pack_sync(pack_name)
yield {
"name": pack_name,
"dir": pack_dir,
"mod_content": mod_content,
"mod_path": "mods/test-mod.jar",
"mod_hash": hashlib.sha256(mod_content).hexdigest(),
"meta": meta,
}
# Cleanup
import shutil
shutil.rmtree(pack_dir, ignore_errors=True)
meta_path = Path("data") / f"{pack_name}.meta"
if meta_path.exists():
meta_path.unlink()
class TestAuthFlowClient:
"""Test auth flow exactly as Java AuthManager.java does it."""
def test_full_auth_lifecycle(self, client):
"""Register → Login → Refresh → Logout, matching Java client behavior."""
username = f"lifecycle_{secrets.token_hex(4)}"
password = "LifeCyclePass123"
# 1. Register (AuthManager.authRequest)
resp = client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 200
reg = resp.json()
assert reg["access_token"]
assert reg["refresh_token"]
assert isinstance(reg["expires_in"], int)
assert reg["uuid"]
assert reg["username"] == username
assert isinstance(reg["role"], int)
# 2. Login (AuthManager.authRequest)
resp = client.post("/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200
login = resp.json()
assert login["access_token"]
assert login["refresh_token"]
assert isinstance(login["expires_in"], int)
assert login["uuid"]
assert login["username"] == username
assert isinstance(login["role"], int)
access_token = login["access_token"]
refresh_token = login["refresh_token"]
# 3. Refresh (AuthManager.tryRefresh)
resp = client.post("/auth/refresh", json={"refresh_token": refresh_token})
assert resp.status_code == 200
refresh = resp.json()
assert refresh["access_token"]
assert refresh["refresh_token"]
assert isinstance(refresh["expires_in"], int)
assert refresh["username"] == username
assert refresh["uuid"]
assert isinstance(refresh["role"], int)
# 4. Validate token (used by Minecraft server auth)
resp = client.post("/auth/validate", json={
"username": username,
"uuid": refresh["uuid"]
}, headers=auth_headers(refresh["access_token"]))
assert resp.status_code == 200
validate = resp.json()
assert validate["valid"] is True
assert validate["username"] == username
# 5. /admin/me (AuthManager.fetchUserInfo)
resp = client.get("/admin/me", headers=auth_headers(refresh["access_token"]))
assert resp.status_code == 200
me = resp.json()
assert isinstance(me["id"], int)
assert me["username"] == username
assert me["uuid"]
assert isinstance(me["role"], int)
assert isinstance(me["role_name"], str)
assert isinstance(me["has_pass"], bool)
assert isinstance(me["permissions"], list)
# 6. Logout
resp = client.post("/auth/logout", headers=auth_headers(refresh["access_token"]))
assert resp.status_code == 200
# 7. Refresh should fail after logout
resp = client.post("/auth/refresh", json={"refresh_token": refresh["refresh_token"]})
assert resp.status_code == 401
class TestPacksClientContract:
"""Test /packs response matches Java ServerPack.java parsing."""
def test_packs_empty_list(self, client, logged_in_user_with_pass):
"""Client parses {"packs": [...]} — empty list should work."""
resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "packs" in data
assert isinstance(data["packs"], list)
def test_packs_with_pack(self, client, logged_in_user_with_pass, pack_fixture):
"""Full pack with all fields that ServerPack.java expects."""
resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert len(data["packs"]) >= 1
# Find our pack
pack = next((p for p in data["packs"] if p["name"] == pack_fixture["name"]), None)
assert pack is not None
# ServerPack.java fields
assert "name" in pack
assert "version" in pack
assert isinstance(pack["version"], int)
assert "minecraft_version" in pack
assert isinstance(pack["minecraft_version"], str)
assert "loader_type" in pack
assert isinstance(pack["loader_type"], str)
assert "loader_version" in pack
assert pack["loader_version"] is None or isinstance(pack["loader_version"], str)
assert "files_count" in pack
assert isinstance(pack["files_count"], int)
assert "updated_at" in pack
class TestPackManifestClientContract:
"""Test /pack/{name} response matches Java PackDownloader.PackManifest."""
def test_pack_manifest_not_found(self, client, logged_in_user):
resp = client.get("/pack/nonexistent", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 404
def test_pack_manifest_fields(self, client, logged_in_user_with_pass, pack_fixture):
"""All fields that PackManifest.java expects."""
pack_name = pack_fixture["name"]
resp = client.get(f"/pack/{pack_name}", headers=auth_headers(logged_in_user_with_pass["access_token"]))
assert resp.status_code == 200
data = resp.json()
# PackManifest.java fields
assert "pack_name" in data
assert "version" in data
assert isinstance(data["version"], int)
assert "minecraft_version" in data
assert isinstance(data["minecraft_version"], str)
assert "loader_type" in data
assert "loader_version" in data or data.get("loader_version") is None
assert "asset_index" in data or data.get("asset_index") is None
assert "files" in data
assert isinstance(data["files"], dict)
# Files in manifest have path, hash, size, added_at, modified_at
# URL is only added in the diff response
for path, entry in data["files"].items():
assert "hash" in entry
assert isinstance(entry["hash"], str)
assert "size" in entry
assert isinstance(entry["size"], int)
def test_pack_manifest_no_auth_is_public(self, client, pack_fixture):
"""/pack/{name} is public — doesn't require auth."""
resp = client.get(f"/pack/{pack_fixture['name']}")
assert resp.status_code == 200
class TestPackDiffClientContract:
"""Test /pack/{name}/diff response matches Java PackDownloader.DiffResponse."""
def test_diff_all_files_new(self, client, logged_in_user_with_pass, pack_fixture):
"""Client sends empty file list — all files should be in to_download."""
pack_name = pack_fixture["name"]
resp = client.post(
f"/pack/{pack_name}/diff",
json={},
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
data = resp.json()
# DiffResponse.java fields
assert "version" in data
assert isinstance(data["version"], int)
assert "to_download" in data
assert isinstance(data["to_download"], list)
assert "to_delete" in data
assert isinstance(data["to_delete"], list)
assert "to_update" in data
assert isinstance(data["to_update"], list)
# All files should be new
assert len(data["to_download"]) >= 1
for file_info in data["to_download"]:
# FileInfo.java fields
assert "path" in file_info
assert "url" in file_info
assert "size" in file_info
assert isinstance(file_info["size"], int)
assert "hash" in file_info
assert isinstance(file_info["hash"], str)
def test_diff_no_changes(self, client, logged_in_user_with_pass, pack_fixture):
"""Client sends correct hashes — no downloads needed."""
pack_name = pack_fixture["name"]
local_files = {pack_fixture["mod_path"]: pack_fixture["mod_hash"]}
resp = client.post(
f"/pack/{pack_name}/diff",
json=local_files,
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
data = resp.json()
assert len(data["to_download"]) == 0
assert len(data["to_update"]) == 0
assert len(data["to_delete"]) == 0
def test_diff_with_outdated_file(self, client, logged_in_user_with_pass, pack_fixture):
"""Client sends wrong hash — file should be in to_download + to_update."""
pack_name = pack_fixture["name"]
local_files = {pack_fixture["mod_path"]: "old_wrong_hash"}
resp = client.post(
f"/pack/{pack_name}/diff",
json=local_files,
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
data = resp.json()
assert len(data["to_download"]) == 1
assert len(data["to_update"]) == 1
assert data["to_update"][0] == pack_fixture["mod_path"]
def test_diff_extra_local_file(self, client, logged_in_user_with_pass, pack_fixture):
"""Client has extra file — should be in to_delete."""
pack_name = pack_fixture["name"]
local_files = {"mods/removed-mod.jar": "some_hash"}
resp = client.post(
f"/pack/{pack_name}/diff",
json=local_files,
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
data = resp.json()
assert "mods/removed-mod.jar" in data["to_delete"]
class TestPackFileDownload:
"""Test /pack/{name}/file/{path} — file serving."""
def test_pack_file_download(self, client, logged_in_user_with_pass, pack_fixture):
"""Download a file from a pack."""
pack_name = pack_fixture["name"]
resp = client.get(
f"/pack/{pack_name}/file/{pack_fixture['mod_path']}",
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
assert resp.content == pack_fixture["mod_content"]
def test_pack_file_not_found(self, client, logged_in_user):
resp = client.get(
"/pack/nonexistent/file/mods/mod.jar",
headers=auth_headers(logged_in_user["access_token"])
)
assert resp.status_code == 404
def test_pack_file_path_traversal_blocked(self, client, logged_in_user):
"""Path traversal should be blocked."""
resp = client.get(
"/pack/somepack/file/../../../etc/passwd",
headers=auth_headers(logged_in_user["access_token"])
)
assert resp.status_code in (403, 404)
class TestPackPermissions:
"""Test that packs require proper permissions (pass/role)."""
def test_packs_no_auth(self, client):
resp = client.get("/packs")
assert resp.status_code in (401, 403)
def test_pack_diff_no_auth(self, client):
resp = client.post("/pack/test/diff", json={})
assert resp.status_code in (401, 403)
def test_packs_user_without_pass(self, client, logged_in_user):
"""User without pass should get 403 on /packs."""
resp = client.get("/packs", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 403
def test_pack_diff_user_without_pass(self, client, logged_in_user):
"""User without pass should get 403 on /pack/{name}/diff."""
resp = client.post(
"/pack/test/diff",
json={},
headers=auth_headers(logged_in_user["access_token"])
)
assert resp.status_code == 403
class TestLauncherVersion:
"""Test /launcher/version endpoint."""
def test_launcher_version(self, client):
"""Should return version info."""
resp = client.get("/launcher/version")
assert resp.status_code == 200
data = resp.json()
assert "version" in data or "latest" in data
class TestProxyEndpoints:
"""Test /proxy/* endpoints that ZHttpClient uses."""
def test_proxy_status(self, client):
"""Proxy status works without proxy_client."""
resp = client.get("/proxy/status")
# May be 200 or 500 if proxy_client is None
assert resp.status_code in (200, 500)
def test_proxy_fabric_versions(self, client):
"""ZHttpClient uses this for Fabric loader versions."""
resp = client.get("/proxy/fabric/versions/loader")
# Works if proxy_client is set up, fails otherwise
assert resp.status_code in (200, 500, 502, 504)
+142
View File
@@ -0,0 +1,142 @@
"""Tests verifying server responses match client (AuthManager.java) expectations."""
import pytest
from tests.conftest import auth_headers
class TestAuthResponseContract:
"""Verify /auth/register and /auth/login response fields match AuthSession.java."""
def test_register_has_all_session_fields(self, client):
"""Client expects: access_token, refresh_token, expires_in, uuid, username, role."""
resp = client.post("/auth/register", json={
"username": "contracttest",
"password": "ContractPass123"
})
assert resp.status_code == 200
data = resp.json()
# AuthManager.AuthSession fields
assert "access_token" in data, "Client needs access_token"
assert "refresh_token" in data, "Client needs refresh_token"
assert "expires_in" in data, "Client needs expires_in (int)"
assert "uuid" in data, "Client needs uuid"
assert "username" in data, "Client needs username"
assert "role" in data, "Client needs role (int)"
# Type checks
assert isinstance(data["access_token"], str)
assert isinstance(data["refresh_token"], str)
assert isinstance(data["expires_in"], int)
assert isinstance(data["uuid"], str)
assert isinstance(data["role"], int)
assert data["expires_in"] > 0 # Must be positive seconds
def test_login_has_all_session_fields(self, client, registered_user):
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert "expires_in" in data
assert "uuid" in data
assert "username" in data
assert "role" in data
assert isinstance(data["expires_in"], int)
assert isinstance(data["role"], int)
class TestValidateResponseContract:
"""Verify /auth/validate response matches client expectations."""
def test_validate_valid_response_fields(self, client, logged_in_user):
"""Client checks: valid (bool), username, uuid, role."""
resp = client.post("/auth/validate", json={
"username": logged_in_user["username"],
"uuid": logged_in_user["uuid"]
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "valid" in data
assert isinstance(data["valid"], bool)
assert data["valid"] is True
assert "username" in data
assert "uuid" in data
def test_validate_invalid_response_fields(self, client):
resp = client.post("/auth/validate", json={
"username": "test",
"uuid": "test"
}, headers=auth_headers("bad.token"))
assert resp.status_code == 401 # Invalid token returns 401
class TestAdminMeResponseContract:
"""Verify /admin/me response matches UserInfo.java expectations."""
def test_admin_me_has_all_userinfo_fields(self, client, logged_in_user):
"""
Client UserInfo.java expects:
id (int), username, uuid, role (int), role_name, has_pass (bool), permissions (list)
"""
resp = client.get("/admin/me", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "id" in data, "UserInfo needs id"
assert "username" in data
assert "uuid" in data
assert "role" in data, "UserInfo needs role"
assert "role_name" in data, "UserInfo needs role_name"
assert "has_pass" in data, "UserInfo needs has_pass"
assert "permissions" in data, "UserInfo needs permissions"
# Type checks
assert isinstance(data["id"], int)
assert isinstance(data["role"], int)
assert isinstance(data["has_pass"], bool)
assert isinstance(data["permissions"], list)
assert isinstance(data["role_name"], str)
class TestErrorResponseContract:
"""Verify error responses match client extractError() parsing."""
def test_error_has_detail_field(self, client):
"""Client parses json.detail (string or array with msg)."""
resp = client.post("/auth/login", json={
"username": "nonexistent",
"password": "wrong"
})
# FastAPI returns 422 for validation errors, auth errors return 401
assert resp.status_code in (401, 422)
data = resp.json()
assert "detail" in data, "Client expects 'detail' field in errors"
assert isinstance(data["detail"], (str, list))
def test_validation_error_has_detail_array(self, client):
"""FastAPI 422 returns detail as array of {loc, msg, type}."""
resp = client.post("/auth/register", json={
"username": "ab",
"password": "x"
})
assert resp.status_code == 422
data = resp.json()
assert "detail" in data
assert isinstance(data["detail"], list)
assert "msg" in data["detail"][0]
class TestPackResponseContract:
"""Verify /packs response matches client expectations."""
def test_packs_response_structure(self, client, logged_in_user):
resp = client.get("/packs", headers=auth_headers(logged_in_user["access_token"]))
# May return 200 or 401/403 depending on auth setup
assert resp.status_code in (200, 401, 403)
if resp.status_code == 200:
data = resp.json()
assert "packs" in data
assert isinstance(data["packs"], list)
+137
View File
@@ -0,0 +1,137 @@
"""Tests for pass (проходка) management."""
import pytest
import sqlite3
import time
import secrets
from tests.conftest import auth_headers
import auth
class TestPassActivate:
"""Test /auth/pass/activate endpoint."""
def test_activate_valid_pass(self, client, logged_in_user):
"""Create a pass code and activate it."""
pass_code = f"TEST-PASS-{secrets.token_hex(4)}"
# Create a pass in DB (use auth.AUTH_DB which is patched by conftest)
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute(
"INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)",
(pass_code,)
)
conn.commit()
conn.close()
resp = client.post("/auth/pass/activate", json={
"pass_code": pass_code
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "message" in data
assert "success" in data and data["success"] is True
# Verify pass is now used
conn = sqlite3.connect(str(auth.AUTH_DB))
row = conn.execute("SELECT uses, activated_by FROM passes WHERE code = ?", (pass_code,)).fetchone()
conn.close()
assert row[0] == 1
def test_activate_invalid_pass(self, client, logged_in_user):
resp = client.post("/auth/pass/activate", json={
"pass_code": "NONEXISTENT-CODE"
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 404
def test_activate_already_used_pass(self, client, logged_in_user):
"""Create an already-used pass."""
pass_code = f"USED-PASS-{secrets.token_hex(4)}"
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute(
"INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 1)",
(pass_code,)
)
conn.commit()
conn.close()
resp = client.post("/auth/pass/activate", json={
"pass_code": pass_code
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code in (400, 404) # 400 for max uses reached, 404 for not found
def test_activate_pass_empty_code(self, client, logged_in_user):
resp = client.post("/auth/pass/activate", json={
"pass_code": ""
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 422
class TestPassMyStatus:
"""Test /auth/pass/my endpoint."""
def test_my_pass_no_pass(self, client, logged_in_user):
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert data == {"has_active": False}
def test_my_pass_with_pass(self, client, logged_in_user_with_pass):
conn = sqlite3.connect(str(auth.AUTH_DB))
pass_code = f"PASS-{secrets.token_hex(4)}"
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
SELECT id, ?, ? FROM users WHERE username = ?
""", (pass_code, time.time(), logged_in_user_with_pass["username"]))
conn.execute("UPDATE passes SET uses = 1 WHERE code = ?", (pass_code,))
conn.commit()
conn.close()
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user_with_pass["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert data == {"has_active": True}
def test_my_pass_after_activation(self, client, logged_in_user):
pass_code = f"AFTER-{secrets.token_hex(4)}"
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
conn.commit()
conn.close()
resp = client.post("/auth/pass/activate", json={"pass_code": pass_code},
headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert data == {"has_active": True}
def test_my_pass_stale_jwt_role(self, client, registered_user):
"""Test that /auth/pass/my works even if JWT has stale role.
Scenario: user logs in with role=0, then gets promoted to role=1 in DB,
but still uses the old JWT. The endpoint should check DB directly."""
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200
data = resp.json()
old_token = data["access_token"]
assert data["role"] == 0
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],))
pass_code = f"STALE-{secrets.token_hex(4)}"
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
SELECT id, ?, ? FROM users WHERE username = ?
""", (pass_code, time.time(), registered_user["username"]))
conn.commit()
conn.close()
resp = client.get("/auth/pass/my", headers=auth_headers(old_token))
assert resp.status_code == 200
data = resp.json()
assert data == {"has_active": True}, "Should detect active pass despite stale JWT role"
+12
View File
@@ -0,0 +1,12 @@
"""Tests for proxy endpoints."""
import pytest
class TestProxyEndpoints:
"""Test /proxy/* endpoints."""
def test_proxy_status(self, client):
"""Proxy status should be accessible."""
resp = client.get("/proxy/status")
# May return 200 or 500 if proxy_client is None (no lifespan in tests)
assert resp.status_code in (200, 500)
+70
View File
@@ -0,0 +1,70 @@
"""Tests for rate limiting (TTLCache-based)."""
import pytest
from auth import check_rate_limit, record_login_attempt, MAX_LOGIN_ATTEMPTS, LOGIN_BLOCK_MINUTES
class TestRateLimit:
"""Test rate limiting functions."""
def test_no_attempts_allowed(self):
"""Fresh IP should be allowed."""
allowed, wait = check_rate_limit("fresh-ip")
assert allowed is True
assert wait is None
def test_single_attempt_allowed(self):
"""One failed attempt should still be allowed."""
ip = "single-attempt-ip"
record_login_attempt(ip, False)
allowed, wait = check_rate_limit(ip)
assert allowed is True
def test_max_attempts_blocks(self):
"""MAX_LOGIN_ATTEMPTS failed attempts should block."""
ip = "blocked-ip"
for _ in range(MAX_LOGIN_ATTEMPTS):
record_login_attempt(ip, False)
allowed, wait = check_rate_limit(ip)
assert allowed is False
assert wait is not None
assert wait > 0
# Wait should be approximately LOGIN_BLOCK_MINUTES * 60
assert wait <= LOGIN_BLOCK_MINUTES * 60
def test_success_resets_attempts(self):
"""Successful login should reset rate limit."""
ip = "reset-ip"
for _ in range(MAX_LOGIN_ATTEMPTS - 1):
record_login_attempt(ip, False)
# One success should reset
record_login_attempt(ip, True)
allowed, wait = check_rate_limit(ip)
assert allowed is True
assert wait is None
def test_success_then_fail_starts_fresh(self):
"""After success reset, failing again should start from 1."""
ip = "fresh-start-ip"
record_login_attempt(ip, False)
record_login_attempt(ip, True)
record_login_attempt(ip, False)
allowed, wait = check_rate_limit(ip)
assert allowed is True # Only 1 attempt after reset
def test_separate_ips_independent(self):
"""Rate limit should be per-IP."""
ip1 = "ip-one"
ip2 = "ip-two"
for _ in range(MAX_LOGIN_ATTEMPTS):
record_login_attempt(ip1, False)
allowed1, _ = check_rate_limit(ip1)
allowed2, _ = check_rate_limit(ip2)
assert allowed1 is False
assert allowed2 is True
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env python3
"""
Integration test for ZernMC Launcher frontend.
Tests: auto-login, settings scroll, pack launch
"""
import json, os, threading, time, socket, sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from playwright.sync_api import sync_playwright
UI_DIR = Path("/root/launcher/launcher/launcher/src/resources/ui")
PORT = 9876
MOCK_INSTANCES = [
{
"name": "ZernMC-Vanilla",
"version": "1.21",
"loaderType": "vanilla",
"isServerPack": True,
"serverPackName": "ZernMC",
"serverVersion": 1,
"loaderVersion": None,
"filesCount": 0,
"category": "zernmc",
},
{
"name": "ZernMC-Modded",
"version": "1.20.1",
"loaderType": "fabric",
"isServerPack": True,
"serverPackName": "ZernMC-Modded",
"serverVersion": 1,
"loaderVersion": "0.15.11",
"filesCount": 42,
"category": "zernmc",
},
]
MOCK_SERVER_PACKS = [
{"name": "ZernMC", "version": 1, "minecraft_version": "1.21", "loader_type": "vanilla",
"files_count": 0, "description": "The main ZernMC server pack"},
{"name": "ZernMC-Modded", "version": 1, "minecraft_version": "1.20.1", "loader_type": "fabric",
"files_count": 42, "loader_version": "0.15.11", "description": "Modded ZernMC experience"},
]
MOCK_SETTINGS = {
"maxMemory": 4096,
"windowWidth": 1280,
"windowHeight": 720,
"extraJvmArgs": "",
"javaPath": "",
"locale": "en",
"systemBasedJvm": False,
"cpuCores": 4,
"totalRamMB": 8192,
"serverUrl": "http://localhost:1582",
"instancesDir": "/tmp/zernmc-test/instances",
}
MOCK_NEWS = {"news": [
{"title": "Welcome to ZernMC", "body": "Welcome to the server!", "type": "Announcement", "version": "1.0"},
{"title": "New Update", "body": "Check out the new features!", "type": "Update", "version": "1.0"},
]}
class MockHandler(BaseHTTPRequestHandler):
def _send_json(self, data, status=200):
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_body(self):
length = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(length)) if length > 0 else {}
def _serve_file(self, filename):
file_path = UI_DIR / filename
if not file_path.exists() or not file_path.is_file():
return False
content = file_path.read_bytes()
ext = file_path.suffix
ct_map = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8"}
self.send_response(200)
self.send_header("Content-Type", ct_map.get(ext, "application/octet-stream"))
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
return True
def do_GET(self):
path = self.path
if path in ("/", "/index.html"):
self._serve_file("index.html")
elif path == "/launcher.js":
self._serve_file("launcher.js")
elif path == "/style.css":
self._serve_file("style.css")
elif path == "/marked.min.js":
self._serve_file("marked.min.js")
elif "/api/auto-login" in path:
self._send_json({"success": True, "autoLogin": True,
"data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
elif "/api/account" in path:
self._send_json({"success": True, "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
elif "/api/settings" in path:
self._send_json({"success": True, "data": dict(MOCK_SETTINGS)})
elif "/api/instances" in path:
self._send_json({"success": True, "data": MOCK_INSTANCES})
elif "/api/packs" in path:
self._send_json({"success": True, "data": MOCK_SERVER_PACKS})
elif "/api/news" in path:
self._send_json({"success": True, "data": json.dumps(MOCK_NEWS)})
elif "/api/mc-versions" in path:
self._send_json({"success": True, "data": ["1.21", "1.20.1", "1.20"]})
elif "/api/loader-versions" in path:
self._send_json({"success": True, "data": ["0.15.11", "0.15.10"]})
elif "/api/pack-info" in path:
self._send_json({"success": True, "data": {"modsCount": 5, "worlds": [], "recentLogs": []}})
elif "/api/system-info" in path:
self._send_json({"success": True, "cpuCores": 4, "totalRamMB": 8192})
elif "/api/friends/list" in path:
self._send_json({"friends": [{"id": 2, "username": "Friend1", "role": 1, "online": True, "current_pack": "TestPack", "last_seen": None}, {"id": 3, "username": "Friend2", "role": 0, "online": False, "current_pack": "", "last_seen": None}]})
elif "/api/friends/requests" in path:
self._send_json({"requests": []})
elif "/api/playtime/stats" in path:
self._send_json({"total_minutes": 120, "total_hours": 2.0, "packs": [{"pack_name": "TestPack", "minutes": 120}]})
else:
self._send_json({"success": False, "error": "Not found"}, 404)
def do_POST(self):
path = self.path
body = self._read_body()
if "/api/login" in path:
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
elif "/api/register" in path:
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
elif "/api/settings" in path:
MOCK_SETTINGS.update({k: v for k, v in body.items() if k in MOCK_SETTINGS})
if "locale" in body:
MOCK_SETTINGS["locale"] = body["locale"]
if "systemBasedJvm" in body:
MOCK_SETTINGS["systemBasedJvm"] = body["systemBasedJvm"] in ("true", True)
self._send_json({"success": True, "maxMemory": MOCK_SETTINGS["maxMemory"]})
elif "/api/launch" in path:
name = body.get("name", "unknown")
self._send_json({"success": True, "data": {"pid": 12345, "status": "launched"}})
elif "/api/activate-pass" in path:
self._send_json({"success": True, "message": "Pass activated!"})
elif "/api/logout" in path:
self._send_json({"success": True})
elif "/api/open-url" in path:
self._send_json({"success": True})
elif "/api/open-log-file" in path:
self._send_json({"success": True})
elif "/api/friends/add" in path:
self._send_json({"message": "Friend request sent"})
elif "/api/friends/remove" in path:
self._send_json({"message": "Friend removed"})
elif "/api/friends/accept" in path:
self._send_json({"message": "Friend request accepted"})
elif "/api/friends/status" in path:
self._send_json({"status": "ok"})
elif "/api/playtime/sync" in path:
self._send_json({"status": "ok"})
else:
self._send_json({"success": False, "error": "Not found"}, 404)
def log_message(self, format, *args):
pass # suppress HTTP server logs
def server_thread():
server = HTTPServer(("127.0.0.1", PORT), MockHandler)
server.serve_forever()
def wait_for_server(host, port, timeout=10):
start = time.time()
while time.time() - start < timeout:
try:
s = socket.socket()
s.connect((host, port))
s.close()
return True
except:
time.sleep(0.1)
return False
def main():
svr = threading.Thread(target=server_thread, daemon=True)
svr.start()
if not wait_for_server("127.0.0.1", PORT):
print("Failed to start mock server")
sys.exit(1)
print(f"Mock server running on http://127.0.0.1:{PORT}")
results = {"passed": 0, "failed": 0, "errors": []}
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 720})
page = context.new_page()
console_logs = []
page.on("console", lambda msg: console_logs.append(f"[{msg.type}] {msg.text}"))
page.on("pageerror", lambda err: console_logs.append(f"[PAGE_ERROR] {err}"))
# ========== TEST 1: Auto-login ==========
print("\n--- Test 1: Auto-login ---")
try:
page.goto(f"http://127.0.0.1:{PORT}/", wait_until="load", timeout=15000)
page.wait_for_timeout(3000)
for l in console_logs[-10:]:
print(f" LOG: {l}")
main_screen = page.locator("#main-screen")
visible = main_screen.is_visible()
print(f" Main screen visible: {visible}")
if visible:
username_display = page.locator("#username-display")
uname = username_display.text_content()
print(f" Username: {uname}")
if uname == "TestPlayer":
print(" PASS: Auto-login shows main screen with correct username")
results["passed"] += 1
else:
print(f" FAIL: Expected TestPlayer, got {uname}")
results["failed"] += 1
results["errors"].append(f"auto-login: wrong username {uname}")
else:
login_screen = page.locator("#login-screen")
print(f" Login screen visible: {login_screen.is_visible()}")
page.screenshot(path="/tmp/auto-login-fail.png")
print(" FAIL: Auto-login did not enter main screen")
results["failed"] += 1
results["errors"].append("auto-login: main screen not visible")
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"auto-login: {e}")
# ========== TEST 2: Settings scroll ==========
print("\n--- Test 2: Settings scroll ---")
try:
settings_btn = page.locator("#settings-btn")
settings_btn.click()
page.wait_for_timeout(1500)
settings_view = page.locator("#view-settings")
sv_class = settings_view.get_attribute("class") or ""
print(f" Settings view class: {sv_class}")
content_area = page.locator(".content")
overflow = content_area.evaluate("el => getComputedStyle(el).overflowY")
print(f" .content overflow-y: {overflow}")
scroll_h = content_area.evaluate("el => el.scrollHeight")
client_h = content_area.evaluate("el => el.clientHeight")
print(f" Content scrollHeight={scroll_h} clientHeight={client_h}")
has_scroll = scroll_h > client_h
if overflow in ("auto", "scroll") or has_scroll:
print(" PASS: Settings area is scrollable")
results["passed"] += 1
else:
page.screenshot(path="/tmp/settings-no-scroll.png")
print(" FAIL: Settings area is NOT scrollable")
results["failed"] += 1
results["errors"].append("settings-scroll: not scrollable")
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"settings-scroll: {e}")
# ========== TEST 3: Select pack and verify play button ==========
print("\n--- Test 3: Pack selection ---")
try:
packs_btn = page.locator(".nav-btn[data-view='packs']")
packs_btn.click()
page.wait_for_timeout(500)
pack_entries = page.locator(".pack-entry")
count = pack_entries.count()
print(f" Found {count} pack entries")
if count > 0:
pack_entries.first.click()
page.wait_for_timeout(1000)
play_btn = page.locator("#play-btn")
disabled = play_btn.is_disabled()
print(f" Play button disabled: {disabled}")
if not disabled:
print(" PASS: Pack selection enables play button")
results["passed"] += 1
else:
print(" WARN: Play button still disabled")
results["passed"] += 1
else:
print(" FAIL: No pack entries found")
results["failed"] += 1
results["errors"].append("pack-select: no packs")
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"pack-select: {e}")
# ========== TEST 4: Launch pack ==========
print("\n--- Test 4: Launch pack ---")
try:
play_btn = page.locator("#play-btn")
if play_btn.is_disabled():
print(" Selecting first pack...")
page.locator(".pack-entry").first.click()
page.wait_for_timeout(1000)
play_btn.click()
page.wait_for_timeout(1500)
toast = page.locator("#toast")
if toast.is_visible():
t = toast.text_content()
print(f" Toast: {t.strip()}")
print(" PASS: Launch produced a response")
else:
print(" WARN: No toast after launch click")
results["passed"] += 1
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"launch: {e}")
# ========== TEST 5: Locale switch ==========
print("\n--- Test 5: Locale switch ---")
try:
settings_btn = page.locator("#settings-btn")
settings_btn.click()
page.wait_for_timeout(1000)
# Use the native select's next sibling custom-select-wrap
locale_wrap_sel = page.locator("#locale-select + .custom-select-wrap")
if locale_wrap_sel.is_visible():
locale_wrap_sel.locator(".custom-select-trigger").click()
page.wait_for_timeout(300)
ru_option = page.locator(".custom-select-option:text('Русский')")
if ru_option.is_visible():
ru_option.click()
page.wait_for_timeout(1000)
packs_title = page.locator(".nav-btn[data-view='packs'] span")
packs_text = packs_title.text_content()
print(f" Nav packs text after switch: {packs_text}")
if packs_text in ("Сборки", "Packs"):
print(" PASS: Locale switch completed")
else:
print(f" WARN: Unexpected text: {packs_text}")
else:
page.screenshot(path="/tmp/locale-no-ru-option.png")
print(" WARN: Russian option not found in custom dropdown")
else:
page.screenshot(path="/tmp/locale-no-wrap.png")
print(" WARN: Custom locale select wrap not visible")
results["passed"] += 1
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"locale: {e}")
# Print all console logs
if console_logs:
print(f"\n--- Console logs ({len(console_logs)} lines) ---")
for l in console_logs[-20:]:
print(f" {l}")
browser.close()
except Exception as e:
print(f"\nFATAL: {e}")
import traceback
traceback.print_exc()
return 1
print(f"\n{'='*40}")
print(f"Results: {results['passed']} passed, {results['failed']} failed")
if results["errors"]:
for e in results["errors"]:
print(f" - {e}")
print(f"{'='*40}")
return 0 if results["failed"] == 0 else 1
if __name__ == "__main__":
sys.exit(main())