107 Commits

Author SHA1 Message Date
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
Sashegdev a3f9871d6e СУКА ЛАСТ ФИКСЫ ДЛЯ ПРОХОДОК (логин работает) 2026-04-08 20:22:47 +00:00
Sashegdev 2b6cb6b3ad ДА БЛЯ Я ЗАБЕАЛСЯ ФИКСИТЬ ПОМОГИТЕ Я КОНЧЕННЫЫЫЙ 2026-04-08 20:16:51 +00:00
Sashegdev cca6ef3eca SuperMinor Fixes (надеюсь последние для аккаунтов) 2026-04-08 20:04:52 +00:00
Sashegdev 8733e359e6 Minor fixes(важные блять) 2026-04-08 20:02:09 +00:00
Sashegdev 89c0057759 Server Fixes 2026-04-08 19:56:38 +00:00
Sashegdev bf26baaf93 1.0.7 типоооо и фиксы 2026-04-08 19:45:15 +00:00
Sashegdev 13a43a01ef utf-8 рефактор чутка 2026-04-07 19:00:42 +00:00
Sashegdev 296f564b39 небольшой рефактор 2026-04-07 18:54:16 +00:00
Sashegdev de703a4ddd ВАЖНИ ФИКСЕС 2026-04-07 18:40:51 +00:00
Sashegdev a501329956 Изменил версию и немного фиксов 2026-04-07 18:28:26 +00:00
Sashegdev 5516aeb12f Readme модификейшин 2026-04-07 18:08:11 +00:00
Sashegdev 7b48ae2ab6 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-07 18:01:49 +00:00
Sashegdev 9bee361ea4 Попытка добавления проходок, аккаунтов, а так же доработка прокси 2026-04-07 17:50:29 +00:00
Sashegdev 8cbad9af96 Update README.md 2026-04-07 18:11:54 +03:00
Sashegdev 33cb7830e3 Орфографическая правка 2026-04-07 12:24:06 +03:00
Sashegdev 920330bbab REAMDE.md update, мяу 2026-04-07 11:17:36 +03:00
Sashegdev c03d7a788f ДОБАВЛЕНИЕ ПРОКСИ РЕЖИМА ЙОООУ 1.0.5 2026-04-06 19:57:32 +00:00
Sashegdev b47793b618 Багфиксы ClassPath 2026-04-06 18:17:07 +00:00
Sashegdev 94968e8e77 Smol Fixes Yoooo 2026-04-06 17:00:39 +00:00
Sashegdev 0b4af1353d Both | БЛЯЯЯ ЗАГРУЗКА ПАКОВ С СЕРВЕРА СЮДААА 2026-04-06 00:32:36 +00:00
Sashegdev 4edbe7e910 Server BugFixes + убрал генерацию sevrer команды т.к это уже в клиенте лол 2026-04-05 22:25:43 +00:00
Sashegdev 3d8313f7d2 Немного рефактора 2026-04-05 18:45:31 +00:00
Sashegdev e21fd922ab Попытка заставить работать Forge 2026-04-05 16:18:39 +00:00
Sashegdev b29222af68 ФАБРИК ПОДДЕРЖКАААААААА 2026-04-05 15:43:16 +00:00
Sashegdev ac3ce1800f Починил загрузку ассетов, добавлена оптимизация
запуск Vanilla версий работает
2026-04-05 14:56:01 +00:00
Sashegdev 2babe53e99 uuuh почему бы и нет 2026-04-05 10:47:28 +00:00
Sashegdev 369f8c2f9d Изменение получения self-version 2026-04-05 10:46:32 +00:00
Sashegdev c0a85658f4 КЛИЕНТ ЛАУНЧЕРА ЙОООО 2026-04-05 00:18:57 +00:00
Sashegdev 7568e34d91 перенос readme т.к я криворукий 2026-04-04 14:59:00 +00:00
Sashegdev 7670edbff7 server update 2026-04-04 14:57:15 +00:00
Sashegdev cf4a5c74e5 test 2026-04-04 14:55:18 +00:00
Sashegdev 1a5336a996 first commit 2026-04-04 14:49:24 +00:00
94 changed files with 2070 additions and 18484 deletions
-9
View File
@@ -10,14 +10,5 @@ server/data
jre jre
.vscode .vscode
dependency-reduced-pom.xml dependency-reduced-pom.xml
.flattened-pom.xml
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
telegram-bot/ telegram-bot/
builds/
server/news/
data/
packs/
.__pycache__
.pytest_cache
.venvtodo.txt
*.bak
+38 -63
View File
@@ -8,50 +8,32 @@
- Написан полностью с нуля, без использования готовых решений - Написан полностью с нуля, без использования готовых решений
- Удобная загрузка и обновление сборок прямо с сервера ZernMC - Удобная загрузка и обновление сборок прямо с сервера ZernMC
- Чистый и относительно читаемый код как клиента, так и сервера - Чистый и относительно читаемый код как клиента, так и сервера
- Графический интерфейс на **JavaFX** (по умолчанию) + **TUI** (режим `--cli`) - Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.)
- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.), обход блокировок через выбор домена и SNI - Возможность запуска клиента даже без графического интерфейса (TUI-режим)
- Поддержка загрузчиков **Fabric**, **Forge**, **NeoForge** и ванильных версий (установка через встроенный JRE)
- Ротационные логи в стиле Minecraft: `latest.log` + архивы `.log.gz`
- Инкрементальное обновление JavaFX и автообновление лаунчера
- Подпись exe самоподписным сертификатом Zern.cc (jsign/Authenticode)
- Полное отсутствие рекламы и телеметрии - Полное отсутствие рекламы и телеметрии
- Не собирает никаких данных о пользователе - Не собирает никаких данных о пользователе
- Работает «из коробки»: достаточно распаковать архив и запустить `zernmc.exe` - Работает «из коробки»: достаточно распаковать архив и запустить `ZernMCLauncher.exe`
- Есть система аккаунтов и проходок - Есть система аккаунтов и проходок
## Состав дистрибутива
**Онлайн-установщик `ZernMC-Online-Setup.exe` (Go, ~5.7М, без Java):**
- Спрашивает папку установки (default `C:\ZernMC`) + чекбокс `Ярлык ZernMC Launcher.lnk на рабочем столе`
- Качает `JRE 47М``lib/jre21`, затем `meta/file` для `lib/javafx`, `bin/`, `assets/`, `zernmc.exe`
- Пишет `build.version`, создаёт `uninstall.exe` (`C:\ZernMC\uninstall.exe`), ярлык
- Локальные файлы `~/.zernmc` не трогает
**Оффлайн `ZernMC-win-<версия>.zip` (или `ZernMC-Offline-Setup.exe` — тот же выбор папки + ярлык):**
- `zernmc.exe` — GUI (JavaFX)
- `zernmc-cli.exe` — TUI
- `zernmc-cli-jfx.exe` — консоль + JavaFX
- `lib/jre21` — JRE 21 (только оффлайн)
- `lib/javafx` — JavaFX 23.0.1
Bootstrap встроен в `.exe`.
## Чего пока нет в лаунчере ## Чего пока нет в лаунчере
- Готовых пресетов оптимизации JVM (настраиваются Java и выделенная память) - Графического интерфейса (GUI) — только TUI
- Поддержки Quilt, LabyMod и других лоадеров - Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
- Поддержки **Forge** (в разработке)
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
- Раздела новостей об обновлениях Minecraft и лаунчера
- Выбора готовых пресетов оптимизации JVM
- Кастомных модов (UI, спавнеры, DPI, карточки) - Кастомных модов (UI, спавнеры, DPI, карточки)
- Сайта для лаунчера и сервера (текущий требует переделки) - Сайта для лаунчера и сервера
- Трекинга наигранного времени (обновление раз в час вместо минуты) - Трекинга наигранного времени
## Что планируется доработать в ближайшее время ## Что планируется доработать в ближайшее время
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых - **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
- **GUI мод** — привести в единый стиль с главным меню - **GUI мод** — привести в единый стиль с главным меню
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут - **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпиленной проверкой, уведомления админу в Telegram с технической информацией - **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
- **Сайт** — полноценный сайт для лаунчера и сервера - **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты - **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку) - **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков - **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
@@ -68,7 +50,7 @@ Bootstrap встроен в `.exe`.
### Управление в лаунчере (TUI) ### Управление в лаунчере (TUI)
Консольные версии (`zernmc-cli.exe`, а также режим `--cli`) используют **текстовый интерфейс (TUI)**: Лаунчер использует **текстовый интерфейс (TUI)**:
- `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню - `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
- `ENTER` — выбор пункта - `ENTER` — выбор пункта
@@ -76,35 +58,27 @@ Bootstrap встроен в `.exe`.
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре. Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
Основная версия `zernmc.exe` запускает графический интерфейс JavaFX.
### Расположение сборок ### Расположение сборок
Все установленные сборки хранятся в папке: Все установленные сборки хранятся в папке:
- **Windows**: `%USERPROFILE%\.zernmc\instances` - **Windows**: `%USERPROFILE%\.zernmc\instances`
- **Linux**: `~/.zernmc\instances` - **Linux**: `~/.zernmc/instances`
Если сборка не удаляется корректно — удалите папку вручную. Если сборка не удаляется корректно — удалите папку вручную.
### Логи
Логи лаунчера пишутся в файл `latest.log` рядом с исполняемым файлом; старые логи архивируются в `.log.gz` (в стиле Minecraft).
## Как скачать и запустить ## Как скачать и запустить
**Скачать лаунчер (выбери один):** Пока автообновления и GitHub Releases не настроены, скачать последнюю версию можно по ссылкам:
- **Онлайн-установщик (рекомендуется)** — `ZernMC-Online-Setup.exe` (~5.7М, Go, без Java): скачай → выбери папку установки (по умолчанию `C:\ZernMC`) → [x] Создать ярлык ZernMC Launcher на рабочем столе → Установить. Первый запуск докачает JRE 47М + файлы лаунчера 50М. Данные сборок `~/.zernmc` остаются в профиле пользователя. Деинсталляция — `C:\ZernMC\uninstall.exe` или удаление папки.
- **Оффлайн ZIP** — `ZernMC-win-<версия>.zip` (98М, с JRE): скачай → выбери папку куда распаковать (диалог в `ZernMC-Offline-Setup.exe` или вручную) → [x] Ярлык → готово. Для air-gapped.
**Зеркала (оба варианта):** **Скачать лаунчер:**
- https://api.zern.cc/launcher/download/jre — JRE для онлайн - https://api.zernmc.ru/launcher/download/latest
- https://api.zernmc.ru/launcher/download/latest / https://api.zernmc.online/launcher/download/latest — ZIP - https://api.zernmc.online/launcher/download/latest
**Инструкция (оба):** **Инструкция:**
1. Скачай online exe **или** offline zip 1. Скачайте zip-архив
2. Запусти установщик → выбери папку (по умолчанию `C:\ZernMC`, можно `D:\Games\ZernMC`) → ярлык 2. Распакуйте в удобную папку
3. Запусти `zernmc.exe` (или `zernmc-cli.exe`) из папки установки 3. Запустите `ZernMCLauncher.exe`
4. Войди в аккаунт и нажми **«Начать игру»** 4. Нажмите **«Начать игру»**
### Установка первой сборки ### Установка первой сборки
@@ -112,31 +86,28 @@ Bootstrap встроен в `.exe`.
1. Выберите **«Установить новую сборку»** 1. Выберите **«Установить новую сборку»**
2. Выберите тип сборки: 2. Выберите тип сборки:
- **ZernMC** — готовые сборки с нашего сервера - **ZernMC** — готовые сборки с нашего сервера
- **Модовый загрузчик** — Fabric / Forge / NeoForge - **Модовый загрузчик** — Fabric / Forge / Quilt и т.д.
- **Ваниль** — чистая версия от Mojang - **Ваниль** — чистая версия от Mojang
**Рекомендация:** Не используйте кириллицу в названии сборки! (science 1.0.0-1.0.5) **Рекомендация:** Не используйте кириллицу в названии сборки! (science 1.0.0-1.0.5)
## Возможные проблемы и решения ## Возможные проблемы и решения
### «Error: Invalid or corrupt jarfile» при запуске exe
Перекачайте архив целиком из https://api.zernmc.ru/launcher/download/latest и распакуйте заново — старые версии (до 1.0.16.17) были повреждены подписью и не запускаются.
### Проблемы с загрузкой ассетов ### Проблемы с загрузкой ассетов
Иногда лаунчер неправильно определяет Asset Index. Иногда лаунчер неправильно определяет Asset Index.
**Решение:** **Решение:**
1. Запустите версию и посмотрите в логах, какой `.json` файл он пытается загрузить. 1. Запустите версию и посмотрите в логах, какой `.json` файл он пытается загрузить.
2. Скопируйте правильный файл из `assets/indexes/` и переименуйте его в нужное имя. 2. Скопируйте правильный файл из `assets/indexes/` и переименуйте его в нужное имя.
### Forge / NeoForge ### Forge версии
Работает через установщики лоадеров. Если у вас нет внешнего доступа к Maven-репозиториям — используйте прокси-режим. Запуск Forge пока работает нестабильно. Рекомендуется дождаться исправления генерации команды запуска.
### Прокси-режим ### Прокси-режим
Если нет доступа к Fabric/Forge Maven или серверам Mojang — лаунчер пытается использовать наш сервер как прокси. Если и к нему нет доступа — загрузка может не работать. Если нет доступа к Fabric/Forge Maven или серверам Mojang — лаунчер пытается использовать наш сервер как прокси. Если и к нему нет доступа — загрузка может не работать.
## Как сообщить об ошибке (Issue) ## Как сообщить об ошибке (Issue)
1. Скопируйте весь текст ошибки из логов (`latest.log`) 1. Скопируйте весь текст ошибки из логов (лаунчер не очищает экран, поэтому просто скролльте вверх)
2. Укажите, что вы делали перед ошибкой 2. Укажите, что вы делали перед ошибкой
3. Укажите версию лаунчера 3. Укажите версию лаунчера
4. Создайте Issue на GitHub 4. Создайте Issue на GitHub
@@ -147,21 +118,25 @@ Bootstrap встроен в `.exe`.
git clone https://github.com/SashegDev/launcher git clone https://github.com/SashegDev/launcher
``` ```
Откройте `launcher/launcher/pom.xml` Откройте `launcher/launcher/pom.xml`
отредактируйте пути для билда, так как сейчас - он рассчитан на разработку только на своём сервере отредактируете пути для билда, так как сейчас - он расчитан на разработку только на моём сервере
```bash ```bash
mvn clean package mvn clean package
``` ```
можете начинать! можете начинать!
**Сборка exe на Linux требует:** `jsign` (`~/tools/jsign/jsign.jar`), файлы сертификата в `/root/cert/codesign/`, встроенный JRE в `~/launcher/jre/jre21`.
### Благодарность ### Благодарность
- Glitch135 — за бета-тест лаунчера и фидбеки Glitch135 - За бета-тест лаунчера и фидбеки
### помощь
Я был бы рад если нашлись люди кто мог бы помочь в разработке лаунчера и добавления к нему хотя бы базового UI чтоб было намногл удобнее работать с ним
Буду рад помощи в разработке! Буду рад помощи в разработке!
Особенно приветствуется помощь с: Особенно приветствуется помощь с:
- Переходом на нормальный графический интерфейс (GUI)
- Улучшением генерации команды запуска - Улучшением генерации команды запуска
- Добавлением поддержки Quilt / LabyMod - Добавлением поддержки Forge / NeoForge / Quilt
- Стабилизацией серверной части - Стабилизацией серверной части
- Вообще улучшением клиента - вообще улучшением клиента
-12
View File
@@ -1,12 +0,0 @@
module zernmc-installer
go 1.21
require (
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect
golang.org/x/sys v0.28.0 // indirect
gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
)
-13
View File
@@ -1,13 +0,0 @@
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac h1:/QqP+ajFMma4hNWQyBDVaQQhz9Z1kDyXScNWMO3owx0=
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
-324
View File
@@ -1,324 +0,0 @@
package main
import (
"archive/zip"
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/sqweek/dialog"
)
//go:embed offline.zip
var offlineFS embed.FS
var (
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from embedded zip)")
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
)
const (
defaultInstallDir = "C:\\ZernMC"
serverBase = "https://api.zern.cc"
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
)
func main() {
flag.Parse()
isOnline := *onlineFlag && !*offlineFlag
// GUI mode on Windows unless --cli
hasCLI := false
for _, a := range os.Args { if a=="--cli" { hasCLI=true } }
if runtime.GOOS=="windows" && !hasCLI {
// try GUI, fallback to console on error
if err := runGUI(isOnline); err != nil {
fmt.Printf("GUI failed %v, fallback console\n", err)
runConsole(isOnline)
}
return
}
runConsole(isOnline)
}
func runConsole(isOnline bool) {
dir := *installDirFlag
if dir == "" { dir = askInstallDir() }
if dir == "" { fmt.Println("Installation cancelled"); os.Exit(0) }
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline)
if err := os.MkdirAll(dir, 0755); err != nil { fatal("Cannot create directory: %v", err) }
createShortcut := *shortcutFlag
if !isFlagSet("shortcut") { createShortcut = askShortcut() }
var err error
if isOnline { err = installOnline(dir, createShortcut, nil) } else {
zipPath := *offlineZipFlag
if zipPath=="" { zipPath = findAdjacentOfflineZip() }
if zipPath=="" {
// try embedded
err = installOfflineEmbedded(dir, createShortcut)
} else { err = installOffline(dir, zipPath, createShortcut) }
}
if err != nil { fatal("%v", err) }
fmt.Println("\nInstallation complete!")
}
func runGUI(isOnline bool) error {
// Fallback to console dialogs if GUI not available (walk requires windows)
// For now use sqweek/dialog wizard to mimic Inno bootstrap
dir := *installDirFlag
if dir == "" {
// Welcome
ok := dialog.Message("Добро пожаловать в ZernMC\n\nМастер установит ZernMC Launcher на ваш компьютер.\nОнлайн — скачает 47М JRE + 50М лаунчер | Оффлайн — распакует встроенный архив\n\nНажмите ОК чтобы выбрать папку C:\\ZernMC").Title("ZernMC Setup — Приветствие").YesNo()
if !ok { return fmt.Errorf("cancelled") }
d, err := dialog.Directory().Title("Выберите папку установки — ZernMC").Browse()
if err != nil || d=="" { d = defaultInstallDir }
if !strings.Contains(strings.ToLower(d), "zernmc") { d = filepath.Join(d, "ZernMC") }
dir = d
}
if err := os.MkdirAll(dir, 0755); err != nil { dialog.Message("Ошибка создания папки: %v", err).Title("Ошибка").Error(); return err }
createShortcut := *shortcutFlag
if !isFlagSet("shortcut") { createShortcut = dialog.Message("Создать ярлык на рабочем столе?").Title("Ярлык").YesNo() }
// Progress via console + dialog
fmt.Printf("Установка в %s ...\n", dir)
var err error
if isOnline {
err = installOnline(dir, createShortcut, func(cur, total int, name string){
fmt.Printf("[%d/%d] %s\n", cur, total, name)
})
} else {
zipPath := *offlineZipFlag
if zipPath=="" { zipPath = findAdjacentOfflineZip() }
if zipPath!="" { err = installOffline(dir, zipPath, createShortcut) } else { err = installOfflineEmbedded(dir, createShortcut) }
}
if err != nil { dialog.Message("Ошибка установки: %v", err).Title("Ошибка").Error(); return err }
dialog.Message("Установка завершена!\n\nЗапустите с ярлыка или C:\\ZernMC\\zernmc.exe").Title("ZernMC Setup — Готово").Info()
// launch?
if dialog.Message("Запустить ZernMC сейчас?").Title("Готово").YesNo() {
exec.Command(filepath.Join(dir, "zernmc.exe")).Start()
}
return nil
}
func isFlagSet(name string) bool { found:=false; flag.Visit(func(f *flag.Flag){ if f.Name==name { found=true } }); return found }
func askInstallDir() string {
if runtime.GOOS=="windows" {
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
if err==nil && dir!="" {
if !strings.Contains(strings.ToLower(dir), "zernmc") { dir = filepath.Join(dir, "ZernMC") }
return dir
}
fmt.Printf("Dialog cancelled (%v), using default %s\n", err, defaultInstallDir)
}
fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
var input string; fmt.Scanln(&input); input=strings.TrimSpace(input)
if input=="" { input=defaultInstallDir }
return input
}
func askShortcut() bool {
if runtime.GOOS!="windows" { return false }
return dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
}
func fatal(format string, args ...interface{}) {
msg:=fmt.Sprintf(format, args...); fmt.Fprintln(os.Stderr, msg)
if runtime.GOOS=="windows" { dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error() }
os.Exit(1)
}
func installOnline(dir string, createShortcut bool, progress func(cur,total int, name string)) error {
fmt.Println("Online installation — downloading JRE + launcher files...")
jreDir:=filepath.Join(dir, "lib", "jre21"); javaExe:=filepath.Join(jreDir, "bin", "java.exe")
if _, err:=os.Stat(javaExe); err!=nil {
fmt.Println("Downloading JRE 21 (47 MB)...")
jreURL:=serverBase+"/launcher/download/jre"; tmpZip:=filepath.Join(os.TempDir(), jreZipName)
if err:=downloadFile(jreURL, tmpZip); err!=nil {
mirrors:=[]string{"https://api.zernmc.ru/launcher/download/jre","https://api.zernmc.online/launcher/download/jre"}
var lastErr error
for _, m:=range mirrors { fmt.Printf("Retry mirror %s\n", m); if err:=downloadFile(m, tmpZip); err==nil { lastErr=nil; break } else { lastErr=err } }
if lastErr!=nil { return fmt.Errorf("JRE download failed: %w", lastErr) }
}
fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
if err:=unzip(tmpZip, dir); err!=nil { return fmt.Errorf("unzip JRE: %w", err) }
candidate:=filepath.Join(dir, "jre21")
if _, err:=os.Stat(candidate); err==nil { os.MkdirAll(filepath.Join(dir, "lib"), 0755); os.RemoveAll(jreDir); if err:=os.Rename(candidate, jreDir); err!=nil { if err:=copyDir(candidate, jreDir); err!=nil { return err }; os.RemoveAll(candidate) } }
os.Remove(tmpZip)
if _, err:=os.Stat(javaExe); err!=nil { return fmt.Errorf("JRE unpack failed, %s not found", javaExe) }
fmt.Println("JRE ready")
} else { fmt.Println("JRE already present, skipping") }
fmt.Println("Fetching launcher meta...")
meta, err:=fetchMeta()
if err!=nil { return fmt.Errorf("fetch meta: %w", err) }
fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
for i, f:=range meta.Files {
rel:=f.Path
if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") { continue }
dest:=filepath.Join(dir, filepath.FromSlash(rel))
need:=true
if fi, err:=os.Stat(dest); err==nil && !fi.IsDir() {
hash, _:=fileSHA256(dest)
if hash==strings.TrimPrefix(f.Hash, "sha256:") { need=false }
}
if !need { continue }
if progress!=nil { progress(i+1, len(meta.Files), rel) }
fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024)
if err:=os.MkdirAll(filepath.Dir(dest), 0755); err!=nil { return err }
url:=fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
if err:=downloadFile(url, dest); err!=nil { return fmt.Errorf("download %s: %w", rel, err) }
hash, _:=fileSHA256(dest)
if hash!=strings.TrimPrefix(f.Hash, "sha256:") { fmt.Printf(" WARNING hash mismatch for %s\n", rel) }
}
if err:=os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err!=nil { fmt.Printf("warning write build.version: %v\n", err) }
fmt.Println("Launcher files ready")
if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
return nil
}
func installOfflineEmbedded(dir string, createShortcut bool) error {
fmt.Println("Offline embedded installation...")
data, err:=offlineFS.ReadFile("offline.zip")
if err!=nil {
// try adjacent
zp:=findAdjacentOfflineZip()
if zp!="" { return installOffline(dir, zp, createShortcut) }
return fmt.Errorf("offline.zip not embedded, put ZernMC-win-*.zip next to installer or rebuild with offline.zip: %w", err)
}
tmp:=filepath.Join(os.TempDir(), fmt.Sprintf("offline-%d.zip", time.Now().UnixNano()))
if err:=os.WriteFile(tmp, data, 0644); err!=nil { return err }
defer os.Remove(tmp)
fmt.Println("Unpacking embedded (98 MB)...")
if err:=unzip(tmp, dir); err!=nil { return fmt.Errorf("unzip: %w", err) }
if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
fmt.Println("Offline unpack complete")
return nil
}
func installOffline(dir, zipPath string, createShortcut bool) error {
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
if _, err:=os.Stat(zipPath); err!=nil { return fmt.Errorf("offline zip not found: %s", zipPath) }
fmt.Println("Unpacking (98 MB)...")
if err:=unzip(zipPath, dir); err!=nil { return fmt.Errorf("unzip: %w", err) }
if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
fmt.Println("Offline unpack complete")
return nil
}
func downloadFile(url, dest string) error {
client:=&http.Client{Timeout: 120*time.Second}
resp, err:=client.Get(url)
if err!=nil { return err }
defer resp.Body.Close()
if resp.StatusCode!=200 { return fmt.Errorf("HTTP %d", resp.StatusCode) }
if err:=os.MkdirAll(filepath.Dir(dest), 0755); err!=nil { return err }
tmp:=dest+".tmp"
out, err:=os.Create(tmp)
if err!=nil { return err }
defer out.Close()
total:=resp.ContentLength
var written int64
buf:=make([]byte, 64*1024)
start:=time.Now()
for {
n, err:=resp.Body.Read(buf)
if n>0 {
if _, werr:=out.Write(buf[:n]); werr!=nil { return werr }
written+=int64(n)
if total>0 {
pct:=float64(written)/float64(total)*100
elapsed:=time.Since(start).Seconds()
speed:=float64(written)/1024/1024/(elapsed+0.001)
fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
} else { fmt.Printf("\r %.1f MB ", float64(written)/1024/1024) }
}
if err==io.EOF { break }
if err!=nil { return err }
}
fmt.Println()
out.Close()
if err:=os.Rename(tmp, dest); err!=nil { if err2:=copyFile(tmp, dest); err2!=nil { return err2 }; os.Remove(tmp) }
return nil
}
func unzip(src, dest string) error {
r, err:=zip.OpenReader(src)
if err!=nil { return err }
defer r.Close()
for _, f:=range r.File {
fpath:=filepath.Join(dest, f.Name)
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("illegal file path: %s", fpath) }
if f.FileInfo().IsDir() { os.MkdirAll(fpath, f.Mode()); continue }
if err:=os.MkdirAll(filepath.Dir(fpath), 0755); err!=nil { return err }
out, err:=os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err!=nil { return err }
rc, err:=f.Open()
if err!=nil { out.Close(); return err }
_, err=io.Copy(out, rc)
out.Close(); rc.Close()
if err!=nil { return err }
}
return nil
}
func copyFile(src, dst string) error { in, err:=os.Open(src); if err!=nil { return err }; defer in.Close(); out, err:=os.Create(dst); if err!=nil { return err }; defer out.Close(); _, err=io.Copy(out, in); return err }
func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err!=nil { return err }; rel, _:=filepath.Rel(path, src); target:=filepath.Join(dst, rel); if info.IsDir() { return os.MkdirAll(target, info.Mode()) }; return copyFile(path, target) }) }
func fileSHA256(path string) (string, error) { f, err:=os.Open(path); if err!=nil { return "", err }; defer f.Close(); h:=sha256.New(); if _, err:=io.Copy(h, f); err!=nil { return "", err }; return hex.EncodeToString(h.Sum(nil)), nil }
type Meta struct { Version string `json:"version"`; Files []struct{ Path string `json:"path"`; Size int64 `json:"size"`; Hash string `json:"hash"` } `json:"files"` }
func fetchMeta() (*Meta, error) {
resp, err:=http.Get(serverBase+"/launcher/version")
if err!=nil { return nil, err }
defer resp.Body.Close()
var verResp struct{ Version string `json:"version"` }
if err:=json.NewDecoder(resp.Body).Decode(&verResp); err!=nil { return nil, err }
ver:=verResp.Version
if ver=="" { ver="1.1.1" }
metaResp, err:=http.Get(serverBase+"/launcher/meta/"+ver)
if err!=nil { return nil, err }
defer metaResp.Body.Close()
if metaResp.StatusCode!=200 { return nil, fmt.Errorf("meta %d", metaResp.StatusCode) }
var meta Meta
if err:=json.NewDecoder(metaResp.Body).Decode(&meta); err!=nil { return nil, err }
if meta.Version=="" { meta.Version=ver }
return &meta, nil
}
func findAdjacentOfflineZip() string {
exe, _:=os.Executable(); dir:=filepath.Dir(exe)
matches, _:=filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
if len(matches)>0 { return matches[0] }
matches, _=filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
if len(matches)>0 { return matches[0] }
matches, _=filepath.Glob("ZernMC-Offline*.zip")
if len(matches)>0 { return matches[0] }
return ""
}
func createDesktopShortcut(installDir string) error {
if runtime.GOOS!="windows" { return nil }
desktop:=filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
if _, err:=os.Stat(desktop); err!=nil { desktop=filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop") }
if _, err:=os.Stat(desktop); err!=nil { return fmt.Errorf("desktop not found") }
lnk:=filepath.Join(desktop, "ZernMC Launcher.lnk")
target:=filepath.Join(installDir, "zernmc.exe")
ps:=fmt.Sprintf(`$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WorkingDirectory = '%s'; $Shortcut.IconLocation = '%s'; $Shortcut.Save()`, lnk, target, installDir, target)
tmp:=filepath.Join(os.TempDir(), "zernmc_shortcut.ps1")
if err:=os.WriteFile(tmp, []byte(ps), 0644); err!=nil { return err }
defer os.Remove(tmp)
cmd:=exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp)
cmd.Stdout=os.Stdout; cmd.Stderr=os.Stderr
return cmd.Run()
}
func createUninstaller(dir string) error {
exe, _:=os.Executable(); uninstExe:=filepath.Join(dir, "uninstall.exe")
if exe!="" && exe!=uninstExe { _=copyFile(exe, uninstExe) }
bat:=filepath.Join(dir, "uninstall.bat")
content:=fmt.Sprintf("@echo off\necho Uninstalling ZernMC from %s\ntimeout /t 2 >nul\ndel \"%%%%USERPROFILE%%%%\\Desktop\\ZernMC Launcher.lnk\" 2>nul\ndel \"%%%%USERPROFILE%%%%\\OneDrive\\Desktop\\ZernMC Launcher.lnk\" 2>nul\necho Data in %%%%USERPROFILE%%%%\\.zernmc will be kept.\n", dir)
_=os.WriteFile(bat, []byte(content), 0644)
return nil
}
func runCmd(name string, args ...string) error { cmd:=exec.Command(name, args...); cmd.Stdout=os.Stdout; cmd.Stderr=os.Stderr; return cmd.Run() }
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>me.sashegdev</groupId> <groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId> <artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version> <version>1.0.9</version>
</parent> </parent>
<artifactId>zernmc-bootstrap</artifactId> <artifactId>zernmc-bootstrap</artifactId>
File diff suppressed because it is too large Load Diff
-75
View File
@@ -1,75 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version>
</parent>
<artifactId>zernmc-diag</artifactId>
<packaging>jar</packaging>
<name>ZernMC Network Diagnostics</name>
<description>Standalone network diagnostics utility (DNS, connectivity, report upload)</description>
<dependencies>
<dependency>
<groupId>dnsjava</groupId>
<artifactId>dnsjava</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/bin/zernmcdiag.jar</outputFile>
<filters>
<filter>
<artifact>dnsjava:dnsjava</artifact>
<excludes>
<exclude>META-INF/services/java.net.spi.InetAddressResolverProvider</exclude>
<exclude>META-INF/services/sun.net.spi.nameservice.NameServiceDescriptor</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.diag.DiagMain</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
<Implementation-Title>ZernMC Network Diagnostics</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,29 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.nio.file.Path;
public record DiagCheck(String category, String name, String status, String detail, long latencyMs) {
public boolean ok() {
return "OK".equals(status);
}
public String renderLine() {
StringBuilder sb = new StringBuilder();
if ("OK".equals(status)) {
sb.append(" [OK] ");
} else if ("FAIL".equals(status)) {
sb.append(" [FAIL]");
} else {
sb.append(" [SKIP]");
}
sb.append(' ').append(category).append(" / ").append(name);
if (latencyMs >= 0) {
sb.append(" (").append(latencyMs).append(" ms)");
}
if (detail != null && !detail.isEmpty()) {
sb.append(" -> ").append(detail);
}
return sb.toString();
}
}
@@ -1,55 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.util.List;
import java.util.Map;
public final class DiagConstants {
public static final String HOST_API = "api.zern.cc";
public static final List<String> HOSTS = List.of(
HOST_API,
"api.zernmc.ru",
"api.zernmc.online",
"api.pl.zern.cc",
"api.swe.zern.cc",
"api.ru.zern.cc"
);
public static final List<String> KNOWN_SERVER_IPS = List.of(
"87.120.187.36", // main (Frankfurt)
"212.22.82.243", // ru geo (Moscow)
"2.26.5.202", // pl geo (Warsaw)
"2.26.55.218" // swe geo (Stockholm)
);
public static final String PRIMARY_API = "https://" + HOST_API;
public static final String DIRECT_API = "http://87.120.187.36:1582";
public static final List<Integer> TLS_PORTS = List.of(443, 80);
public static final int DIRECT_PORT = 1582;
public static final String VERSION_PATH = "/launcher/version";
public static final String CLIENT_IP_PATH = "/launcher/ip";
public static final String DIAG_UPLOAD_PATH = "/diag/upload";
public static final String DOH_GOOGLE_URL = "https://dns.google/resolve?name=%s&type=A";
public static final String DOH_CLOUDFLARE_URL = "https://cloudflare-dns.com/dns-query?name=%s&type=A";
public static final Map<String, String> DNS_PROVIDERS = Map.ofEntries(
Map.entry("Google 8.8.8.8", "8.8.8.8"),
Map.entry("Google 8.8.4.4", "8.8.4.4"),
Map.entry("Cloudflare 1.1.1.1", "1.1.1.1"),
Map.entry("Cloudflare 1.0.0.1", "1.0.0.1"),
Map.entry("Yandex 77.88.8.8", "77.88.8.8"),
Map.entry("Yandex 77.88.8.1", "77.88.8.1"),
Map.entry("OpenDNS 208.67.222.222", "208.67.222.222"),
Map.entry("Quad9 9.9.9.9", "9.9.9.9")
);
public static final int CONNECT_TIMEOUT_MS = 8000;
public static final int READ_TIMEOUT_MS = 8000;
private DiagConstants() {
}
}
@@ -1,79 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
public final class DiagMain {
public static void main(String[] args) {
List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli");
Path baseDir = baseDirFromArgs(argList);
if (cliMode) {
runCli(baseDir);
return;
}
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception ignored) {
}
DiagnosticsUI ui = new DiagnosticsUI(baseDir);
ui.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
System.exit(0);
}
});
ui.setVisible(true);
ui.start();
});
}
private static Path baseDirFromArgs(List<String> args) {
int idx = args.indexOf("--dir");
if (idx >= 0 && idx + 1 < args.size()) {
return Path.of(args.get(idx + 1)).toAbsolutePath();
}
return Path.of("").toAbsolutePath();
}
private static void runCli(Path baseDir) {
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir, System.out::println, null);
Path log = diag.run();
System.out.println("Diagnostics log: " + (log != null ? log.toAbsolutePath() : "n/a"));
System.out.println("Client IP: " + diag.getClientIp());
if (log != null) {
System.out.print("Send report to the Zern server? [y/N]: ");
System.out.flush();
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line = reader.readLine();
if (line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"))) {
ReportSender.SendResult r = ReportSender.send(log, diag.getClientIp());
System.out.println("Send result: " + (r.ok() ? "OK via " + r.endpoint() : "FAILED (" + r.detail() + ")"));
}
} catch (Exception e) {
System.out.println("Send skipped: " + e.getMessage());
}
}
int exitCode = 0;
for (DiagCheck c : diag.getChecks()) {
if ("FAIL".equals(c.status()) && c.category().equals("HTTP")) {
exitCode = 1;
}
}
System.exit(exitCode);
}
}
@@ -1,194 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.GridLayout;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingWorker;
import javax.swing.border.EmptyBorder;
public final class DiagnosticsUI extends JFrame {
private static final Color BG = new Color(0x15151a);
private static final Color PANEL = new Color(0x1e1e24);
private static final Color TEXT = new Color(0xe8e8ea);
private static final Color ACCENT = new Color(0x5dade2);
private final Path baseDir;
private final JTextArea output = new JTextArea();
private final JButton sendButton = new JButton("Отправить на сервер");
private final JLabel statusLabel = new JLabel("Запуск диагностики...");
private volatile Path diagLog;
private volatile String clientIp = "unknown";
public DiagnosticsUI(Path baseDir) {
this.baseDir = baseDir;
setTitle("ZernMC — Диагностика сети");
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(760, 560);
setMinimumSize(new Dimension(600, 420));
setLocationRelativeTo(null);
getContentPane().setBackground(BG);
((JPanel) getContentPane()).setBorder(new EmptyBorder(16, 16, 16, 16));
JLabel header = new JLabel("Проверка связи с серверами Zern");
header.setFont(new Font("Segoe UI", Font.BOLD, 17));
header.setForeground(ACCENT);
header.setBorder(new EmptyBorder(0, 0, 10, 0));
output.setEditable(false);
output.setFont(new Font("Consolas", Font.PLAIN, 12));
output.setBackground(PANEL);
output.setForeground(TEXT);
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JScrollPane scroll = new JScrollPane(output);
scroll.setBorder(BorderFactory.createLineBorder(PANEL));
statusLabel.setForeground(TEXT);
statusLabel.setBorder(new EmptyBorder(8, 0, 0, 0));
JButton openButton = new JButton("Открыть папку с логом");
JButton closeButton = new JButton("Закрыть");
sendButton.setEnabled(false);
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
buttons.setOpaque(false);
buttons.add(openButton);
buttons.add(sendButton);
buttons.add(closeButton);
JPanel south = new JPanel(new BorderLayout());
south.setOpaque(false);
south.add(statusLabel, BorderLayout.NORTH);
south.add(buttons, BorderLayout.SOUTH);
setLayout(new BorderLayout());
add(header, BorderLayout.NORTH);
add(scroll, BorderLayout.CENTER);
add(south, BorderLayout.SOUTH);
openButton.addActionListener(e -> openFolder());
closeButton.addActionListener(e -> dispose());
sendButton.addActionListener(e -> sendReport());
}
public void start() {
SwingWorker<Void, String> worker = new SwingWorker<>() {
@Override
protected Void doInBackground() {
publish("\u2014 Сервер Zern недоступен, выполняем диагностику \u2014");
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir,
this::publish,
c -> publish(renderCheck(c)));
diagLog = diag.run();
clientIp = diag.getClientIp();
publish("Диагностика завершена. Лог: " + (diagLog != null ? diagLog.toAbsolutePath() : "n/a"));
return null;
}
@Override
protected void process(java.util.List<String> chunks) {
for (String line : chunks) {
appendLine(line);
}
}
@Override
protected void done() {
sendButton.setEnabled(diagLog != null);
statusLabel.setText("Готово. Можете отправить лог на сервер или сохранить его.");
}
};
worker.execute();
}
private static String renderCheck(DiagCheck c) {
String prefix = switch (c.status()) {
case "OK" -> "[OK] ";
case "FAIL" -> "[FAIL]";
default -> "[SKIP]";
};
StringBuilder sb = new StringBuilder(prefix).append(' ').append(c.category()).append(" / ").append(c.name());
if (c.latencyMs() >= 0) {
sb.append(" (").append(c.latencyMs()).append(" ms)");
}
if (c.detail() != null && !c.detail().isEmpty()) {
sb.append(" -> ").append(c.detail());
}
return sb.toString();
}
private void appendLine(String line) {
output.append(line);
if (!line.endsWith("\n")) {
output.append("\n");
}
output.setCaretPosition(output.getDocument().getLength());
}
private void openFolder() {
try {
Path dir = diagLog != null ? diagLog.getParent() : baseDir;
if (dir != null && Files.exists(dir)) {
Desktop.getDesktop().open(dir.toFile());
} else {
JOptionPane.showMessageDialog(this, "Папка ещё не создана.");
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "Не удалось открыть папку: " + e.getMessage());
}
}
private void sendReport() {
if (diagLog == null) {
return;
}
sendButton.setEnabled(false);
statusLabel.setText("Отправка отчёта на сервер...");
SwingWorker<ReportSender.SendResult, Void> worker = new SwingWorker<>() {
@Override
protected ReportSender.SendResult doInBackground() throws Exception {
return ReportSender.send(diagLog, clientIp);
}
@Override
protected void done() {
sendButton.setEnabled(true);
try {
ReportSender.SendResult r = get();
if (r.ok()) {
statusLabel.setText("Отчёт отправлен: " + r.endpoint());
JOptionPane.showMessageDialog(DiagnosticsUI.this,
"Отчёт успешно отправлен на сервер Zern.",
"Отправлено", JOptionPane.INFORMATION_MESSAGE);
} else {
statusLabel.setText("Не удалось отправить отчёт.");
JOptionPane.showMessageDialog(DiagnosticsUI.this,
"Не удалось отправить отчёт на сервер.\n"
+ "Сохраните лог и отправьте его вручную:\n" + diagLog.toAbsolutePath()
+ "\n\nОшибка: " + r.detail(),
"Ошибка отправки", JOptionPane.WARNING_MESSAGE);
}
} catch (Exception e) {
statusLabel.setText("Ошибка отправки: " + e.getMessage());
}
}
};
worker.execute();
}
}
@@ -1,128 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.net.InetAddress;
import java.net.URL;
import java.net.URLConnection;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import org.xbill.DNS.ARecord;
import org.xbill.DNS.Lookup;
import org.xbill.DNS.Record;
import org.xbill.DNS.SimpleResolver;
import org.xbill.DNS.Type;
public final class DnsResolver {
private DnsResolver() {
}
public static List<String> resolveSystem(String host) {
try {
InetAddress[] addrs = InetAddress.getAllByName(host);
List<String> ips = new ArrayList<>();
for (InetAddress a : addrs) {
String ip = a.getHostAddress();
if (ip != null && !ips.contains(ip)) {
ips.add(ip);
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveDoHGoogle(String host) {
try {
String url = String.format(DiagConstants.DOH_GOOGLE_URL, host);
String body = doGet(url, "application/json");
JSONObject json = new JSONObject(body);
JSONArray answers = json.optJSONArray("Answer");
if (answers == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (int i = 0; i < answers.length(); i++) {
JSONObject a = answers.optJSONObject(i);
if (a != null && a.optInt("type", 0) == 1) {
String data = a.optString("data", null);
if (data != null && !ips.contains(data)) {
ips.add(data);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveDoHCloudflare(String host) {
try {
String url = String.format(DiagConstants.DOH_CLOUDFLARE_URL, host);
String body = doGet(url, "application/dns-json");
JSONObject json = new JSONObject(body);
JSONArray answers = json.optJSONArray("Answer");
if (answers == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (int i = 0; i < answers.length(); i++) {
JSONObject a = answers.optJSONObject(i);
if (a != null && a.optInt("type", 0) == 1) {
String data = a.optString("data", null);
if (data != null && !ips.contains(data)) {
ips.add(data);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveUdp(String providerIp, String host) {
try {
SimpleResolver resolver = new SimpleResolver(providerIp);
resolver.setTimeout(Duration.ofSeconds(5));
Lookup lookup = new Lookup(org.xbill.DNS.Name.fromString(host + "."), Type.A);
lookup.setResolver(resolver);
lookup.setCache(null);
Record[] records = lookup.run();
if (records == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (Record r : records) {
if (r instanceof ARecord a) {
String ip = a.getAddress().getHostAddress();
if (ip != null && !ips.contains(ip)) {
ips.add(ip);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
private static String doGet(String url, String accept) throws Exception {
URLConnection conn = new URL(url).openConnection();
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
conn.setRequestProperty("Accept", accept);
conn.setRequestProperty("User-Agent", "ZernMC-Diag");
conn.connect();
byte[] buf;
try (var in = conn.getInputStream()) {
buf = in.readAllBytes();
}
return new String(buf, java.nio.charset.StandardCharsets.UTF_8);
}
}
@@ -1,58 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
public final class NetProbe {
public record TcpResult(boolean ok, long latencyMs, String detail) {
}
public record HttpResult(boolean ok, int status, long latencyMs, String bodySnippet, String detail) {
}
private NetProbe() {
}
public static TcpResult tcpConnect(String host, int port, int timeoutMs) {
long t0 = System.currentTimeMillis();
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), timeoutMs);
return new TcpResult(true, System.currentTimeMillis() - t0, host + ":" + port);
} catch (Exception e) {
return new TcpResult(false, System.currentTimeMillis() - t0, host + ":" + port + " -> " + e.getMessage());
}
}
public static HttpResult httpGet(String url, int timeoutMs) {
long t0 = System.currentTimeMillis();
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
conn.setConnectTimeout(timeoutMs);
conn.setReadTimeout(timeoutMs);
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
conn.setInstanceFollowRedirects(false);
int status = conn.getResponseCode();
InputStream in = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
String snippet = "";
if (in != null) {
byte[] bytes = in.readAllBytes();
String body = new String(bytes, StandardCharsets.UTF_8).trim();
snippet = body.length() > 200 ? body.substring(0, 200) + "..." : body;
}
boolean ok = status >= 200 && status < 400;
return new HttpResult(ok, status, System.currentTimeMillis() - t0, snippet, "HTTP " + status);
} catch (Exception e) {
return new HttpResult(false, 0, System.currentTimeMillis() - t0, "", e.getMessage());
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
}
@@ -1,263 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.json.JSONObject;
public final class NetworkDiagnostics {
private final Path baseDir;
private final Consumer<String> onLine;
private final Consumer<DiagCheck> onCheck;
private final List<DiagCheck> checks = new CopyOnWriteArrayList<>();
private final StringBuilder report = new StringBuilder();
private String clientIp = "unknown";
public NetworkDiagnostics(Path baseDir, Consumer<String> onLine, Consumer<DiagCheck> onCheck) {
this.baseDir = baseDir;
this.onLine = onLine;
this.onCheck = onCheck;
}
public List<DiagCheck> getChecks() {
return checks;
}
public String getClientIp() {
return clientIp;
}
public Path run() {
checks.clear();
report.setLength(0);
report.append("=== ZernMC Network Diagnostics Report ===\n");
report.append("Timestamp: ").append(timestamp()).append('\n');
report.append("Base dir: ").append(baseDir.toAbsolutePath()).append('\n');
report.append("Zern server known IP(s): ").append(String.join(", ", DiagConstants.KNOWN_SERVER_IPS)).append('\n');
report.append("Primary API: ").append(DiagConstants.PRIMARY_API).append('\n');
report.append("Direct API: ").append(DiagConstants.DIRECT_API).append('\n');
runDnsChecks();
runConnectivityChecks();
runHttpChecks();
clientIp = determineClientIp();
int ok = 0, fail = 0, skip = 0;
for (DiagCheck c : checks) {
switch (c.status()) {
case "OK" -> ok++;
case "FAIL" -> fail++;
default -> skip++;
}
}
report.append("=== Summary ===\n");
report.append("OK: ").append(ok).append(", FAIL: ").append(fail).append(", SKIP: ").append(skip).append('\n');
report.append("--- JSON ---\n");
JSONObject json = new JSONObject();
json.put("client_ip", clientIp);
json.put("timestamp", timestamp());
JSONObject jsonChecks = new JSONObject();
for (DiagCheck c : checks) {
JSONObject j = new JSONObject();
j.put("status", c.status());
j.put("detail", c.detail() == null ? "" : c.detail());
j.put("latency_ms", c.latencyMs());
jsonChecks.put(c.category() + "/" + c.name(), j);
}
json.put("checks", jsonChecks);
report.append(json.toString(2)).append('\n');
return writeLog();
}
private Path writeLog() {
try {
Files.createDirectories(baseDir);
Path log = baseDir.resolve("diag.log");
Files.writeString(log, report.toString(), StandardCharsets.UTF_8);
if (onLine != null) {
onLine.accept("\nDiagnostic log written to: " + log.toAbsolutePath());
}
return log;
} catch (IOException e) {
if (onLine != null) {
onLine.accept("Failed to write diag.log: " + e.getMessage());
}
return null;
}
}
private void runDnsChecks() {
report.append("\n=== DNS resolution ===\n");
for (String host : DiagConstants.HOSTS) {
report.append("Host: ").append(host).append('\n');
submit(resolveToCheck(host, "system", DnsResolver.resolveSystem(host)));
}
List<Callable<DiagCheck>> tasks = new ArrayList<>();
List<String> taskKeys = new ArrayList<>();
for (String host : DiagConstants.HOSTS) {
tasks.add(() -> resolveToCheck(host, "DoH Google", DnsResolver.resolveDoHGoogle(host)));
taskKeys.add("DoH Google/" + host);
tasks.add(() -> resolveToCheck(host, "DoH Cloudflare", DnsResolver.resolveDoHCloudflare(host)));
taskKeys.add("DoH Cloudflare/" + host);
for (String provider : DiagConstants.DNS_PROVIDERS.keySet()) {
String providerIp = DiagConstants.DNS_PROVIDERS.get(provider);
tasks.add(() -> resolveToCheck(host, "UDP " + provider, DnsResolver.resolveUdp(providerIp, host)));
taskKeys.add("UDP " + provider + "/" + host);
}
}
ExecutorService pool = Executors.newFixedThreadPool(Math.min(8, tasks.size()), r -> {
Thread t = new Thread(r, "diag-dns");
t.setDaemon(true);
return t;
});
try {
List<Future<DiagCheck>> futures = new ArrayList<>();
for (Callable<DiagCheck> task : tasks) {
futures.add(pool.submit(task));
}
int idx = 0;
for (Future<DiagCheck> f : futures) {
String key = taskKeys.get(idx);
try {
DiagCheck c = f.get(20, TimeUnit.SECONDS);
submit(c);
} catch (Exception e) {
submit(new DiagCheck("DNS", key, "FAIL", "query error: " + e.getMessage(), -1));
}
idx++;
}
} finally {
pool.shutdownNow();
}
}
static DiagCheck resolveToCheck(String host, String source, List<String> ips) {
if (ips.isEmpty()) {
return new DiagCheck("DNS", host + " / " + source, "FAIL", "no A records", -1);
}
boolean matchesKnown = false;
for (String ip : ips) {
if (DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
matchesKnown = true;
break;
}
}
if (matchesKnown) {
return new DiagCheck("DNS", host + " / " + source, "OK", String.join(", ", ips), -1);
}
String detail = "resolved to " + String.join(", ", ips) + " (DIFFERENT from known " + String.join(", ", DiagConstants.KNOWN_SERVER_IPS) + ")";
return new DiagCheck("DNS", host + " / " + source, "FAIL", detail, -1);
}
private void runConnectivityChecks() {
report.append("\n=== Connectivity ===\n");
for (String ip : DiagConstants.KNOWN_SERVER_IPS) {
for (int port : DiagConstants.TLS_PORTS) {
submit(tcpToCheck(ip, port));
}
}
submit(tcpToCheck("87.120.187.36", DiagConstants.DIRECT_PORT));
List<String> systemIps = DnsResolver.resolveSystem(DiagConstants.HOST_API);
for (String ip : systemIps) {
if (!DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
submit(tcpToCheck(ip, 443));
}
}
}
private DiagCheck tcpToCheck(String host, int port) {
NetProbe.TcpResult r = NetProbe.tcpConnect(host, port, DiagConstants.CONNECT_TIMEOUT_MS);
return new DiagCheck("TCP", host + ":" + port, r.ok() ? "OK" : "FAIL", r.detail(), r.latencyMs());
}
private void runHttpChecks() {
report.append("\n=== HTTP ===\n");
for (String host : DiagConstants.HOSTS) {
submit(httpToCheck("https://" + host + DiagConstants.VERSION_PATH));
}
submit(httpToCheck(DiagConstants.DIRECT_API + DiagConstants.VERSION_PATH));
}
private DiagCheck httpToCheck(String url) {
NetProbe.HttpResult r = NetProbe.httpGet(url, DiagConstants.READ_TIMEOUT_MS);
String detail = r.ok() ? r.detail() + (r.bodySnippet().isEmpty() ? "" : " body=" + r.bodySnippet()) : r.detail();
return new DiagCheck("HTTP", url, r.ok() ? "OK" : "FAIL", detail, r.latencyMs());
}
private String determineClientIp() {
String ip = null;
NetProbe.HttpResult direct = NetProbe.httpGet(DiagConstants.DIRECT_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
if (direct.ok()) {
ip = parseIp(direct.bodySnippet());
}
if (ip == null) {
NetProbe.HttpResult primary = NetProbe.httpGet(DiagConstants.PRIMARY_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
if (primary.ok()) {
ip = parseIp(primary.bodySnippet());
}
}
String result = ip != null ? ip : "unknown";
submit(new DiagCheck("INFO", "client public IP", ip != null ? "OK" : "FAIL", ip != null ? ip : "could not determine", -1));
report.append("=== Client IP ===\nClient public IP: ").append(result).append('\n');
return result;
}
static String parseIp(String snippet) {
if (snippet == null) {
return null;
}
try {
int start = snippet.indexOf("\"ip\"");
if (start < 0) {
return null;
}
int colon = snippet.indexOf(':', start);
int q1 = snippet.indexOf('"', colon);
int q2 = snippet.indexOf('"', q1 + 1);
if (q1 < 0 || q2 < 0) {
return null;
}
String ip = snippet.substring(q1 + 1, q2);
return ip.matches("[\\d.]+") ? ip : null;
} catch (Exception e) {
return null;
}
}
private void submit(DiagCheck c) {
checks.add(c);
report.append(c.renderLine()).append('\n');
if (onCheck != null) {
onCheck.accept(c);
}
if (onLine != null) {
onLine.accept(c.renderLine());
}
}
private static String timestamp() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
}
}
@@ -1,69 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
public final class ReportSender {
public record SendResult(boolean ok, String endpoint, String detail) {
}
private ReportSender() {
}
public static String buildFilename(String clientIp) {
String ts = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
String ip = (clientIp == null || clientIp.isBlank()) ? "unknown" : clientIp;
return "diag_" + ts + "_" + ip + ".log";
}
public static SendResult send(Path logFile, String clientIp) throws IOException {
String content = Files.readString(logFile, StandardCharsets.UTF_8);
String name = buildFilename(clientIp);
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8);
List<String> endpoints = List.of(
DiagConstants.PRIMARY_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded,
DiagConstants.DIRECT_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded
);
String lastDetail = null;
for (String endpoint : endpoints) {
try {
HttpURLConnection conn = (HttpURLConnection) URI.create(endpoint).toURL().openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "text/plain; charset=utf-8");
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
byte[] body = content.getBytes(StandardCharsets.UTF_8);
conn.setFixedLengthStreamingMode(body.length);
try (OutputStream out = conn.getOutputStream()) {
out.write(body);
}
int status = conn.getResponseCode();
boolean ok = status >= 200 && status < 300;
String detail = "HTTP " + status;
conn.disconnect();
return new SendResult(ok, endpoint, detail);
} catch (Exception e) {
lastDetail = e.getMessage();
}
}
return new SendResult(false, lastEndpointUsed(endpoints), lastDetail != null ? lastDetail : "all endpoints failed");
}
private static String lastEndpointUsed(List<String> endpoints) {
return endpoints.get(endpoints.size() - 1);
}
}
@@ -1,35 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DiagCheckTest {
@Test
void renderLine_ok() {
DiagCheck c = new DiagCheck("DNS", "api.zernmc.ru / system", "OK", "87.120.187.36", 12);
String line = c.renderLine();
assertTrue(line.contains("[OK]"));
assertTrue(line.contains("api.zernmc.ru / system"));
assertTrue(line.contains("12 ms"));
assertTrue(line.contains("87.120.187.36"));
}
@Test
void renderLine_fail() {
DiagCheck c = new DiagCheck("TCP", "1.2.3.4:443", "FAIL", "connection refused", -1);
String line = c.renderLine();
assertTrue(line.contains("[FAIL]"));
assertFalse(line.contains("ms)"));
assertTrue(line.contains("connection refused"));
}
@Test
void ok_returnsStatus() {
assertTrue(new DiagCheck("a", "b", "OK", null, -1).ok());
assertFalse(new DiagCheck("a", "b", "FAIL", null, -1).ok());
}
}
@@ -1,52 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DnsResolverTest {
@Test
void resolveToCheck_emptyIsFail() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "Google 8.8.8.8", List.of());
assertEquals("FAIL", c.status());
assertEquals("no A records", c.detail());
}
@Test
void resolveToCheck_knownIpIsOk() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("87.120.187.36"));
assertEquals("OK", c.status());
assertTrue(c.detail().contains("87.120.187.36"));
}
@Test
void resolveToCheck_unknownIpIsFail() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("1.2.3.4"));
assertEquals("FAIL", c.status());
assertTrue(c.detail().contains("DIFFERENT from known"));
assertTrue(c.detail().contains("1.2.3.4"));
}
@Test
void resolveToCheck_multipleIpsOneKnown() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("zern.cc", "system", List.of("1.2.3.4", "87.120.187.36"));
assertEquals("OK", c.status());
}
@Test
void parseIp_valid() {
assertEquals("1.2.3.4", NetworkDiagnostics.parseIp("{\"ip\":\"1.2.3.4\"}"));
}
@Test
void parseIp_invalidReturnsNull() {
assertEquals(null, NetworkDiagnostics.parseIp("not json"));
assertEquals(null, NetworkDiagnostics.parseIp("{\"other\":\"x\"}"));
assertEquals(null, NetworkDiagnostics.parseIp(null));
assertEquals(null, NetworkDiagnostics.parseIp("{\"ip\":\"not-an-ip\"}"));
}
}
@@ -1,24 +0,0 @@
package me.sashegdev.zernmc.launcher.diag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ReportSenderTest {
@Test
void buildFilename_containsTimestampAndIp() {
String name = ReportSender.buildFilename("1.2.3.4");
assertTrue(name.matches("diag_\\d{8}_\\d{6}_[\\d.]+[.]log"), name);
assertTrue(name.contains("1.2.3.4"));
}
@Test
void buildFilename_unknownIp() {
String name = ReportSender.buildFilename(null);
assertTrue(name.contains("unknown"), name);
assertTrue(name.matches("diag_\\d{8}_\\d{6}_unknown[.]log"), name);
String blank = ReportSender.buildFilename(" ");
assertTrue(blank.contains("unknown"), blank);
}
}
+30 -229
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>me.sashegdev</groupId> <groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId> <artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version> <version>1.0.9</version>
</parent> </parent>
<artifactId>zernmclauncher</artifactId> <artifactId>zernmclauncher</artifactId>
@@ -61,37 +61,32 @@
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>javafx-controls</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
<scope>provided</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId> <artifactId>javafx-web</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
<scope>provided</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId> <artifactId>javafx-graphics</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
<scope>provided</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId> <artifactId>javafx-base</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
<scope>provided</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId> <artifactId>javafx-media</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
<scope>provided</scope>
</dependency> </dependency>
<!-- Test --> <!-- Test -->
@@ -104,15 +99,6 @@
</dependencies> </dependencies>
<build> <build>
<resources>
<resource>
<directory>src/resources</directory>
<targetPath>assets</targetPath>
<includes>
<include>ui/**</include>
</includes>
</resource>
</resources>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
@@ -130,7 +116,7 @@
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass> <mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<manifestEntries> <manifestEntries>
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version> <Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title> <Implementation-Title>ZernMC Launcher</Implementation-Title>
</manifestEntries> </manifestEntries>
</transformer> </transformer>
@@ -146,114 +132,32 @@
<artifactId>launch4j-maven-plugin</artifactId> <artifactId>launch4j-maven-plugin</artifactId>
<version>2.5.0</version> <version>2.5.0</version>
<executions> <executions>
<!-- GUI версия (основная) - без консоли -->
<execution> <execution>
<id>l4j-gui</id> <id>l4j</id>
<phase>package</phase> <phase>package</phase>
<goals> <goals>
<goal>launch4j</goal> <goal>launch4j</goal>
</goals> </goals>
<configuration> <configuration>
<outfile>../../server/builds/zernmc.exe</outfile> <outfile>../../server/builds/zernmc-${project.version}.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar> <jar>../../server/builds/zernmc-bootstrap.jar</jar>
<icon>${project.basedir}/src/main/icons/zernmc.ico</icon> <headerType>console</headerType>
<headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar> <dontWrapJar>false</dontWrapJar>
<classPath> <mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<addDependencies>false</addDependencies>
</classPath>
<cmdLine>--jfx</cmdLine>
<jre> <jre>
<path>lib/jre21</path> <path>lib/jre21</path>
<minVersion>21</minVersion> <minVersion>21</minVersion>
</jre> </jre>
<versionInfo> <versionInfo>
<fileVersion>${project.version}.${hotfix}</fileVersion> <fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion> <txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher</fileDescription> <fileDescription>ZernMC Launcher</fileDescription>
<copyright>Copyright (c) 2023-2026 ZernMC</copyright> <productVersion>${project.version}.0</productVersion>
<productVersion>${project.version}.${hotfix}</productVersion> <txtProductVersion>${project.version}</txtProductVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC</productName> <productName>ZernMC</productName>
<companyName>ZernMC</companyName> <companyName>ZernMC</companyName>
<trademarks>ZernMC</trademarks>
<internalName>zernmc</internalName> <internalName>zernmc</internalName>
<originalFilename>zernmc.exe</originalFilename> <originalFilename>zernmc-${project.version}.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
<!-- CLI версия - с консолью -->
<execution>
<id>l4j-cli</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc-cli.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<addDependencies>false</addDependencies>
</classPath>
<cmdLine>--cli</cmdLine>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher CLI</fileDescription>
<copyright>Copyright (c) 2023-2026 ZernMC</copyright>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC CLI</productName>
<companyName>ZernMC</companyName>
<trademarks>ZernMC</trademarks>
<internalName>zernmc-cli</internalName>
<originalFilename>zernmc-cli.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
<!-- CLI+JFX версия: консоль видима + форсим JFX UI -->
<execution>
<id>l4j-cli-jfx</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc-cli-jfx.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<addDependencies>false</addDependencies>
</classPath>
<cmdLine>--jfx</cmdLine>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher CLI + JFX</fileDescription>
<copyright>Copyright (c) 2023-2026 ZernMC</copyright>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC CLI JFX</productName>
<companyName>ZernMC</companyName>
<trademarks>ZernMC</trademarks>
<internalName>zernmc-cli-jfx</internalName>
<originalFilename>zernmc-cli-jfx.exe</originalFilename>
</versionInfo> </versionInfo>
</configuration> </configuration>
</execution> </execution>
@@ -271,7 +175,7 @@
<goals><goal>run</goal></goals> <goals><goal>run</goal></goals>
<configuration> <configuration>
<target> <target>
<echo file="../../server/builds/build.version">${project.version}.${hotfix}</echo> <echo file="../../server/builds/build.version">${project.version}</echo>
<!-- Удаляем старую папку lib если есть --> <!-- Удаляем старую папку lib если есть -->
<delete dir="../../server/builds/lib"/> <delete dir="../../server/builds/lib"/>
@@ -290,31 +194,35 @@
<!-- Копируем JavaFX модули в lib/javafx --> <!-- Копируем JavaFX модули в lib/javafx -->
<mkdir dir="../../server/builds/lib/javafx"/> <mkdir dir="../../server/builds/lib/javafx"/>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/23.0.1"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/23.0.1"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/23.0.1"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/23.0.1"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/23.0.1"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<!-- Переименовываем exe для zip -->
<move file="../../server/builds/zernmc-${project.version}.exe"
tofile="../../server/builds/zernmc.exe" overwrite="true"/>
<!-- Создаем папку bin и копируем JAR --> <!-- Создаем папку bin и копируем JAR -->
<mkdir dir="../../server/builds/bin"/> <mkdir dir="../../server/builds/bin"/>
<copy file="../../server/builds/zernmclauncher.jar" <copy file="../../server/builds/zernmclauncher.jar"
@@ -328,118 +236,11 @@
</fileset> </fileset>
</copy> </copy>
<!-- Создаём zip -->
<!-- Подпись экзешников самоподписным сертификатом Zern.cc (publisher: ZernMC Launcher) --> <zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
<loadfile property="pfxpass" srcFile="/root/cert/codesign/.pfxpass">
<filterchain>
<striplinebreaks/>
</filterchain>
</loadfile>
<exec executable="java" failonerror="true">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/zernmc.exe"/>
</exec>
<exec executable="java" failonerror="true">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/zernmc-cli.exe"/>
</exec>
<exec executable="java" failonerror="true">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/zernmc-cli-jfx.exe"/>
</exec>
<!-- Создаём README -->
<echo file="../../server/builds/README.txt">
ZernMC Launcher
Files:
- zernmc.exe - Main launcher with GUI (default)
- zernmc-cli.exe - CLI version (console, architecture-independent)
- zernmc-cli-jfx.exe - CLI + JFX UI (console visible + JavaFX frontend)
How to use:
Just run zernmc.exe (or zernmc-cli.exe / zernmc-cli-jfx.exe for CLI)
</echo>
<!-- Создаём один архив со всем -->
<zip destfile="../../server/builds/ZernMC-win-${revision}.${hotfix}.zip"
basedir="../../server/builds" basedir="../../server/builds"
includes="zernmc.exe,zernmc-cli.exe,zernmc-cli-jfx.exe,bin/**,assets/**,lib/**,README.txt" includes="zernmc.exe,bin/**,assets/**,lib/**"
excludes="build.version,*.jar"/> excludes="build.version,*-${project.version}.*,zernmclauncher.jar,zernmc-bootstrap.jar"/>
<!-- Go online/offline инсталляторы (нативные, без JRE) -->
<exec executable="bash" failonerror="false">
<arg value="-c"/>
<arg value="if command -v /usr/local/go/bin/go >/dev/null 2>&amp;1; then echo 'Building Go installers (Go absolute)...'; mkdir -p ../../server/builds; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; cd ../../installer &amp;&amp; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe . 2>&amp;1 || echo 'Go online build failed'; cd ../launcher/launcher; echo 'Building offline with embedded ZIP...'; cp ../../server/builds/ZernMC-win-${revision}.${hotfix}.zip ../../installer/offline.zip 2>/dev/null || cp ../../server/builds/ZernMC-win-1.1.1.4.zip ../../installer/offline.zip 2>/dev/null || true; cd ../../installer &amp;&amp; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe . 2>&amp;1 || echo 'Go offline build failed'; cd ../launcher/launcher; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; fi"/>
</exec>
<!-- Подпись Go инсталляторов -->
<exec executable="java" failonerror="false">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe"/>
</exec>
<exec executable="java" failonerror="false">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe"/>
</exec>
</target> </target>
</configuration> </configuration>
</execution> </execution>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

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

Before

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

@@ -10,12 +10,11 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
public class Bootstrap { public class Bootstrap {
private static final String VERSION_FILE = "build.version"; private static final String VERSION_FILE = "build.version";
private static final String JAR_NAME = "ZernMCLauncher.jar"; private static final String JAR_NAME = "ZernMCLauncher.jar";
private static final String BASE_URL = "https://api.zernmc.ru"; private static final String BASE_URL = "http://87.120.187.36:1582";
private static Path baseDir; private static Path baseDir;
private static Path logDir; private static Path logDir;
@@ -31,8 +30,6 @@ public class Bootstrap {
boolean cliMode = argList.contains("--cli"); boolean cliMode = argList.contains("--cli");
boolean jfxMode = !cliMode; boolean jfxMode = !cliMode;
cleanupStaleUpdates();
String currentVersion = readCurrentVersion(); String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion(); String serverVersion = getServerVersion();
@@ -41,17 +38,7 @@ public class Bootstrap {
if (isNewer(serverVersion, currentVersion)) { if (isNewer(serverVersion, currentVersion)) {
log("Update available!"); log("Update available!");
updateJar(serverVersion); downloadUpdate(serverVersion);
Path ownExe = getOwnExe();
if (ownExe != null) {
log("Self-updating bootstrap...");
selfUpdate(ownExe);
return;
}
Files.writeString(baseDir.resolve(VERSION_FILE), serverVersion);
log("Updated to v" + serverVersion);
} else { } else {
log("Version is up to date"); log("Version is up to date");
} }
@@ -63,109 +50,15 @@ public class Bootstrap {
} }
} }
private static void cleanupStaleUpdates() { private static void log(String msg) {
try (Stream<Path> files = Files.list(baseDir)) { String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
files.filter(p -> p.toString().endsWith(".update")) System.out.println(entry);
.forEach(p -> { try {
try { Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
Files.deleteIfExists(p); StandardOpenOption.CREATE, StandardOpenOption.APPEND);
log("Cleaned stale: " + p.getFileName());
} catch (Exception ignored) {}
});
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
private static void updateJar(String newVersion) throws Exception {
Path jarFile = baseDir.resolve(JAR_NAME);
Path jarUpdate = baseDir.resolve(JAR_NAME + ".update");
if (Files.exists(jarFile)) {
try {
Files.move(jarFile, jarUpdate, StandardCopyOption.REPLACE_EXISTING);
log("Renamed " + JAR_NAME + "" + JAR_NAME + ".update");
} catch (Exception e) {
log("Could not rename " + JAR_NAME + ": " + e.getMessage());
}
}
downloadFile(BASE_URL + "/launcher/download/jar", jarFile);
log("Downloaded new " + JAR_NAME);
try {
Files.deleteIfExists(jarUpdate);
log("Removed " + JAR_NAME + ".update");
} catch (Exception e) {
log("Could not remove " + JAR_NAME + ".update: " + e.getMessage());
}
Files.writeString(baseDir.resolve(VERSION_FILE), newVersion);
}
private static void selfUpdate(Path exePath) throws Exception {
String exeName = exePath.getFileName().toString();
Path exeUpdate = baseDir.resolve(exeName + ".update");
try {
Files.move(exePath, exeUpdate, StandardCopyOption.REPLACE_EXISTING);
log("Renamed " + exeName + "" + exeName + ".update");
} catch (Exception e) {
log("Could not rename " + exeName + ": " + e.getMessage());
return;
}
downloadFile(BASE_URL + "/launcher/download/exe", exePath);
log("Launching new " + exeName + "...");
new ProcessBuilder(exePath.toAbsolutePath().toString())
.directory(baseDir.toFile())
.inheritIO()
.start();
Thread.sleep(500);
log("Exiting old process");
System.exit(0);
}
private static Path getOwnExe() {
try {
String cmd = ProcessHandle.current().info().command().orElse(null);
if (cmd != null) {
Path p = Paths.get(cmd);
String name = p.getFileName().toString().toLowerCase();
if (name.contains("zernmc")) return p;
}
} catch (Exception ignored) {}
for (String name : Arrays.asList("zernmc.exe", "zernmc-cli.exe")) {
Path p = baseDir.resolve(name);
if (Files.exists(p)) return p;
}
return null;
}
private static void downloadFile(String urlStr, Path target) throws Exception {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) {
try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(target.toFile())) {
byte[] buf = new byte[8192];
int len;
long total = 0;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
total += len;
System.out.print("\rDownloaded: " + (total / 1024 / 1024) + " MB");
}
}
System.out.println();
} else {
throw new IOException("Server returned code: " + conn.getResponseCode());
}
}
private static String readCurrentVersion() { private static String readCurrentVersion() {
Path f = baseDir.resolve(VERSION_FILE); Path f = baseDir.resolve(VERSION_FILE);
try { try {
@@ -176,7 +69,7 @@ public class Bootstrap {
private static String getServerVersion() { private static String getServerVersion() {
try { try {
URL url = new URL(BASE_URL + "/launcher/version"); URL url = new URL(BASE_URL.replace("download?type=jar", "version"));
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET"); conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) { if (conn.getResponseCode() == 200) {
@@ -195,25 +88,50 @@ public class Bootstrap {
try { try {
String[] sa = server.split("\\."); String[] sa = server.split("\\.");
String[] ca = current.split("\\."); String[] ca = current.split("\\.");
int max = Math.max(sa.length, ca.length); for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
for (int i = 0; i < max; i++) { int sv = Integer.parseInt(sa[i]);
int sv = i < sa.length ? Integer.parseInt(sa[i]) : 0; int cv = Integer.parseInt(ca[i]);
int cv = i < ca.length ? Integer.parseInt(ca[i]) : 0;
if (sv > cv) return true; if (sv > cv) return true;
if (sv < cv) return false; if (sv < cv) return false;
} }
return false; return sa.length > ca.length;
} catch (Exception ignored) {} } catch (Exception ignored) {}
return false; return false;
} }
private static void log(String msg) { private static void downloadUpdate(String newVersion) throws Exception {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg; URL url = new URL(BASE_URL + "/launcher/download/jar");
System.out.println(entry); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
try { conn.setRequestMethod("GET");
Files.writeString(logDir.resolve("latest.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND); if (conn.getResponseCode() == 200) {
} catch (Exception ignored) {} 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 { private static void launchJFX() throws Exception {
@@ -22,8 +22,6 @@ public class Main {
System.setProperty("java.stderr.encoding", "UTF-8"); System.setProperty("java.stderr.encoding", "UTF-8");
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true"); System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
LauncherLogger.init();
if (System.getProperty("os.name").toLowerCase().contains("windows")) { if (System.getProperty("os.name").toLowerCase().contains("windows")) {
try { try {
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor(); new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
@@ -31,17 +29,13 @@ public class Main {
} }
ZAnsi.install(); ZAnsi.install();
LauncherLogger.info("Starting ZernMC Launcher " + CURRENT_VERSION); System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
List<String> argList = List.of(args); List<String> argList = List.of(args);
boolean jfxMode = argList.contains("--jfx"); boolean jfxMode = argList.contains("--jfx");
boolean cliMode = argList.contains("--cli"); boolean cliMode = argList.contains("--cli");
if (argList.contains("--offline")) {
System.setProperty("zernmc.offline", "true");
LauncherLogger.info("Offline mode requested (--offline)");
}
if (jfxMode) { if (jfxMode) {
launchJFX(); launchJFX();
return; return;
@@ -55,7 +49,7 @@ public class Main {
private static void launchJFX() { private static void launchJFX() {
try { try {
System.setProperty("javafx.runtime.version", "23.0.1"); System.setProperty("javafx.runtime.version", "21");
JFXLauncher.main(new String[]{}); JFXLauncher.main(new String[]{});
} catch (Exception e) { } catch (Exception e) {
@@ -70,7 +64,6 @@ public class Main {
} }
private static void startCLI() throws IOException { private static void startCLI() throws IOException {
DomainSelector.selectBest();
ZHttpClient.checkAllServicesOnStartup(true); ZHttpClient.checkAllServicesOnStartup(true);
System.out.println(ZAnsi.cyan("Checking authorization...")); System.out.println(ZAnsi.cyan("Checking authorization..."));
@@ -102,7 +95,41 @@ public class Main {
} }
private static void mainLoop() throws Exception { private static void mainLoop() throws Exception {
globalFlow(); 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 { private static void globalFlow() throws Exception {
@@ -3,7 +3,6 @@ package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.auth.AuthService; import me.sashegdev.zernmc.launcher.api.auth.AuthService;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService; import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService; import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.util.ArrayList; import java.util.ArrayList;
@@ -88,7 +87,7 @@ public class LauncherAPI {
} }
return ApiResponse.success(mcVersions); return ApiResponse.success(mcVersions);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.warn("[API] MC versions fetch failed: " + e.getMessage()); System.out.println("[API] MC versions fetch failed: " + e.getMessage());
} }
return ApiResponse.error("Failed to load Minecraft versions"); return ApiResponse.error("Failed to load Minecraft versions");
} }
@@ -114,7 +113,7 @@ public class LauncherAPI {
} }
idx = end; idx = end;
} }
versions.sort(LauncherAPI::compareVersions); versions.sort((a, b) -> b.compareTo(a));
break; break;
case "neoforge": case "neoforge":
String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml"); String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
@@ -129,32 +128,18 @@ public class LauncherAPI {
} }
neoidx = end; neoidx = end;
} }
versions.sort(LauncherAPI::compareVersions); versions.sort((a, b) -> b.compareTo(a));
break; break;
default: default:
break; break;
} }
return ApiResponse.success(versions); return ApiResponse.success(versions);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.warn("[API] Loader versions fetch failed: " + e.getMessage()); System.out.println("[API] Loader versions fetch failed: " + e.getMessage());
return ApiResponse.error("Failed to load loader versions"); return ApiResponse.error("Failed to load loader versions");
} }
} }
private static int compareVersions(String a, String b) {
String[] partsA = a.split("\\.");
String[] partsB = b.split("\\.");
int max = Math.max(partsA.length, partsB.length);
for (int i = 0; i < max; i++) {
try {
int numA = i < partsA.length ? Integer.parseInt(partsA[i]) : 0;
int numB = i < partsB.length ? Integer.parseInt(partsB[i]) : 0;
if (numA != numB) return Integer.compare(numB, numA);
} catch (NumberFormatException ignored) {}
}
return 0;
}
private boolean isNeoForgeCompatible(String version, String mcVersion) { private boolean isNeoForgeCompatible(String version, String mcVersion) {
if (mcVersion.startsWith("1.21")) { if (mcVersion.startsWith("1.21")) {
return version.contains("1.21") && !version.contains("1.20"); return version.contains("1.21") && !version.contains("1.20");
@@ -168,31 +153,26 @@ public class LauncherAPI {
try { try {
String token = authService.getCurrentToken(); String token = authService.getCurrentToken();
if (token == null) { if (token == null) {
LauncherLogger.warn("getZernMCPacks: not logged in");
return ApiResponse.error("Not logged in"); return ApiResponse.error("Not logged in");
} }
String response = ZHttpClient.get("/packs"); String response = ZHttpClient.get("/packs");
org.json.JSONObject root = new org.json.JSONObject(response); org.json.JSONArray arr = new org.json.JSONArray(response);
org.json.JSONArray arr = root.optJSONArray("packs");
List<Map<String, String>> packs = new ArrayList<>(); List<Map<String, String>> packs = new ArrayList<>();
if (arr != null) { for (int i = 0; i < arr.length(); i++) {
for (int i = 0; i < arr.length(); i++) { org.json.JSONObject pack = arr.getJSONObject(i);
org.json.JSONObject pack = arr.getJSONObject(i); Map<String, String> packInfo = new java.util.HashMap<>();
Map<String, String> packInfo = new java.util.HashMap<>(); packInfo.put("name", pack.optString("name", ""));
packInfo.put("name", pack.optString("name", "")); packInfo.put("displayName", pack.optString("displayName", pack.optString("name", "")));
packInfo.put("displayName", pack.optString("displayName", pack.optString("name", ""))); packInfo.put("version", pack.optString("version", ""));
packInfo.put("version", pack.optString("version", "")); packInfo.put("mcVersion", pack.optString("mcVersion", ""));
packInfo.put("mcVersion", pack.optString("minecraft_version", "")); packInfo.put("loader", pack.optString("loader", "vanilla"));
packInfo.put("loader", pack.optString("loader_type", "vanilla")); packInfo.put("description", pack.optString("description", ""));
packInfo.put("description", pack.optString("description", "")); packs.add(packInfo);
packs.add(packInfo);
}
} }
LauncherLogger.info("getZernMCPacks: loaded " + packs.size() + " packs");
return ApiResponse.success(packs); return ApiResponse.success(packs);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.error("getZernMCPacks failed: " + e.getMessage()); System.out.println("[API] Packs fetch failed: " + e.getMessage());
return ApiResponse.error("Failed to load packs: " + e.getMessage()); return ApiResponse.error("Failed to load packs: " + e.getMessage());
} }
} }
@@ -1,7 +1,5 @@
package me.sashegdev.zernmc.launcher.api.auth; 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.api.ApiResponse;
import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
@@ -12,10 +10,8 @@ public class AuthService {
public ApiResponse<LoginResult> register(String username, String password) { public ApiResponse<LoginResult> register(String username, String password) {
try { try {
JsonObject json = new JsonObject(); String response = post("/auth/register",
json.addProperty("username", username); "{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}");
json.addProperty("password", password);
String response = post("/auth/register", json.toString());
// If registration succeeds, auto-login // If registration succeeds, auto-login
AuthManager.AuthResult result = AuthManager.login(username, password); AuthManager.AuthResult result = AuthManager.login(username, password);
@@ -26,15 +22,10 @@ public class AuthService {
return ApiResponse.error(result.error != null ? result.error : "Registration failed"); return ApiResponse.error(result.error != null ? result.error : "Registration failed");
} catch (Exception e) { } catch (Exception e) {
String msg = e.getMessage(); String msg = e.getMessage();
if (msg != null) { if (msg != null && msg.contains("HTTP 409")) {
java.util.regex.Matcher m = java.util.regex.Pattern.compile("HTTP (\\d{3})").matcher(msg); return ApiResponse.error("Username already taken");
if (m.find()) {
int code = Integer.parseInt(m.group(1));
String body = msg.substring(m.end()).trim();
return ApiResponse.error(AuthManager.friendlyHttpError(code, body));
}
} }
return ApiResponse.error(AuthManager.friendlyConnectionError(e)); return ApiResponse.error("Registration error: " + msg);
} }
} }
@@ -45,9 +36,9 @@ public class AuthService {
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken()); LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
return ApiResponse.success(loginResult); return ApiResponse.success(loginResult);
} }
return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль"); return ApiResponse.error(result.error != null ? result.error : "Invalid login or password");
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error(AuthManager.friendlyConnectionError(e)); return ApiResponse.error("Auth error: " + e.getMessage());
} }
} }
@@ -81,10 +72,8 @@ public class AuthService {
public ApiResponse<Boolean> activatePass(String passCode) { public ApiResponse<Boolean> activatePass(String passCode) {
try { try {
JsonObject json = new JsonObject(); String response = post("/auth/pass/activate",
json.addProperty("pass_code", passCode); "{\"code\":\"" + passCode + "\"}");
String response = post("/auth/pass/activate", json.toString());
AuthManager.refreshUserInfo();
return ApiResponse.success(true); return ApiResponse.success(true);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Pass activation error: " + e.getMessage()); return ApiResponse.error("Pass activation error: " + e.getMessage());
@@ -6,9 +6,8 @@ import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
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.JFXBridge; import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
@@ -24,7 +23,7 @@ public class LaunchService {
static { static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> { Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LauncherLogger.info("[LAUNCH] Shutting down all running processes..."); System.out.println("[LAUNCH] Shutting down all running processes...");
runningProcesses.values().forEach(p -> { runningProcesses.values().forEach(p -> {
try { try {
p.destroy(); p.destroy();
@@ -40,7 +39,6 @@ public class LaunchService {
return ApiResponse.error("Pack not found: " + instanceName); return ApiResponse.error("Pack not found: " + instanceName);
} }
LauncherLogger.info("Preparing launch for: " + instanceName);
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions(); LaunchOptions options = createOptions();
@@ -53,7 +51,6 @@ public class LaunchService {
); );
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.error("Error preparing launch for " + instanceName, e);
return ApiResponse.error("Error preparing launch: " + e.getMessage()); return ApiResponse.error("Error preparing launch: " + e.getMessage());
} }
} }
@@ -65,52 +62,16 @@ public class LaunchService {
return ApiResponse.error("Pack not found: " + instanceName); return ApiResponse.error("Pack not found: " + instanceName);
} }
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")"); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions(); LaunchOptions options = createOptions();
options.setUsername(AuthManager.getUsername()); options.setUsername(AuthManager.getUsername());
options.setAccessToken(AuthManager.getAccessToken()); options.setAccessToken(AuthManager.getAccessToken());
options.setUuid(AuthManager.getUuid()); options.setUuid(AuthManager.getUuid());
return launchProcess(instance, instanceName, options);
} catch (Exception e) {
LauncherLogger.error("Launch error for " + instanceName, e);
return ApiResponse.error("Launch error: " + e.getMessage());
}
}
public ApiResponse<ProcessInfo> launchOffline(String instanceName, String nickname) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName);
}
LauncherLogger.info("Offline launch: " + instanceName);
String safeName = (nickname == null || nickname.isBlank()) ? "Player" : nickname.trim();
LaunchOptions options = createOptions();
options.setUsername(safeName);
options.setAccessToken("0");
options.setUuid(java.util.UUID.nameUUIDFromBytes(
("OfflinePlayer:" + safeName).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString());
return launchProcess(instance, instanceName, options);
} catch (Exception e) {
LauncherLogger.error("Offline launch error for " + instanceName, e);
return ApiResponse.error("Offline launch error: " + e.getMessage());
}
}
private ApiResponse<ProcessInfo> launchProcess(Instance instance, String instanceName, LaunchOptions options) {
try {
long t0 = System.currentTimeMillis();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options); List<String> command = builder.build(options);
LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms"); System.out.println("[LAUNCH] Generated command for " + instanceName + ":");
LauncherLogger.info("Generated command for " + instanceName + ":"); command.forEach(arg -> System.out.println(" " + arg));
command.forEach(arg -> LauncherLogger.debug(" " + arg));
long t1 = System.currentTimeMillis();
ProcessBuilder processBuilder = new ProcessBuilder(command); ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile()); processBuilder.directory(instance.getPath().toFile());
processBuilder.redirectErrorStream(true); processBuilder.redirectErrorStream(true);
@@ -121,10 +82,9 @@ public class LaunchService {
Process process = processBuilder.start(); Process process = processBuilder.start();
long pid = process.pid(); long pid = process.pid();
LauncherLogger.info("Launch: process started in " + (System.currentTimeMillis() - t1) + " ms (total " + (System.currentTimeMillis() - t0) + " ms), pid=" + pid);
runningProcesses.put(pid, process); runningProcesses.put(pid, process);
LauncherLogger.info("Process started, pid=" + pid); System.out.println("[LAUNCH] Process started, pid=" + pid);
java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true); java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true);
@@ -133,14 +93,14 @@ public class LaunchService {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line; String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line;
JFXBridge.appendGameLog(line); JFXLauncher.appendGameLog(line);
try { try {
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8)); logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
logFileOut.flush(); logFileOut.flush();
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
} catch (Exception e) { } catch (Exception e) {
JFXBridge.appendGameLog("[Error reading logs: " + e.getMessage() + "]"); JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
} finally { } finally {
try { logFileOut.close(); } catch (Exception ignored) {} try { logFileOut.close(); } catch (Exception ignored) {}
} }
@@ -148,30 +108,14 @@ public class LaunchService {
logReader.setDaemon(true); logReader.setDaemon(true);
logReader.start(); logReader.start();
// Watch the child on a DAEMON thread via Process.waitFor() instead of process.onExit().thenRun(() -> {
// process.onExit().thenRun(...): Process.onExit() completes its action on runningProcesses.remove(pid);
// the common ForkJoinPool, whose worker threads are non-daemon and keep JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
// the launcher JVM alive on exit (the v1.0.14.2 "JFX hang (ForkJoinPool)" });
// regression, re-introduced in v1.0.14.3's sendBounded). A daemon watcher
// does not prevent JVM shutdown and avoids pinning the common pool.
Thread processWatcher = new Thread(() -> {
try {
int exitCode = process.waitFor();
runningProcesses.remove(pid);
JFXBridge.appendGameLog("[Minecraft exited with code: " + exitCode + "]");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
JFXBridge.appendGameLog("[Error watching process: " + e.getMessage() + "]");
}
}, "ProcessWatcher-" + instanceName);
processWatcher.setDaemon(true);
processWatcher.start();
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING"); ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.error("Launch error for " + instanceName, e);
return ApiResponse.error("Launch error: " + e.getMessage()); return ApiResponse.error("Launch error: " + e.getMessage());
} }
} }
@@ -227,21 +171,15 @@ public class LaunchService {
options.setWidth(Config.getWindowWidth()); options.setWidth(Config.getWindowWidth());
options.setHeight(Config.getWindowHeight()); options.setHeight(Config.getWindowHeight());
options.setJavaPath(Config.getJavaPath()); 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(); String args = Config.getExtraJvmArgs();
if (args != null && !args.isEmpty()) { if (args != null && !args.isEmpty()) {
for (String arg : args.split("\n")) { List<String> extraArgs = new ArrayList<>();
for (String arg : args.split("\\s+")) {
arg = arg.trim(); arg = arg.trim();
if (!arg.isEmpty()) extraArgs.add(arg); if (!arg.isEmpty()) extraArgs.add(arg);
} }
options.setExtraJvmArgs(extraArgs);
} }
options.setExtraJvmArgs(extraArgs);
return options; return options;
} }
@@ -6,7 +6,6 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
import me.sashegdev.zernmc.launcher.utils.Config; 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.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
@@ -37,61 +36,24 @@ public class AuthManager {
public static final String PERM_DOWNLOAD_PACK = "download_pack"; public static final String PERM_DOWNLOAD_PACK = "download_pack";
public static boolean loadSavedSession() { public static boolean loadSavedSession() {
if (!Files.exists(AUTH_FILE)) { if (!Files.exists(AUTH_FILE)) return false;
LauncherLogger.warn("loadSavedSession: auth.json not found at " + AUTH_FILE);
return false;
}
try { try {
String json = Files.readString(AUTH_FILE); String json = Files.readString(AUTH_FILE);
AuthSession loaded = GSON.fromJson(json, AuthSession.class); AuthSession loaded = GSON.fromJson(json, AuthSession.class);
if (loaded == null || loaded.accessToken == null) { if (loaded == null || loaded.accessToken == null) return false;
LauncherLogger.warn("loadSavedSession: invalid auth.json content, deleting");
Files.deleteIfExists(AUTH_FILE);
return false;
}
session = loaded; session = loaded;
LauncherLogger.info("loadSavedSession: loaded session for " + loaded.username userInfo = fetchUserInfo();
+ " expiresAt=" + loaded.expiresAt + " hasRefresh=" + (loaded.refreshToken != null));
refreshUserInfo();
if (isAccessTokenExpired()) { if (isAccessTokenExpired()) {
LauncherLogger.info("loadSavedSession: token expired, attempting refresh"); return tryRefresh();
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; return true;
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.error("loadSavedSession error: " + e.getMessage());
invalidateSession();
return false; return false;
} }
} }
public static boolean tryAutoLogin() {
if (isLoggedIn()) return true;
if (!Files.exists(AUTH_FILE)) return false;
boolean loaded = loadSavedSession();
if (loaded) {
refreshUserInfo();
}
return loaded;
}
public static AuthResult login(String username, String password) { public static AuthResult login(String username, String password) {
return authRequest("/auth/login", username, password); return authRequest("/auth/login", username, password);
} }
@@ -108,115 +70,49 @@ public class AuthManager {
if (resp.statusCode() == 200) { if (resp.statusCode() == 200) {
session = GSON.fromJson(resp.body(), AuthSession.class); session = GSON.fromJson(resp.body(), AuthSession.class);
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn; session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
LauncherLogger.info("authRequest: login successful, expiresAt=" + session.expiresAt
+ " hasRefresh=" + (session.refreshToken != null));
saveSession(); saveSession();
userInfo = fetchUserInfo(); userInfo = fetchUserInfo();
return AuthResult.ok(); return AuthResult.ok();
} else if (resp.statusCode() == 422) {
return AuthResult.fail("Validation error: " + extractError(resp.body()));
} else { } else {
return AuthResult.fail(friendlyHttpError(resp.statusCode(), resp.body())); return AuthResult.fail(extractError(resp.body()));
} }
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.warn("authRequest failed (" + endpoint + "): " + e.getMessage()); e.printStackTrace();
return AuthResult.fail(friendlyConnectionError(e)); return AuthResult.fail("Connection error: " + e.getMessage());
} }
} }
/**
* Maps an HTTP status code + raw body to a short, human-readable error message
* (in Russian) suitable for showing directly to the user.
*/
public static String friendlyHttpError(int statusCode, String body) {
switch (statusCode) {
case 401:
case 403:
return "Неверный логин или пароль";
case 404:
return "Сервис временно недоступен, попробуйте позже";
case 409:
return "Логин уже занят";
case 422:
return "Некорректные данные: " + extractError(body);
case 429:
return "Слишком много попыток. Подождите немного.";
default:
if (statusCode >= 500) {
return "Сервер временно недоступен, попробуйте позже";
}
}
String detail = extractError(body);
if (detail.isEmpty() || detail.startsWith("<")) {
return "Ошибка сервера (код " + statusCode + ")";
}
if (detail.length() > 120) {
detail = detail.substring(0, 117) + "...";
}
return detail;
}
/**
* Maps a network/IO exception to a short, human-readable message.
*/
public static String friendlyConnectionError(Exception e) {
String m = e == null ? "" : String.valueOf(e.getMessage()).toLowerCase();
if (e instanceof java.net.SocketTimeoutException
|| e instanceof java.net.ConnectException
|| e instanceof java.net.UnknownHostException
|| m.contains("timeout") || m.contains("connect") || m.contains("unknownhost")
|| m.contains("refused")) {
return "Нет соединения с сервером. Проверьте интернет.";
}
return "Ошибка соединения с сервером. Попробуйте позже.";
}
public static void logout() { public static void logout() {
if (session != null && session.refreshToken != null) { if (session != null && session.refreshToken != null) {
try { try {
JsonObject json = new JsonObject(); post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
json.addProperty("refresh_token", session.refreshToken); } catch (Exception ignored) {}
post("/auth/logout", json.toString());
} catch (Exception e) {
LauncherLogger.warn("Logout error: " + e.getMessage());
}
} }
session = null; session = null;
userInfo = null; userInfo = null;
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception e) { try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
LauncherLogger.warn("Failed to delete auth.json: " + e.getMessage());
}
} }
public static boolean isLoggedIn() { public static boolean isLoggedIn() {
return session != null && session.accessToken != null; return session != null && session.accessToken != null;
} }
public static boolean authFileExists() {
return Files.exists(AUTH_FILE);
}
public static String getUsername() { public static String getUsername() {
AuthSession localSession = session; return session != null ? session.username : "Player";
return localSession != null ? localSession.username : "Player";
} }
public static String getUuid() { public static String getUuid() {
AuthSession localSession = session; return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000";
return localSession != null ? localSession.uuid : "00000000-0000-0000-0000-000000000000";
} }
public static String getAccessToken() { public static String getAccessToken() {
AuthSession localSession = session; if (session == null) return "0";
if (localSession == null) return "0";
if (isAccessTokenExpired()) { if (isAccessTokenExpired()) {
boolean refreshed = tryRefresh(); tryRefresh();
if (!refreshed) {
localSession = session;
if (localSession == null) return "0";
return localSession.accessToken != null ? localSession.accessToken : "0";
}
} }
localSession = session; return session != null && session.accessToken != null ? session.accessToken : "0";
return localSession != null && localSession.accessToken != null ? localSession.accessToken : "0";
} }
private static boolean isAccessTokenExpired() { private static boolean isAccessTokenExpired() {
@@ -225,63 +121,32 @@ public class AuthManager {
} }
private static boolean tryRefresh() { private static boolean tryRefresh() {
if (session == null) { if (session == null || session.refreshToken == null) return false;
LauncherLogger.warn("tryRefresh: session is null");
return false;
}
if (session.refreshToken == null) {
LauncherLogger.warn("tryRefresh: no refreshToken in session");
return false;
}
try { try {
JsonObject json = new JsonObject(); String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
json.addProperty("refresh_token", session.refreshToken); SimpleHttpResponse resp = post("/auth/refresh", body);
SimpleHttpResponse resp = post("/auth/refresh", json.toString());
if (resp.statusCode() == 200) { if (resp.statusCode() == 200) {
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class); AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn; newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
session = newSession; session = newSession;
userInfo = fetchUserInfo(); userInfo = fetchUserInfo();
if (userInfo != null) {
session.role = userInfo.role;
}
saveSession(); saveSession();
LauncherLogger.info("tryRefresh: token refreshed successfully");
return true; return true;
} }
} catch (Exception ignored) {}
if (resp.statusCode() == 401) {
LauncherLogger.warn("tryRefresh: server rejected refresh token (401)");
invalidateSession();
} else {
LauncherLogger.warn("tryRefresh: server returned " + resp.statusCode());
}
} catch (Exception e) {
LauncherLogger.warn("tryRefresh: network error: " + e.getMessage());
return false;
}
return false;
}
private static void invalidateSession() {
session = null; session = null;
userInfo = null; userInfo = null;
try { try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
Files.deleteIfExists(AUTH_FILE); return false;
LauncherLogger.info("Session invalidated, auth.json deleted");
} catch (Exception e) {
LauncherLogger.error("Failed to delete auth.json", e);
}
} }
private static void saveSession() { private static void saveSession() {
try { try {
Files.createDirectories(AUTH_FILE.getParent()); Files.createDirectories(AUTH_FILE.getParent());
Files.writeString(AUTH_FILE, GSON.toJson(session)); Files.writeString(AUTH_FILE, GSON.toJson(session));
LauncherLogger.info("Session saved to " + AUTH_FILE);
} catch (IOException e) { } catch (IOException e) {
LauncherLogger.error("Failed to save session", e); System.err.println(ZAnsi.yellow("Failed to save session: " + e.getMessage()));
} }
} }
@@ -315,25 +180,14 @@ public class AuthManager {
if (conn != null) conn.disconnect(); if (conn != null) conn.disconnect();
} }
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.warn("Failed to get UserInfo: " + e.getMessage()); System.err.println("Failed to get UserInfo: " + e.getMessage());
return null; return null;
} }
} }
public static boolean hasPass() { public static boolean hasPass() {
if (!isLoggedIn()) return false;
if (userInfo != null) return userInfo.has_pass; if (userInfo != null) return userInfo.has_pass;
if (getRole() >= ROLE_PASS_HOLDER) return true; return getRole() >= ROLE_PASS_HOLDER;
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() { public static boolean canViewPacks() {
@@ -423,30 +277,16 @@ public class AuthManager {
return body.length() > 200 ? body.substring(0, 200) + "..." : body; 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() { public static boolean hasActivePass() {
if (!isLoggedIn()) return false; if (!isLoggedIn()) return false;
return hasPass(); try {
String response = ZHttpClient.get("/auth/pass/my");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
return json.has("has_active") && json.get("has_active").getAsBoolean();
} catch (Exception e) {
System.err.println(ZAnsi.red("Failed to check pass: ") + e.getMessage());
return false;
}
} }
public static String getPassStatus() { public static String getPassStatus() {
@@ -465,7 +305,7 @@ public class AuthManager {
@SerializedName("access_token") public String accessToken; @SerializedName("access_token") public String accessToken;
@SerializedName("refresh_token") public String refreshToken; @SerializedName("refresh_token") public String refreshToken;
@SerializedName("expires_in") public int expiresIn; @SerializedName("expires_in") public int expiresIn;
public long expiresAt; public transient long expiresAt;
public String username; public String username;
public String uuid; public String uuid;
public int role; public int role;
@@ -501,20 +341,6 @@ public class AuthManager {
public static AuthResult ok() { return new AuthResult(true, null); } public static AuthResult ok() { return new AuthResult(true, null); }
public static AuthResult fail(String msg) { return new AuthResult(false, msg); } 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 { class SimpleHttpResponse {
@@ -26,7 +26,51 @@ import java.util.stream.Collectors;
public class LaunchMenu { public class LaunchMenu {
public void show() throws Exception { public void show() throws Exception {
showGlobal(); 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 { private boolean awaitActivePass() throws Exception {
@@ -86,6 +130,43 @@ public class LaunchMenu {
} }
} }
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 { private void showGlobal() throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
@@ -1,7 +1,5 @@
package me.sashegdev.zernmc.launcher.minecraft; package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import java.io.IOException; import java.io.IOException;
@@ -23,7 +21,6 @@ public class Instance {
private int serverVersion; // версия сборки на сервере private int serverVersion; // версия сборки на сервере
private String serverPackName; // имя пака на сервере private String serverPackName; // имя пака на сервере
private String fabricVersionId; private String fabricVersionId;
private String presetId;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
@@ -91,15 +88,6 @@ public class Instance {
saveMetadata(); saveMetadata();
} }
public String getPresetId() {
return presetId;
}
public void setPresetId(String presetId) {
this.presetId = presetId;
saveMetadata();
}
public String getServerPackName() { public String getServerPackName() {
return serverPackName; return serverPackName;
} }
@@ -142,17 +130,14 @@ public class Instance {
this.isServerPack = meta.isServerPack; this.isServerPack = meta.isServerPack;
this.serverVersion = meta.serverVersion; this.serverVersion = meta.serverVersion;
this.serverPackName = meta.serverPackName; this.serverPackName = meta.serverPackName;
this.presetId = meta.presetId; } catch (Exception ignored) {}
} catch (Exception e) {
LauncherLogger.warn("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
}
} }
private void saveMetadata() { private void saveMetadata() {
Path metaFile = path.resolve("instance.json"); Path metaFile = path.resolve("instance.json");
InstanceMeta meta = new InstanceMeta( InstanceMeta meta = new InstanceMeta(
minecraftVersion, loaderType, loaderVersion, assetIndex, minecraftVersion, loaderType, loaderVersion, assetIndex,
isServerPack, serverVersion, serverPackName, presetId isServerPack, serverVersion, serverPackName
); );
try { try {
Files.writeString(metaFile, GSON.toJson(meta)); Files.writeString(metaFile, GSON.toJson(meta));
@@ -169,13 +154,12 @@ public class Instance {
boolean isServerPack = false; boolean isServerPack = false;
int serverVersion = 0; int serverVersion = 0;
String serverPackName; String serverPackName;
String presetId;
public InstanceMeta(String minecraftVersion, String loaderType, public InstanceMeta(String minecraftVersion, String loaderType,
String loaderVersion, String assetIndex, String loaderVersion, String assetIndex,
boolean isServerPack, int serverVersion, boolean isServerPack, int serverVersion,
String serverPackName, String presetId) { String serverPackName) {
this.minecraftVersion = minecraftVersion; this.minecraftVersion = minecraftVersion;
this.loaderType = loaderType; this.loaderType = loaderType;
this.loaderVersion = loaderVersion; this.loaderVersion = loaderVersion;
@@ -183,7 +167,6 @@ public class Instance {
this.isServerPack = isServerPack; this.isServerPack = isServerPack;
this.serverVersion = serverVersion; this.serverVersion = serverVersion;
this.serverPackName = serverPackName; this.serverPackName = serverPackName;
this.presetId = presetId;
} }
} }
} }
@@ -1,7 +1,5 @@
package me.sashegdev.zernmc.launcher.minecraft; package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.Config;
import java.io.IOException; import java.io.IOException;
@@ -53,7 +51,7 @@ public class InstanceManager {
try { try {
Files.deleteIfExists(path); Files.deleteIfExists(path);
} catch (IOException e) { } catch (IOException e) {
LauncherLogger.warn("Не удалось удалить: " + path); System.err.println("Не удалось удалить: " + path);
} }
}); });
return true; return true;
@@ -1,13 +1,13 @@
package me.sashegdev.zernmc.launcher.minecraft; 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.ModLoaderInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; import me.sashegdev.zernmc.launcher.minecraft.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.JFXBridge; 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.BufferedReader;
@@ -17,8 +17,6 @@ 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;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
public class MinecraftLib { public class MinecraftLib {
@@ -44,13 +42,13 @@ public class MinecraftLib {
} }
public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception { public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception {
return new ModLoaderInstaller(instance) ForgeInstaller installer = new ForgeInstaller(instance);
.install(minecraftVersion, forgeVersion, ModLoaderInstaller.LoaderType.FORGE); return installer.install(minecraftVersion, forgeVersion);
} }
public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception { public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception {
return new ModLoaderInstaller(instance) NeoForgeInstaller installer = new NeoForgeInstaller(instance);
.install(minecraftVersion, neoforgeVersion, ModLoaderInstaller.LoaderType.NEOFORGE); return installer.install(minecraftVersion, neoforgeVersion);
} }
public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception { public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception {
@@ -71,12 +69,12 @@ public class MinecraftLib {
* Stub - will be expanded * 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 {
LauncherLogger.info("Starting full pack install: " + packName); System.out.println(ZAnsi.cyan("Starting full pack install: " + packName));
// 1. Install Minecraft // 1. Install Minecraft
boolean mcInstalled = installMinecraft(minecraftVersion); boolean mcInstalled = installMinecraft(minecraftVersion);
if (!mcInstalled) { if (!mcInstalled) {
LauncherLogger.error("Failed to install Minecraft " + minecraftVersion); System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion));
return false; return false;
} }
@@ -84,53 +82,44 @@ public class MinecraftLib {
if ("fabric".equalsIgnoreCase(loaderType)) { if ("fabric".equalsIgnoreCase(loaderType)) {
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion); boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
if (!fabricInstalled) { if (!fabricInstalled) {
LauncherLogger.error("Failed to install Fabric"); System.out.println(ZAnsi.brightRed("Failed to install Fabric"));
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(loaderType)) { } else if ("forge".equalsIgnoreCase(loaderType)) {
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion); boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
if (!forgeInstalled) { if (!forgeInstalled) {
LauncherLogger.error("Failed to install Forge"); System.out.println(ZAnsi.brightRed("Failed to install Forge"));
return false; return false;
} }
} else if ("neoforge".equalsIgnoreCase(loaderType)) { } else if ("neoforge".equalsIgnoreCase(loaderType)) {
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion); boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
if (!neoforgeInstalled) { if (!neoforgeInstalled) {
LauncherLogger.error("Failed to install NeoForge"); System.out.println(ZAnsi.brightRed("Failed to install NeoForge"));
return false; return false;
} }
} }
// 3. In the future: diff and mod download // 3. In the future: diff and mod download
LauncherLogger.info("Basic pack install complete!"); System.out.println(ZAnsi.brightGreen("Basic pack install complete!"));
return true; return true;
} }
//Launch //Launch
public void launch(LaunchOptions options) throws Exception { public void launch(LaunchOptions options) throws Exception {
LauncherLogger.info("Launching pack: " + instance.getName()); System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
cleanupOldLoaders(); cleanupOldLoaders();
validateJarFiles();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options); List<String> command = builder.build(options);
LauncherLogger.info("Launch command (" + command.size() + " args) written below"); System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):"));
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());
// Write launch command to file for debugging System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
Path cmdLogFile = instance.getPath().resolve("launch-command.log");
try {
Files.writeString(cmdLogFile, String.join(" \\\n ", command));
LauncherLogger.info("Launch command written to " + cmdLogFile.toAbsolutePath());
} catch (Exception e) {
LauncherLogger.warn("Failed to write launch command log: " + e.getMessage());
}
LauncherLogger.info("Starting Minecraft...");
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
Process process = pb.start(); Process process = pb.start();
@@ -140,10 +129,10 @@ public class MinecraftLib {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
JFXBridge.appendGameLog(line); JFXLauncher.appendGameLog(line);
} }
} catch (Exception e) { } catch (Exception e) {
JFXBridge.appendGameLog("[Error reading output: " + e.getMessage() + "]"); JFXLauncher.appendGameLog("[Error reading output: " + e.getMessage() + "]");
} }
}); });
outThread.setDaemon(true); outThread.setDaemon(true);
@@ -154,10 +143,10 @@ public class MinecraftLib {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
JFXBridge.appendGameLog("[ERR] " + line); JFXLauncher.appendGameLog("[ERR] " + line);
} }
} catch (Exception e) { } catch (Exception e) {
JFXBridge.appendGameLog("[Error reading stderr: " + e.getMessage() + "]"); JFXLauncher.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
} }
}); });
errThread.setDaemon(true); errThread.setDaemon(true);
@@ -167,19 +156,18 @@ public class MinecraftLib {
outThread.join(1000); outThread.join(1000);
errThread.join(1000); errThread.join(1000);
LauncherLogger.info("Minecraft exited with code: " + exitCode); System.out.println(ZAnsi.yellow("\nMinecraft exited with code: " + exitCode));
} }
private void safeDeleteDirectory(Path dir) { private void safeDeleteDirectory(Path dir) {
try (var stream = Files.walk(dir)) { try {
stream.sorted((a, b) -> b.compareTo(a)) Files.walk(dir)
.forEach(p -> { .sorted((a, b) -> b.compareTo(a))
try { Files.deleteIfExists(p); } .forEach(p -> {
catch (IOException e) { /* ignore */ } try { Files.deleteIfExists(p); }
}); catch (IOException ignored) {}
} catch (IOException e) { });
LauncherLogger.warn("safeDeleteDirectory: " + e.getMessage()); } catch (IOException ignored) {}
}
} }
private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException { private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException {
@@ -214,7 +202,7 @@ public class MinecraftLib {
if (currentLoaderVer == null) return; if (currentLoaderVer == null) return;
LauncherLogger.info("Cleaning old loader versions..."); System.out.println(ZAnsi.yellow("Cleaning old loader versions..."));
// Delete all old fabric-loader / forge // Delete all old fabric-loader / forge
Path libraries = instance.getPath().resolve("libraries"); Path libraries = instance.getPath().resolve("libraries");
@@ -234,40 +222,6 @@ public class MinecraftLib {
private void validateJarFiles() {
Path[] dirs = {
instance.getPath().resolve("mods"),
instance.getPath().resolve("libraries")
};
for (Path dir : dirs) {
if (!Files.exists(dir)) continue;
try (var stream = Files.walk(dir)) {
stream.filter(p -> p.toString().endsWith(".jar"))
.filter(p -> !isValidJar(p))
.forEach(p -> {
try {
LauncherLogger.warn("Removing corrupt JAR: " + instance.getPath().relativize(p));
Files.delete(p);
} catch (IOException e) {
LauncherLogger.error("Failed to delete: " + p.getFileName());
}
});
} catch (Exception e) {
LauncherLogger.warn("Error scanning for corrupt JARs: " + e.getMessage());
}
}
}
private boolean isValidJar(Path path) {
try (ZipFile zf = new ZipFile(path.toFile())) {
return true;
} catch (ZipException e) {
return false;
} catch (IOException e) {
return false;
}
}
public Instance getInstance() { public Instance getInstance() {
return instance; return instance;
} }
@@ -8,52 +8,33 @@ 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.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;
import java.io.*; import java.io.*;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class PackDownloader { public class PackDownloader {
@FunctionalInterface
public interface ProgressCallback {
void onProgress(String label, int percent, String stageName, int stageIndex, int stageCount);
}
private static final String USER_FILES_FILE = ".user-files.json";
private final Instance instance; private final Instance instance;
private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
private ProgressCallback progressCallback; 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;
} }
public void setProgressCallback(ProgressCallback callback) {
this.progressCallback = callback;
}
private void reportProgress(String label, int percent, String stageName, int stageIndex, int stageCount) {
if (progressCallback != null) {
try {
progressCallback.onProgress(label, percent, stageName, stageIndex, stageCount);
} catch (Exception ignored) {}
}
}
/** /**
* Get list of available packs from server * Get list of available packs from server
*/ */
@@ -137,7 +118,7 @@ public class PackDownloader {
result.add(new ServerPack(name, version, minecraftVersion, loaderType, result.add(new ServerPack(name, version, minecraftVersion, loaderType,
loaderVersion, updatedAt, filesCount)); loaderVersion, updatedAt, filesCount));
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.warn("Error parsing pack: " + e.getMessage()); System.err.println("Error parsing pack: " + e.getMessage());
} }
} }
@@ -156,151 +137,87 @@ public class PackDownloader {
* Install or update a pack from the server * 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 {
return installOrUpdatePack(packName, serverPack, null); System.out.println(ZAnsi.cyan("Installing pack " + packName + " from server..."));
}
public boolean installOrUpdatePack(String packName, ServerPack serverPack, String preset) throws Exception {
LauncherLogger.info("installOrUpdatePack: START packName=" + packName + " mc=" + serverPack.getMinecraftVersion() + " loader=" + serverPack.getLoaderType() + " preset=" + (preset != null ? preset : "none"));
reportProgress("Fetching manifest...", 0, "manifest", 0, 5);
// 1. Get manifest // 1. Get manifest
PackManifest manifest = getPackManifest(packName); PackManifest manifest = getPackManifest(packName);
LauncherLogger.info("installOrUpdatePack: manifest loaded version=" + manifest.getVersion() + " mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderType() + " files=" + (manifest.files == null ? "null" : manifest.files.size()));
// 2. First install Minecraft + Loader via MinecraftLib // 2. First install Minecraft + Loader via MinecraftLib
MinecraftLib lib = new MinecraftLib(instance); MinecraftLib lib = new MinecraftLib(instance);
LauncherLogger.info("installOrUpdatePack: checking Minecraft install. currentMc=" + instance.getMinecraftVersion() + " targetMc=" + 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());
if (needsMinecraftInstall) { if (needsMinecraftInstall) {
LauncherLogger.info("installOrUpdatePack: needs Minecraft install. loader=" + manifest.getLoaderType() + " loaderVer=" + manifest.getLoaderVersion());
reportProgress("Installing " + manifest.getMinecraftVersion() + " with " + manifest.getLoaderType() + "...", 10, "Installing pack files", 4, 5);
if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) { if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) {
LauncherLogger.info("installOrUpdatePack: installing Fabric mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion());
reportProgress("Installing Fabric " + manifest.getLoaderVersion() + "...", 15, "Installing pack files", 4, 5);
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success);
if (!success) { if (!success) {
LauncherLogger.error("[INSTALL] Failed to install Fabric"); System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
reportProgress("Failed to install Fabric", 0, "error", 0, 1);
return false; return false;
} }
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) { } else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
LauncherLogger.info("installOrUpdatePack: installing NeoForge mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion());
reportProgress("Installing NeoForge " + manifest.getLoaderVersion() + "...", 15, "Installing pack files", 4, 5);
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success);
if (!success) { if (!success) {
LauncherLogger.error("[INSTALL] Failed to install NeoForge"); System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
LauncherLogger.info("installOrUpdatePack: installing Forge mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion());
reportProgress("Installing Forge " + manifest.getLoaderVersion() + "...", 15, "Installing pack files", 4, 5);
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Forge install result=" + success);
if (!success) { if (!success) {
LauncherLogger.error("[INSTALL] Failed to install Forge"); System.err.println(ZAnsi.brightRed("Failed to install Forge"));
reportProgress("Failed to install Forge", 0, "error", 0, 1);
return false; return false;
} }
} else { } else {
LauncherLogger.info("installOrUpdatePack: installing Vanilla Minecraft " + manifest.getMinecraftVersion());
reportProgress("Installing Vanilla Minecraft...", 15, "Installing pack files", 4, 5);
boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success);
if (!success) { if (!success) {
LauncherLogger.error("[INSTALL] Failed to install Vanilla Minecraft"); System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
return false; return false;
} }
} }
LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully");
} else { } else {
LauncherLogger.info("Minecraft already installed, skipping..."); System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
} }
reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5);
// 3. Scan local files only if there are files to download // 3. Scan local files only if there are files to download
LauncherLogger.info("installOrUpdatePack: scanning local files...");
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
LauncherLogger.info("installOrUpdatePack: scanLocalFiles returned " + localFiles.size() + " files. instancePath=" + instance.getPath());
// If pack has no files (vanilla/loader only), skip diff // If pack has no files (vanilla/loader only), skip diff
if (manifest.files == null || manifest.files.isEmpty()) { if (manifest.files == null || manifest.files.isEmpty()) {
LauncherLogger.info("installOrUpdatePack: no files in manifest, skipping diff"); System.out.println(ZAnsi.green("Pack contains no additional files"));
LauncherLogger.info("Pack contains no additional files");
reportProgress("Installing...", 50, "Installing pack files", 4, 5);
saveUserFilesSnapshot(localFiles);
// Update instance metadata
instance.setServerPack(true); instance.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
instance.setMinecraftVersion(manifest.getMinecraftVersion()); instance.setMinecraftVersion(manifest.getMinecraftVersion());
instance.setLoaderType(manifest.getLoaderType()); instance.setLoaderType(manifest.getLoaderType());
instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(resolveAssetIndex(manifest)); instance.setAssetIndex(manifest.getAssetIndex());
reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5); System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
LauncherLogger.info("Pack installed successfully!");
return true; return true;
} }
LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff");
// 4. Send diff request // 4. Send diff request
LauncherLogger.info("installOrUpdatePack: requesting diff from server..."); System.out.println(ZAnsi.cyan("Checking pack files..."));
reportProgress("Checking pack files...", 40, "Installing pack files", 4, 5); DiffResponse diff = getDiff(packName, localFiles);
DiffResponse diff = null;
try {
diff = getDiff(packName, localFiles, preset);
LauncherLogger.info("installOrUpdatePack: diff received: toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " version=" + diff.getVersion());
} catch (Exception e) {
LauncherLogger.error("installOrUpdatePack: getDiff failed: " + e.getClass().getName() + " - " + e.getMessage());
e.printStackTrace();
throw e;
}
// 5. Detect user-modified files // 5. Apply changes
Set<String> protectedFiles = getUserModifiedFiles(localFiles); boolean success = applyDiff(diff, packName);
LauncherLogger.info("installOrUpdatePack: user-modified files count=" + protectedFiles.size());
// 6. Apply changes
LauncherLogger.info("installOrUpdatePack: starting applyDiff...");
reportProgress("Downloading pack files...", 50, "Installing pack files", 4, 5);
boolean success = false;
try {
success = applyDiff(diff, packName, protectedFiles);
LauncherLogger.info("installOrUpdatePack: applyDiff returned=" + success);
} catch (Exception e) {
LauncherLogger.error("installOrUpdatePack: applyDiff threw exception: " + e.getClass().getName() + " - " + e.getMessage());
e.printStackTrace();
throw e;
}
if (success) { if (success) {
LauncherLogger.info("installOrUpdatePack: saving user files snapshot..."); // 6. Update instance metadata
Map<String, String> updatedHashes = scanLocalFiles();
saveUserFilesSnapshot(updatedHashes);
LauncherLogger.info("installOrUpdatePack: updating instance metadata...");
instance.setServerPack(true); instance.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
instance.setMinecraftVersion(manifest.getMinecraftVersion()); instance.setMinecraftVersion(manifest.getMinecraftVersion());
instance.setLoaderType(manifest.getLoaderType()); instance.setLoaderType(manifest.getLoaderType());
instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(resolveAssetIndex(manifest)); instance.setAssetIndex(manifest.getAssetIndex());
LauncherLogger.info("Pack installed successfully!"); System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
LauncherLogger.info("installOrUpdatePack: SUCCESS");
} else {
LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception");
} }
return success; return success;
@@ -323,45 +240,30 @@ public class PackDownloader {
* Update an existing server pack * Update an existing server pack
*/ */
public boolean updatePack(String packName) throws Exception { public boolean updatePack(String packName) throws Exception {
return updatePack(packName, instance.getPresetId()); System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "..."));
}
public boolean updatePack(String packName, String preset) throws Exception {
LauncherLogger.info("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()) {
LauncherLogger.info("Pack is already up to date (v" + instance.getServerVersion() + ")"); System.out.println(ZAnsi.green("Pack is already up to date (v" + instance.getServerVersion() + ")"));
return true; return true;
} }
LauncherLogger.info("Update available: v" + instance.getServerVersion() + " -> v" + serverVersion); System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " v" + serverVersion));
// Scan local files // Scan local files
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
// Detect user-modified files (skip overwriting)
Set<String> protectedFiles = getUserModifiedFiles(localFiles);
if (!protectedFiles.isEmpty()) {
LauncherLogger.info("[DEBUG] Found " + protectedFiles.size() + " user-modified files (will not overwrite)");
LauncherLogger.info("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten");
}
// Get diff // Get diff
DiffResponse diff = getDiff(packName, localFiles, preset); DiffResponse diff = getDiff(packName, localFiles);
// Apply changes // Apply changes
boolean success = applyDiff(diff, packName, protectedFiles, preset); boolean success = applyDiff(diff, packName);
if (success) { if (success) {
// Save new user files snapshot
Map<String, String> updatedHashes = scanLocalFiles();
saveUserFilesSnapshot(updatedHashes);
instance.setServerVersion(serverVersion); instance.setServerVersion(serverVersion);
LauncherLogger.info("Pack updated to v" + serverVersion); System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
} }
return success; return success;
@@ -377,7 +279,7 @@ public class PackDownloader {
// Игнорируемые директории // Игнорируемые директории
Set<String> ignoredDirs = Set.of( Set<String> ignoredDirs = Set.of(
"resourcepacks", "shaderpacks", "saves", "logs", "resourcepacks", "shaderpacks", "saves", "logs",
"crash-reports", "screenshots", "journeymap", "crash-reports", "screenshots", "journeymap", "config",
"natives", "assets", "libraries", "versions", "cache" "natives", "assets", "libraries", "versions", "cache"
); );
@@ -391,8 +293,7 @@ public class PackDownloader {
Path relative = instancePath.relativize(file); Path relative = instancePath.relativize(file);
String path = relative.toString().replace("\\", "/"); String path = relative.toString().replace("\\", "/");
// Skip user files snapshot and ignored directories // Проверяем, не в игнорируемой ли директории
if (path.equals(USER_FILES_FILE)) return;
for (String ignored : ignoredDirs) { for (String ignored : ignoredDirs) {
if (path.startsWith(ignored + "/") || path.startsWith(ignored + "\\")) { if (path.startsWith(ignored + "/") || path.startsWith(ignored + "\\")) {
return; return;
@@ -414,10 +315,6 @@ public class PackDownloader {
* Send diff request to server * 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 {
return getDiff(packName, localFiles, null);
}
private DiffResponse getDiff(String packName, Map<String, String> localFiles, String preset) throws Exception {
String json = gson.toJson(localFiles); String json = gson.toJson(localFiles);
// Get auth token // Get auth token
@@ -430,10 +327,6 @@ public class PackDownloader {
} }
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff"; String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
if (preset != null && !preset.isEmpty()) {
url += "?preset=" + java.net.URLEncoder.encode(preset, "UTF-8");
}
LauncherLogger.info("getDiff: url=" + url + " bodySize=" + json.length() + " authToken=" + (accessToken != null ? "present" : "null") + " localFilesCount=" + json.length());
// Use HttpURLConnection for full control // Use HttpURLConnection for full control
java.net.HttpURLConnection connection = null; java.net.HttpURLConnection connection = null;
@@ -457,7 +350,6 @@ public class PackDownloader {
} }
int responseCode = connection.getResponseCode(); int responseCode = connection.getResponseCode();
LauncherLogger.info("getDiff: responseCode=" + responseCode + " packName=" + packName);
// Read response // Read response
StringBuilder response = new StringBuilder(); StringBuilder response = new StringBuilder();
@@ -470,7 +362,6 @@ public class PackDownloader {
} }
String responseBody = response.toString(); String responseBody = response.toString();
LauncherLogger.info("[DEBUG] getDiff response body: " + responseBody.substring(0, Math.min(responseBody.length(), 500)));
if (responseCode == 403) { if (responseCode == 403) {
throw new IOException("Active pass required to download packs. Contact the administrator."); throw new IOException("Active pass required to download packs. Contact the administrator.");
@@ -480,9 +371,7 @@ public class PackDownloader {
throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody)); throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody));
} }
DiffResponse result = gson.fromJson(responseBody, DiffResponse.class); return gson.fromJson(responseBody, DiffResponse.class);
LauncherLogger.info("[DEBUG] getDiff parsed: toDownload=" + result.getToDownload().size() + " toDelete=" + result.getToDelete().size());
return result;
} finally { } finally {
if (connection != null) { if (connection != null) {
@@ -505,61 +394,36 @@ public class PackDownloader {
* Apply diff (download new files, delete old ones) * Apply diff (download new files, delete old ones)
*/ */
private boolean applyDiff(DiffResponse diff, String packName) { private boolean applyDiff(DiffResponse diff, String packName) {
return applyDiff(diff, packName, new HashSet<>(), null); System.out.println(ZAnsi.cyan("\nApplying changes:"));
} System.out.println(" Download: " + diff.getToDownload().size() + " files");
System.out.println(" Delete: " + diff.getToDelete().size() + " files");
private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles) {
return applyDiff(diff, packName, protectedFiles, null);
}
private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles, String preset) {
LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size());
LauncherLogger.info("Apply changes: Download " + diff.getToDownload().size() + " files, Delete " + diff.getToDelete().size() + " files"
+ (!protectedFiles.isEmpty() ? ", Protected " + protectedFiles.size() + " user-modified files (skipped)" : ""));
// Create directories if needed // Create directories if needed
try { try {
Files.createDirectories(instance.getPath()); Files.createDirectories(instance.getPath());
} catch (IOException e) { } catch (IOException e) {
LauncherLogger.error("Error creating directories: " + e.getMessage()); System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage()));
return false; return false;
} }
// Delete files (skip protected) // Delete files
for (String filePath : diff.getToDelete()) { for (String filePath : diff.getToDelete()) {
if (protectedFiles.contains(filePath)) {
LauncherLogger.info("Skipped delete (user-modified): " + filePath);
continue;
}
Path fullPath = instance.getPath().resolve(filePath); Path fullPath = instance.getPath().resolve(filePath);
try { try {
if (Files.deleteIfExists(fullPath)) { if (Files.deleteIfExists(fullPath)) {
LauncherLogger.info("Deleted: " + filePath); System.out.println(ZAnsi.yellow(" Deleted: " + filePath));
} }
} catch (IOException e) { } catch (IOException e) {
LauncherLogger.error("Error deleting " + filePath + ": " + e.getMessage()); System.err.println(ZAnsi.red(" Error deleting " + filePath + ": " + e.getMessage()));
} }
} }
// Download files (skip protected) // Download files
AtomicInteger downloaded = new AtomicInteger(0); AtomicInteger downloaded = new AtomicInteger(0);
int total = diff.getToDownload().size(); int total = diff.getToDownload().size();
int skipped = 0;
long totalBytes = 0;
for (FileInfo f : diff.getToDownload()) totalBytes += f.getSize();
AtomicLong downloadedBytes = new AtomicLong(0);
long downloadStartTime = System.currentTimeMillis();
LauncherLogger.info("applyDiff: downloading " + total + " files, total size=" + totalBytes + " bytes");
for (FileInfo file : diff.getToDownload()) { for (FileInfo file : diff.getToDownload()) {
String path = file.getPath(); String path = file.getPath();
if (protectedFiles.contains(path)) {
skipped++;
LauncherLogger.info("Skipped (user-modified): " + path);
continue;
}
Path fullPath = instance.getPath().resolve(path); Path fullPath = instance.getPath().resolve(path);
try { try {
@@ -567,101 +431,71 @@ public class PackDownloader {
Files.createDirectories(fullPath.getParent()); Files.createDirectories(fullPath.getParent());
// Download file // Download file
String fileUrl = ZHttpClient.getBaseUrl() + file.getUrl();
if (preset != null && !preset.isEmpty() && !file.getUrl().contains("preset=")) {
String sep = file.getUrl().contains("?") ? "&" : "?";
fileUrl = fileUrl + sep + "preset=" + java.net.URLEncoder.encode(preset, "UTF-8");
}
LauncherLogger.info("applyDiff: downloading file=" + path + " url=" + fileUrl + " size=" + file.getSize() + " hash=" + file.getHash());
downloadFile(file, fullPath); downloadFile(file, fullPath);
// Verify hash // Verify hash
String actualHash = calculateHash(fullPath); String actualHash = calculateHash(fullPath);
boolean hashMatch = actualHash.equals(file.getHash()); if (!actualHash.equals(file.getHash())) {
LauncherLogger.info("applyDiff: downloaded file=" + path + " hashMatch=" + hashMatch + " expected=" + file.getHash() + " got=" + actualHash); throw new IOException("Hash mismatch! Expected: " + file.getHash() +
if (!hashMatch) { ", got: " + actualHash);
LauncherLogger.warn("applyDiff: hash mismatch for " + path + ", retrying once...");
downloadFile(file, fullPath);
actualHash = calculateHash(fullPath);
hashMatch = actualHash.equals(file.getHash());
LauncherLogger.info("applyDiff: retry result for " + path + " hashMatch=" + hashMatch);
}
if (!hashMatch) {
LauncherLogger.warn("applyDiff: skipping file " + path + " due to hash mismatch (expected=" + file.getHash() + " got=" + actualHash + ")");
skipped++;
continue;
} }
downloaded.incrementAndGet(); downloaded.incrementAndGet();
downloadedBytes.addAndGet(file.getSize());
if (total > 0) { if (total > 0) {
ProgressBar.show("Download", downloaded.get(), total, "files"); ProgressBar.show("Download", downloaded.get(), total, "files");
long elapsed = System.currentTimeMillis() - downloadStartTime;
long bytesSoFar = downloadedBytes.get();
long remainingBytes = totalBytes - bytesSoFar;
int pct = totalBytes > 0 ? (int)(bytesSoFar * 45 / totalBytes) : (downloaded.get() * 45 / total);
pct = Math.min(pct, 45);
String speedStr = "";
String etaStr = "";
if (elapsed > 1000) {
long speedBps = bytesSoFar * 1000 / elapsed;
if (speedBps > 1_000_000) {
speedStr = String.format("%.1f MB/s", speedBps / 1_000_000.0);
} else if (speedBps > 1_000) {
speedStr = String.format("%.0f KB/s", speedBps / 1_000.0);
} else {
speedStr = speedBps + " B/s";
}
if (speedBps > 0 && remainingBytes > 0) {
long etaSec = remainingBytes / speedBps;
if (etaSec > 3600) {
etaStr = String.format("%dh %dm", etaSec / 3600, (etaSec % 3600) / 60);
} else if (etaSec > 60) {
etaStr = String.format("%dm %ds", etaSec / 60, etaSec % 60);
} else {
etaStr = etaSec + "s";
}
}
}
String label = "Downloading " + downloaded.get() + "/" + total + " files";
if (!speedStr.isEmpty()) label += " (" + speedStr;
if (!etaStr.isEmpty()) label += " ETA: " + etaStr;
if (!speedStr.isEmpty()) label += ")";
reportProgress(label, 50 + pct, "Installing pack files", 4, 5);
} }
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.error("applyDiff: download FAILED for path=" + path + " error=" + e.getClass().getName() + ": " + e.getMessage()); System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage()));
LauncherLogger.error("Download error " + path + ": " + e.getMessage() + " - skipping file"); return false;
skipped++;
continue;
} }
} }
if (total > 0) { if (total > 0) {
ProgressBar.finish("Download"); ProgressBar.finish("Download");
} }
if (skipped > 0) {
LauncherLogger.info("Skipped " + skipped + " user-modified files");
}
return true; return true;
} }
/** /**
* Скачать один файл с сервера через временный файл для атомарности. * Скачать один файл с сервера
* Предотвращает появление битых файлов при обрыве соединения.
*/ */
private void downloadFile(FileInfo file, Path destination) throws Exception { private void downloadFile(FileInfo file, Path destination) throws Exception {
Path tmpFile = destination.resolveSibling(destination.getFileName() + ".tmp"); String url = ZHttpClient.getBaseUrl() + file.getUrl();
try {
String url = ZHttpClient.getBaseUrl() + file.getUrl(); HttpRequest request = HttpRequest.newBuilder()
ZHttpClient.downloadFileWithSmartProxy(url, tmpFile); .uri(java.net.URI.create(url))
Files.move(tmpFile, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); .GET()
} catch (Exception e) { .build();
try { Files.deleteIfExists(tmpFile); } catch (Exception ignored) {}
throw e; HttpResponse<InputStream> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode());
} }
// Скачиваем с прогрессом
try (InputStream in = response.body();
FileOutputStream out = new FileOutputStream(destination.toFile())) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
long fileSize = file.getSize();
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
totalRead += bytesRead;
if (fileSize > 0 && totalRead % 8192 == 0) {
ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize);
}
}
}
ProgressBar.clearLine();
} }
/** /**
@@ -686,59 +520,6 @@ public class PackDownloader {
return sb.toString(); return sb.toString();
} }
// ====================== User Files Protection ======================
// Keeps a snapshot of file hashes after each install/update.
// On next update, files modified by the user are not overwritten.
private Path getUserFilesPath() {
return instance.getPath().resolve(USER_FILES_FILE);
}
private void saveUserFilesSnapshot(Map<String, String> fileHashes) {
try {
Path path = getUserFilesPath();
Files.createDirectories(path.getParent());
Files.writeString(path, gson.toJson(fileHashes));
} catch (Exception e) {
LauncherLogger.warn("Failed to save user files snapshot: " + e.getMessage());
}
}
private Map<String, String> loadUserFilesSnapshot() {
Path path = getUserFilesPath();
if (!Files.exists(path)) return new HashMap<>();
try {
String content = Files.readString(path);
Map<String, Object> raw = gson.fromJson(content, Map.class);
Map<String, String> result = new HashMap<>();
if (raw != null) {
for (Map.Entry<String, Object> e : raw.entrySet()) {
result.put(e.getKey(), e.getValue() != null ? e.getValue().toString() : "");
}
}
return result;
} catch (Exception e) {
LauncherLogger.warn("Failed to load user files snapshot: " + e.getMessage());
return new HashMap<>();
}
}
private Set<String> getUserModifiedFiles(Map<String, String> currentHashes) {
Map<String, String> snapshot = loadUserFilesSnapshot();
if (snapshot.isEmpty()) return new HashSet<>();
Set<String> modified = new HashSet<>();
for (Map.Entry<String, String> e : currentHashes.entrySet()) {
String path = e.getKey();
String currentHash = e.getValue();
String snapshotHash = snapshot.get(path);
if (snapshotHash != null && !snapshotHash.equals(currentHash)) {
modified.add(path);
}
}
return modified;
}
// ====================== Вложенные классы ====================== // ====================== Вложенные классы ======================
public static class PackManifest { public static class PackManifest {
@@ -760,56 +541,6 @@ public class PackDownloader {
public boolean isEmpty() { return files == null || files.isEmpty(); } public boolean isEmpty() { return files == null || files.isEmpty(); }
} }
/**
* Resolve the correct asset index for the installed Minecraft version.
* The manifest/instance.json asset_index value may be wrong (e.g. Zern-OBT
* shipped "1.20.1" while the real index for MC 1.20.1 is "5"). The source of
* truth is the local Mojang version JSON downloaded during install
* (versions/&lt;mc&gt;/&lt;mc&gt;.json -> assetIndex.id). We only trust it when the
* corresponding index file actually exists on disk; otherwise fall back to
* the manifest value.
*/
private String resolveAssetIndex(PackManifest manifest) {
String manifestIndex = manifest.getAssetIndex();
if (manifest == null || manifest.getMinecraftVersion() == null) {
return manifestIndex;
}
try {
Path versionJson = instance.getPath().resolve("versions")
.resolve(manifest.getMinecraftVersion())
.resolve(manifest.getMinecraftVersion() + ".json");
if (Files.exists(versionJson)) {
JsonObject versionData = JsonParser.parseString(Files.readString(versionJson)).getAsJsonObject();
String realIndex = null;
if (versionData.has("assetIndex") && versionData.get("assetIndex").isJsonObject()
&& versionData.getAsJsonObject("assetIndex").has("id")) {
realIndex = versionData.getAsJsonObject("assetIndex").get("id").getAsString();
} else if (versionData.has("assets") && !versionData.get("assets").isJsonNull()) {
realIndex = versionData.get("assets").getAsString();
}
if (realIndex != null && !realIndex.isEmpty()) {
Path indexPath = instance.getPath().resolve("assets").resolve("indexes")
.resolve(realIndex + ".json");
if (Files.exists(indexPath)) {
if (!realIndex.equals(manifestIndex)) {
LauncherLogger.warn("resolveAssetIndex: manifest asset_index=" + manifestIndex
+ " but real index=" + realIndex + " exists on disk, using real index");
}
return realIndex;
} else {
LauncherLogger.warn("resolveAssetIndex: real index=" + realIndex
+ " from version JSON, but " + indexPath + " does not exist; falling back to manifest=" + manifestIndex);
}
}
} else {
LauncherLogger.warn("resolveAssetIndex: local version JSON not found: " + versionJson);
}
} catch (Exception e) {
LauncherLogger.warn("resolveAssetIndex: failed to resolve: " + e.getClass().getSimpleName() + " - " + e.getMessage());
}
return manifestIndex;
}
public static class DiffResponse { public static class DiffResponse {
private int version; private int version;
private List<FileInfo> to_download; private List<FileInfo> to_download;
@@ -1,28 +1,32 @@
package me.sashegdev.zernmc.launcher.minecraft.installer; package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar; import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.concurrent.TimeUnit; import java.time.Duration;
public class FabricInstaller { public class FabricInstaller {
private final Instance instance; private final Instance instance;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build();
public FabricInstaller(Instance instance) { public FabricInstaller(Instance instance) {
this.instance = instance; this.instance = instance;
} }
public boolean install(String minecraftVersion, String loaderVersion) throws Exception { public boolean install(String minecraftVersion, String loaderVersion) throws Exception {
LauncherLogger.info("Installing Fabric " + loaderVersion + " for Minecraft " + minecraftVersion); System.out.println(ZAnsi.cyan("Installing Fabric " + loaderVersion + " for Minecraft " + minecraftVersion));
Path instancePath = instance.getPath(); Path instancePath = instance.getPath();
cleanOldFabricLoaders(); cleanOldFabricLoaders();
@@ -30,13 +34,11 @@ public class FabricInstaller {
VersionInstaller versionInstaller = new VersionInstaller(instancePath); VersionInstaller versionInstaller = new VersionInstaller(instancePath);
String assetIndex = versionInstaller.install(minecraftVersion); String assetIndex = versionInstaller.install(minecraftVersion);
LauncherLogger.info("Asset index obtained: " + assetIndex); System.out.println(ZAnsi.green("Asset index obtained: " + assetIndex));
instance.setAssetIndex(assetIndex); instance.setAssetIndex(assetIndex);
instance.setMinecraftVersion(minecraftVersion); instance.setMinecraftVersion(minecraftVersion);
ProgressBar.showIndeterminate("Preparing Fabric installer");
String installerVersion = getLatestInstallerVersion(); String installerVersion = getLatestInstallerVersion();
String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/" String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/"
+ installerVersion + "/fabric-installer-" + installerVersion + ".jar"; + installerVersion + "/fabric-installer-" + installerVersion + ".jar";
@@ -44,20 +46,17 @@ 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.showIndeterminate("Downloading Fabric Installer"); ProgressBar.show("Downloading Fabric Installer", 0, 100, "%");
downloadFileWithFallback(installerUrl, installerJar); downloadFileWithFallback(installerUrl, installerJar);
ProgressBar.finish("Fabric Installer downloaded"); ProgressBar.finish("Fabric Installer downloaded");
} }
preDownloadFabricLibraries(minecraftVersion, loaderVersion); System.out.println(ZAnsi.cyan("Running Fabric Installer..."));
LauncherLogger.info("Running Fabric Installer...");
ProgressBar.showIndeterminate("Running Fabric Installer (may take a few minutes)");
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion; String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
JavaResolver.getJavaExecutable(), "-jar", installerJar.toAbsolutePath().toString(), "java", "-jar", installerJar.toAbsolutePath().toString(),
"client", "client",
"-dir", instancePath.toAbsolutePath().toString(), "-dir", instancePath.toAbsolutePath().toString(),
"-mcversion", minecraftVersion, "-mcversion", minecraftVersion,
@@ -65,60 +64,21 @@ public class FabricInstaller {
"-noprofile" "-noprofile"
); );
pb.redirectErrorStream(true); pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
Process process = pb.start(); Process process = pb.start();
int exitCode = process.waitFor();
boolean hasErrors = false;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.contains("Downloading") || line.contains("Extracting")
|| line.contains("Installing") || line.contains("Creating")
|| line.contains("SUCCESS") || line.contains("successfully")) {
ProgressBar.showIndeterminate("Fabric Installer: " + ProgressBar.cleanLine(line));
}
if (line.contains("Downloading") || line.contains("Extracting")) {
LauncherLogger.info(" -> " + line);
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
LauncherLogger.info(" + " + line);
} else if (line.contains("WARNING") || line.contains("warning")) {
LauncherLogger.warn(" ! " + line);
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
LauncherLogger.error(" X " + line);
if (line.contains("timed out") || line.contains("failed to download")) {
hasErrors = true;
}
} else if (!line.isBlank()) {
LauncherLogger.info(" " + line);
}
}
}
int exitCode;
if (hasErrors) {
process.destroyForcibly();
exitCode = 1;
} else {
if (!process.waitFor(10, TimeUnit.MINUTES)) {
process.destroyForcibly();
LauncherLogger.error("Fabric Installer timed out after 10 minutes");
return false;
}
exitCode = process.exitValue();
}
if (exitCode != 0) { if (exitCode != 0) {
LauncherLogger.error("Fabric Installer failed (code " + 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)) {
LauncherLogger.info("Fabric installed successfully!"); System.out.println(ZAnsi.brightGreen("Fabric installed successfully!"));
instance.setLoaderType("fabric"); instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVersion); instance.setLoaderVersion(loaderVersion);
@@ -126,11 +86,9 @@ public class FabricInstaller {
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex); ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
ZHttpClient.repairMissingLibraries(instancePath);
return true; return true;
} else { } else {
LauncherLogger.error("Fabric Installer ran, but version not found."); System.out.println(ZAnsi.brightRed("Fabric Installer ran, but version not found."));
return false; return false;
} }
} }
@@ -139,47 +97,16 @@ public class FabricInstaller {
try { try {
ZHttpClient.downloadFile(url, target); ZHttpClient.downloadFile(url, target);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.warn("Failed to download Fabric Installer: " + e.getMessage()); System.out.println(ZAnsi.yellow("Failed to download Fabric Installer: " + e.getMessage()));
throw e; throw e;
} }
} }
/**
* Pre-downloads the Fabric loader jar and its dependency manifest through the smart proxy
* so the installer subprocess finds them already on disk and skips direct downloads.
* Non-fatal: if this fails the subprocess will still try to download itself.
*/
private void preDownloadFabricLibraries(String minecraftVersion, String loaderVersion) {
try {
Path libRoot = instance.getPath().resolve("libraries");
String loaderVersionPath = "net/fabricmc/fabric-loader/" + loaderVersion;
Path loaderJson = libRoot.resolve(loaderVersionPath).resolve("fabric-loader-" + loaderVersion + ".json");
if (!Files.exists(loaderJson)) {
Files.createDirectories(loaderJson.getParent());
ZHttpClient.downloadFileWithSmartProxy(
"https://maven.fabricmc.net/" + loaderVersionPath + "/fabric-loader-" + loaderVersion + ".json",
loaderJson);
}
Path loaderJar = libRoot.resolve(loaderVersionPath).resolve("fabric-loader-" + loaderVersion + ".jar");
if (!Files.exists(loaderJar)) {
ZHttpClient.downloadFileWithSmartProxy(
"https://maven.fabricmc.net/" + loaderVersionPath + "/fabric-loader-" + loaderVersion + ".jar",
loaderJar);
}
ZHttpClient.repairLibrariesFromJson(loaderJson, instance.getPath());
} catch (Exception e) {
LauncherLogger.warn("Pre-download of Fabric libraries incomplete: " + e.getMessage());
}
}
private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException { private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException {
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json"); Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
if (!Files.exists(versionJson)) { if (!Files.exists(versionJson)) {
LauncherLogger.warn("Version JSON file not found: " + versionJson); System.out.println(ZAnsi.yellow("Version JSON file not found: " + versionJson));
return; return;
} }
@@ -187,7 +114,7 @@ public class FabricInstaller {
// Check and fix asset index // Check and fix asset index
if (!content.contains("\"assets\":\"" + assetIndex + "\"")) { if (!content.contains("\"assets\":\"" + assetIndex + "\"")) {
LauncherLogger.warn("Fixing asset index in version JSON file..."); System.out.println(ZAnsi.yellow("Fixing asset index in version JSON file..."));
// Replace assets with correct value // Replace assets with correct value
content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\""); content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\"");
@@ -198,9 +125,9 @@ public class FabricInstaller {
} }
Files.writeString(versionJson, content); Files.writeString(versionJson, content);
LauncherLogger.info("Asset index fixed to: " + assetIndex); System.out.println(ZAnsi.green("Asset index fixed to: " + assetIndex));
} else { } else {
LauncherLogger.info("Asset index in version JSON is correct: " + assetIndex); System.out.println(ZAnsi.green("Asset index in version JSON is correct: " + assetIndex));
} }
} }
@@ -208,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;
LauncherLogger.warn("Cleaning old Fabric Loader versions..."); 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)
@@ -234,16 +161,53 @@ public class FabricInstaller {
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) {
LauncherLogger.warn("Error getting Fabric Installer version: " + e.getMessage()); System.out.println(ZAnsi.yellow("Error getting Fabric Installer version: " + e.getMessage()));
throw new Exception("Failed to get Fabric Installer version", 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 {
return ZHttpClient.getWithSmartProxy(url); Exception lastException = null;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(30 * attempt))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
return resp.body();
}
throw new IOException("HTTP " + resp.statusCode());
} catch (Exception e) {
lastException = e;
System.out.println(ZAnsi.yellow("Attempt " + attempt + " failed: " + e.getMessage()));
if (attempt < 3) {
Thread.sleep(1000 * attempt);
}
}
}
throw lastException;
} }
private void downloadFile(String url, Path target) throws Exception { private void downloadFile(String url, Path target) throws Exception {
ZHttpClient.downloadFileWithSmartProxy(url, target); HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(60))
.GET()
.build();
HttpResponse<Path> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode() + " when downloading " + url);
}
} }
} }
@@ -1,35 +1,41 @@
package me.sashegdev.zernmc.launcher.minecraft.installer; package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar; import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.*; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.util.concurrent.TimeUnit; import java.util.HashMap;
import java.util.Map;
public class ForgeInstaller { public class ForgeInstaller {
private final Instance instance; private final Instance instance;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(30))
.build();
public ForgeInstaller(Instance instance) { public ForgeInstaller(Instance instance) {
this.instance = instance; this.instance = instance;
} }
public boolean install(String mcVersion, String forgeVersion) throws Exception { public boolean install(String mcVersion, String forgeVersion) throws Exception {
LauncherLogger.info("Installing Forge " + forgeVersion + " for Minecraft " + mcVersion); System.out.println(ZAnsi.cyan("Installing Forge " + forgeVersion + " for Minecraft " + mcVersion));
// Step 1: Install vanilla and get real assetIndex // Step 1: Install vanilla and get real assetIndex
LauncherLogger.info("Installing base Minecraft version " + 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()) {
LauncherLogger.error("Failed to install base Minecraft version"); System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
return false; return false;
} }
@@ -45,21 +51,12 @@ public class ForgeInstaller {
Path installerJar = instance.getPath().resolve("forge-installer.jar"); Path installerJar = instance.getPath().resolve("forge-installer.jar");
LauncherLogger.info("Downloading Forge Installer..."); System.out.println(ZAnsi.cyan("Downloading Forge Installer..."));
downloadFileWithProgress(installerUrl, installerJar); downloadFileWithProgress(installerUrl, installerJar);
// Step 4: Pre-download the libraries the installer subprocess would fetch from // Step 4: Run Forge Installer and show its output
// maven.minecraftforge.net directly. On blocked networks that subprocess cannot System.out.println(ZAnsi.cyan("Running Forge Installer..."));
// reach the maven host, so we seed every library (version.json + install_profile.json) System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
// through the launcher smart proxy first. The installer reuses files whose checksums
// validate ("File exists: Checksum validated"), so no direct downloads are needed.
LauncherLogger.info("Prefetching Forge libraries via smart proxy...");
int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath());
LauncherLogger.info("Prefetched " + prefetched + " Forge libraries");
// Step 5: Run Forge Installer and show its output
LauncherLogger.info("Running Forge Installer...");
LauncherLogger.info("This may take a few minutes. Please wait...");
boolean success = runForgeInstaller(installerJar); boolean success = runForgeInstaller(installerJar);
@@ -69,10 +66,10 @@ public class ForgeInstaller {
try { try {
downloadMissingLibraries(mcVersion, forgeVersion); downloadMissingLibraries(mcVersion, forgeVersion);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.warn("Warning: could not download some libraries: " + e.getMessage()); System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
} }
LauncherLogger.info("\nForge " + forgeVersion + " installed successfully!"); 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);
@@ -81,7 +78,7 @@ public class ForgeInstaller {
Files.deleteIfExists(installerJar); Files.deleteIfExists(installerJar);
return true; return true;
} else { } else {
LauncherLogger.error("\nError installing Forge!"); System.out.println(ZAnsi.brightRed("\nError installing Forge!"));
return false; return false;
} }
} }
@@ -97,12 +94,52 @@ public class ForgeInstaller {
} }
"""; """;
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
LauncherLogger.info("Created 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 {
ProgressBar.show("Downloading Forge Installer", 0, 100, "%"); HttpRequest request = HttpRequest.newBuilder()
ZHttpClient.downloadFileWithSmartProxy(url, target); .uri(URI.create(url))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode());
}
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
try (InputStream in = response.body();
FileOutputStream out = new FileOutputStream(target.toFile())) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
int lastPercent = -1;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
totalRead += bytesRead;
if (contentLength > 0) {
int percent = (int) ((totalRead * 100) / contentLength);
if (percent != lastPercent) {
String downloaded = ProgressBar.formatBytes(totalRead);
String total = ProgressBar.formatBytes(contentLength);
ProgressBar.show("Forge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
lastPercent = percent;
}
} else {
// If size unknown, show animation
char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4;
System.out.print("\rDownloading Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
}
}
}
ProgressBar.finish("Forge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")"); ProgressBar.finish("Forge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
} }
@@ -112,21 +149,13 @@ public class ForgeInstaller {
int attempt = 1; int attempt = 1;
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
LauncherLogger.info("Attempt " + attempt + " of " + maxRetries); System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
// Re-seed libraries each attempt: the installer may have left partial files,
// and prefetch deletes any with a bad checksum before re-downloading via proxy.
int seeded = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath());
if (seeded > 0) {
LauncherLogger.info(" Prefetched " + seeded + " libraries for this attempt");
}
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
JavaResolver.getJavaExecutable(), "java",
"-jar", "-jar",
installerJar.toAbsolutePath().toString(), installerJar.toAbsolutePath().toString(),
"--installClient", "--installClient"
instance.getPath().toAbsolutePath().toString()
); );
// Add JVM args for increased timeouts // Add JVM args for increased timeouts
@@ -148,39 +177,23 @@ public class ForgeInstaller {
// Format Forge Installer output // Format Forge Installer output
if (line.contains("Downloading") || line.contains("Extracting")) { if (line.contains("Downloading") || line.contains("Extracting")) {
LauncherLogger.info(" -> " + line); System.out.println(ZAnsi.blue(" -> " + line));
} else if (line.contains("SUCCESS") || line.contains("successfully")) { } else if (line.contains("SUCCESS") || line.contains("successfully")) {
LauncherLogger.info(" + " + line); System.out.println(ZAnsi.brightGreen(" + " + line));
} else if (line.contains("WARNING") || line.contains("warning")) { } else if (line.contains("WARNING") || line.contains("warning")) {
LauncherLogger.warn(" ! " + line); System.out.println(ZAnsi.yellow(" ! " + line));
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
LauncherLogger.error(" X " + line); System.out.println(ZAnsi.brightRed(" X " + line));
if (line.contains("timed out") || line.contains("failed to download")) { if (line.contains("timed out") || line.contains("failed to download")) {
hasErrors = true; hasErrors = true;
} }
} else if (!line.isBlank()) { } else if (!line.isBlank()) {
LauncherLogger.info(" " + line); System.out.println(" " + line);
} }
} }
} }
int exitCode; int exitCode = process.waitFor();
if (hasErrors) {
process.destroyForcibly();
exitCode = 1;
} else {
if (!process.waitFor(10, TimeUnit.MINUTES)) {
process.destroyForcibly();
LauncherLogger.error("Forge Installer timed out after 10 minutes");
if (attempt < maxRetries) {
attempt++;
Thread.sleep(5000);
continue;
}
return false;
}
exitCode = process.exitValue();
}
// If successful or no download errors // If successful or no download errors
if (exitCode == 0 && !hasErrors) { if (exitCode == 0 && !hasErrors) {
@@ -189,20 +202,31 @@ public class ForgeInstaller {
// If error and not last attempt // If error and not last attempt
if (attempt < maxRetries) { if (attempt < maxRetries) {
LauncherLogger.warn("Install error. Retrying in 5 seconds..."); System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
Thread.sleep(5000); Thread.sleep(5000);
// Prefetch at the top of the next attempt already re-seeds any library that
// was left partial or corrupted (SHA-1 mismatch -> delete + re-download via proxy). // Clean temp files before retry
Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) {
// Удаляем только частично скачанные библиотеки Forge
try (var stream = Files.walk(librariesDir)) {
stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar"))
.forEach(p -> {
try { Files.deleteIfExists(p); }
catch (IOException e) { /* ignore */ }
});
}
}
} else { } else {
LauncherLogger.error("Forge Installer exited with error code: " + exitCode); System.out.println(ZAnsi.brightRed("Forge Installer exited with error code: " + exitCode));
// Show possible solution // Show possible solution
if (output.toString().contains("timed out")) { if (output.toString().contains("timed out")) {
LauncherLogger.warn("\nPossible solutions:"); System.out.println(ZAnsi.yellow("\nPossible solutions:"));
LauncherLogger.warn("1. Check your internet connection"); System.out.println(ZAnsi.yellow("1. Check your internet connection"));
LauncherLogger.warn("2. Run the launcher as administrator"); System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
LauncherLogger.warn("3. Temporarily disable antivirus/firewall"); System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
LauncherLogger.warn("4. Try installing a different Forge version"); System.out.println(ZAnsi.yellow("4. Try installing a different Forge version"));
} }
} }
@@ -213,7 +237,34 @@ public class ForgeInstaller {
} }
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception { private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
LauncherLogger.info("Checking and downloading missing libraries..."); System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
ZHttpClient.repairMissingLibraries(instance.getPath());
// List of problematic libraries and their alternate URLs
Map<String, String> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar");
Path librariesDir = instance.getPath().resolve("libraries");
for (Map.Entry<String, String> entry : alternativeUrls.entrySet()) {
Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) {
Files.createDirectories(target.getParent());
System.out.println(ZAnsi.yellow("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("Повторная попытка " + attempt + "/3..."));
Thread.sleep(2000);
}
}
}
}
} }
} }
@@ -1,234 +0,0 @@
package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.*;
import java.util.concurrent.TimeUnit;
public class ModLoaderInstaller {
public enum LoaderType {
FORGE("forge", "net.minecraftforge", "forge", "https://maven.minecraftforge.net"),
NEOFORGE("neoforge", "net.neoforged", null, "https://maven.neoforged.net/releases");
final String loaderName;
final String mavenGroup;
final String fixedArtifact;
final String mavenBase;
LoaderType(String loaderName, String mavenGroup, String fixedArtifact, String mavenBase) {
this.loaderName = loaderName;
this.mavenGroup = mavenGroup;
this.fixedArtifact = fixedArtifact;
this.mavenBase = mavenBase;
}
public String artifact(String mcVersion) {
if (this == NEOFORGE && !"1.20.1".equals(mcVersion)) return "neoforge";
return "forge";
}
}
private final Instance instance;
public ModLoaderInstaller(Instance instance) {
this.instance = instance;
}
public boolean install(String mcVersion, String loaderVersion, LoaderType type) throws Exception {
LauncherLogger.info("Installing " + type.loaderName + " " + loaderVersion + " for Minecraft " + mcVersion);
LauncherLogger.info("Installing base Minecraft version " + mcVersion + "...");
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
String assetIndex = vanillaInstaller.install(mcVersion);
if (assetIndex == null || assetIndex.isEmpty()) {
LauncherLogger.error("Failed to install base Minecraft version");
return false;
}
instance.setAssetIndex(assetIndex);
createLauncherProfile();
ProgressBar.showIndeterminate("Preparing " + type.loaderName + " installer");
String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
String jarName = type.loaderName + "-installer.jar";
Path installerJar = instance.getPath().resolve(jarName);
LauncherLogger.info("Downloading " + type.loaderName + " Installer...");
ProgressBar.showIndeterminate("Downloading " + type.loaderName + " Installer");
ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar);
LauncherLogger.info("Running " + type.loaderName + " Installer...");
LauncherLogger.info("This may take a few minutes. Please wait...");
ProgressBar.showIndeterminate("Running " + type.loaderName + " Installer (may take a few minutes)");
boolean success = runInstaller(installerJar, type);
if (success) {
try {
downloadMissingLibraries(type);
} catch (Exception e) {
LauncherLogger.warn("Warning: could not download some libraries: " + e.getMessage());
}
LauncherLogger.info("\n" + type.loaderName + " " + loaderVersion + " installed successfully!");
instance.setMinecraftVersion(mcVersion);
instance.setLoaderType(type.loaderName);
instance.setLoaderVersion(loaderVersion);
Files.deleteIfExists(installerJar);
return true;
} else {
LauncherLogger.error("\nError installing " + type.loaderName + "!");
return false;
}
}
private String buildInstallerUrl(String mcVersion, String loaderVersion, LoaderType type) {
if (type == LoaderType.FORGE) {
return type.mavenBase + "/" + type.mavenGroup.replace('.', '/')
+ "/" + type.fixedArtifact + "/" + mcVersion + "-" + loaderVersion
+ "/" + type.fixedArtifact + "-" + mcVersion + "-" + loaderVersion + "-installer.jar";
}
String artifact = type.artifact(mcVersion);
return type.mavenBase + "/" + type.mavenGroup.replace('.', '/')
+ "/" + artifact + "/" + loaderVersion
+ "/" + artifact + "-" + loaderVersion + "-installer.jar";
}
private void createLauncherProfile() throws IOException {
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
if (Files.exists(profilePath)) return;
String minimalProfile = """
{
"profiles": {},
"selectedProfile": "Default"
}
""";
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
LauncherLogger.info("Created launcher_profiles.json");
}
private boolean runInstaller(Path installerJar, LoaderType type) throws IOException, InterruptedException {
int maxRetries = 3;
int attempt = 1;
while (attempt <= maxRetries) {
LauncherLogger.info("Attempt " + attempt + " of " + maxRetries);
ProcessBuilder pb = new ProcessBuilder(
JavaResolver.getJavaExecutable(),
"-jar",
installerJar.toAbsolutePath().toString(),
"--installClient",
instance.getPath().toAbsolutePath().toString()
);
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
pb.directory(instance.getPath().toFile());
pb.redirectErrorStream(true);
Process process = pb.start();
StringBuilder output = new StringBuilder();
boolean hasErrors = false;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
if (line.contains("Downloading") || line.contains("Extracting")
|| line.contains("Installing") || line.contains("Creating")
|| line.contains("SUCCESS") || line.contains("successfully")) {
ProgressBar.showIndeterminate(type.loaderName + " Installer: " + ProgressBar.cleanLine(line));
}
if (line.contains("Downloading") || line.contains("Extracting")) {
LauncherLogger.info(" -> " + line);
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
LauncherLogger.info(" + " + line);
} else if (line.contains("WARNING") || line.contains("warning")) {
LauncherLogger.warn(" ! " + line);
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
LauncherLogger.error(" X " + line);
if (line.contains("timed out") || line.contains("failed to download")) {
hasErrors = true;
}
} else if (!line.isBlank()) {
LauncherLogger.info(" " + line);
}
}
}
int exitCode;
if (hasErrors) {
process.destroyForcibly();
exitCode = 1;
} else {
if (!process.waitFor(10, TimeUnit.MINUTES)) {
process.destroyForcibly();
LauncherLogger.error(type.loaderName + " Installer timed out after 10 minutes");
if (attempt < maxRetries) {
attempt++;
Thread.sleep(5000);
continue;
}
return false;
}
exitCode = process.exitValue();
}
if (exitCode == 0) {
return true;
}
if (attempt < maxRetries) {
LauncherLogger.warn("Install error. Retrying in 5 seconds...");
Thread.sleep(5000);
Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) {
try (var stream = Files.walk(librariesDir)) {
stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar"))
.forEach(p -> {
try { Files.deleteIfExists(p); }
catch (IOException e) { /* ignore */ }
});
}
}
} else {
LauncherLogger.error(type.loaderName + " Installer exited with error code: " + exitCode);
if (output.toString().contains("timed out")) {
LauncherLogger.warn("\nPossible solutions:");
LauncherLogger.warn("1. Check your internet connection");
LauncherLogger.warn("2. Run the launcher as administrator");
LauncherLogger.warn("3. Temporarily disable antivirus/firewall");
LauncherLogger.warn("4. Try installing a different version");
}
}
attempt++;
}
return false;
}
private void downloadMissingLibraries(LoaderType type) throws Exception {
LauncherLogger.info("Checking and downloading missing libraries...");
ProgressBar.showIndeterminate("Downloading missing libraries");
ZHttpClient.repairMissingLibraries(instance.getPath());
}
}
@@ -1,34 +1,40 @@
package me.sashegdev.zernmc.launcher.minecraft.installer; package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar; import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.*; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.util.concurrent.TimeUnit; import java.util.HashMap;
import java.util.Map;
public class NeoForgeInstaller { public class NeoForgeInstaller {
private final Instance instance; private final Instance instance;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(30))
.build();
public NeoForgeInstaller(Instance instance) { public NeoForgeInstaller(Instance instance) {
this.instance = instance; this.instance = instance;
} }
public boolean install(String mcVersion, String neoForgeVersion) throws Exception { public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
LauncherLogger.info("Installing NeoForge " + neoForgeVersion + " for Minecraft " + mcVersion); System.out.println(ZAnsi.cyan("Installing NeoForge " + neoForgeVersion + " for Minecraft " + mcVersion));
LauncherLogger.info("Installing base Minecraft version " + 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()) {
LauncherLogger.error("Failed to install base Minecraft version"); System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
return false; return false;
} }
@@ -46,15 +52,11 @@ public class NeoForgeInstaller {
Path installerJar = instance.getPath().resolve("neoforge-installer.jar"); Path installerJar = instance.getPath().resolve("neoforge-installer.jar");
LauncherLogger.info("Downloading NeoForge Installer..."); System.out.println(ZAnsi.cyan("Downloading NeoForge Installer..."));
downloadFileWithProgress(installerUrl, installerJar); downloadFileWithProgress(installerUrl, installerJar);
LauncherLogger.info("Prefetching NeoForge libraries via smart proxy..."); System.out.println(ZAnsi.cyan("Running NeoForge Installer..."));
int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath()); System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
LauncherLogger.info("Prefetched " + prefetched + " NeoForge libraries");
LauncherLogger.info("Running NeoForge Installer...");
LauncherLogger.info("This may take a few minutes. Please wait...");
boolean success = runNeoForgeInstaller(installerJar); boolean success = runNeoForgeInstaller(installerJar);
@@ -62,10 +64,10 @@ public class NeoForgeInstaller {
try { try {
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact); downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.warn("Warning: could not download some libraries: " + e.getMessage()); System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
} }
LauncherLogger.info("\nNeoForge " + neoForgeVersion + " installed successfully!"); System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " installed successfully!"));
instance.setMinecraftVersion(mcVersion); instance.setMinecraftVersion(mcVersion);
instance.setLoaderType("neoforge"); instance.setLoaderType("neoforge");
instance.setLoaderVersion(neoForgeVersion); instance.setLoaderVersion(neoForgeVersion);
@@ -73,7 +75,7 @@ public class NeoForgeInstaller {
Files.deleteIfExists(installerJar); Files.deleteIfExists(installerJar);
return true; return true;
} else { } else {
LauncherLogger.error("\nError installing NeoForge!"); System.out.println(ZAnsi.brightRed("\nError installing NeoForge!"));
return false; return false;
} }
} }
@@ -103,12 +105,51 @@ public class NeoForgeInstaller {
} }
"""; """;
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
LauncherLogger.info("Created 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 {
ProgressBar.show("Downloading NeoForge Installer", 0, 100, "%"); HttpRequest request = HttpRequest.newBuilder()
ZHttpClient.downloadFileWithSmartProxy(url, target); .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)) + ")"); ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
} }
@@ -117,14 +158,13 @@ public class NeoForgeInstaller {
int attempt = 1; int attempt = 1;
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
LauncherLogger.info("Attempt " + attempt + " of " + maxRetries); System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
JavaResolver.getJavaExecutable(), "java",
"-jar", "-jar",
installerJar.toAbsolutePath().toString(), installerJar.toAbsolutePath().toString(),
"--installClient", "--installClient"
instance.getPath().toAbsolutePath().toString()
); );
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
@@ -142,35 +182,30 @@ public class NeoForgeInstaller {
output.append(line).append("\n"); output.append(line).append("\n");
if (line.contains("Downloading") || line.contains("Extracting")) { if (line.contains("Downloading") || line.contains("Extracting")) {
LauncherLogger.info(" -> " + line); System.out.println(ZAnsi.blue(" -> " + line));
} else if (line.contains("SUCCESS") || line.contains("successfully")) { } else if (line.contains("SUCCESS") || line.contains("successfully")) {
LauncherLogger.info(" + " + line); System.out.println(ZAnsi.brightGreen(" + " + line));
} else if (line.contains("WARNING") || line.contains("warning")) { } else if (line.contains("WARNING") || line.contains("warning")) {
LauncherLogger.warn(" ! " + line); System.out.println(ZAnsi.yellow(" ! " + line));
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
LauncherLogger.error(" X " + line); System.out.println(ZAnsi.brightRed(" X " + line));
if (line.contains("timed out") || line.contains("failed to download")) { if (line.contains("timed out") || line.contains("failed to download")) {
hasErrors = true; hasErrors = true;
} }
} else if (!line.isBlank()) { } else if (!line.isBlank()) {
LauncherLogger.info(" " + line); System.out.println(" " + line);
} }
} }
} }
if (!process.waitFor(10, TimeUnit.MINUTES)) { int exitCode = process.waitFor();
process.destroyForcibly();
LauncherLogger.error("NeoForge Installer timed out after 10 minutes");
return false;
}
int exitCode = process.exitValue();
if (exitCode == 0 && !hasErrors) { if (exitCode == 0 && !hasErrors) {
return true; return true;
} }
if (attempt < maxRetries) { if (attempt < maxRetries) {
LauncherLogger.warn("Install error. Retrying in 5 seconds..."); System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
Thread.sleep(5000); Thread.sleep(5000);
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
@@ -184,14 +219,14 @@ public class NeoForgeInstaller {
} }
} }
} else { } else {
LauncherLogger.error("NeoForge Installer exited with error code: " + exitCode); System.out.println(ZAnsi.brightRed("NeoForge Installer exited with error code: " + exitCode));
if (output.toString().contains("timed out")) { if (output.toString().contains("timed out")) {
LauncherLogger.warn("\nPossible solutions:"); System.out.println(ZAnsi.yellow("\nPossible solutions:"));
LauncherLogger.warn("1. Check your internet connection"); System.out.println(ZAnsi.yellow("1. Check your internet connection"));
LauncherLogger.warn("2. Run the launcher as administrator"); System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
LauncherLogger.warn("3. Temporarily disable antivirus/firewall"); System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
LauncherLogger.warn("4. Try installing a different NeoForge version"); System.out.println(ZAnsi.yellow("4. Try installing a different NeoForge version"));
} }
} }
@@ -202,7 +237,35 @@ public class NeoForgeInstaller {
} }
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception { private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
LauncherLogger.info("Checking and downloading missing libraries..."); System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
ZHttpClient.repairMissingLibraries(instance.getPath());
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);
}
}
}
}
} }
} }
@@ -1,43 +1,42 @@
package me.sashegdev.zernmc.launcher.minecraft.installer; package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion; import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar; import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.InputStream;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class VersionInstaller { public class VersionInstaller {
private final Path minecraftDir; private final Path minecraftDir;
private final HttpClient httpClient;
private final ExecutorService executor = Executors.newFixedThreadPool(32); private final ExecutorService executor = Executors.newFixedThreadPool(32);
public VersionInstaller(Path minecraftDir) { public VersionInstaller(Path minecraftDir) {
this.minecraftDir = minecraftDir; this.minecraftDir = minecraftDir;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build();
} }
public List<MinecraftVersion> getAvailableVersions() throws Exception { public List<MinecraftVersion> getAvailableVersions() throws Exception {
// Prefers Zern server Mojang proxy (reachable + cached), falls back to direct piston-meta String jsonString = downloadString("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json");
JSONObject root = ZHttpClient.getMojangVersionManifest(); JSONObject root = new JSONObject(jsonString);
JSONArray versionsArray = root.getJSONArray("versions"); JSONArray versionsArray = root.getJSONArray("versions");
List<MinecraftVersion> versions = new ArrayList<>(); List<MinecraftVersion> versions = new ArrayList<>();
@@ -58,80 +57,26 @@ public class VersionInstaller {
} }
public String install(String versionId) throws Exception { public String install(String versionId) throws Exception {
LauncherLogger.info("Full install of Minecraft " + versionId + "..."); System.out.println(ZAnsi.cyan("Full install of Minecraft " + versionId + "..."));
ProgressBar.setStage("Version", 0, 5);
Path versionDir = minecraftDir.resolve("versions").resolve(versionId); Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
Files.createDirectories(versionDir); Files.createDirectories(versionDir);
boolean alreadyInstalled = Files.exists(versionDir.resolve(versionId + ".json")) String versionUrl = getVersionUrl(versionId);
&& Files.exists(versionDir.resolve(versionId + ".jar")) if (versionUrl == null) throw new Exception("Version " + versionId + " not found");
&& isValidJar(versionDir.resolve(versionId + ".jar"));
if (alreadyInstalled) {
LauncherLogger.info("Minecraft " + versionId + " already installed, skipping");
String existing = Files.readString(versionDir.resolve(versionId + ".json"));
JSONObject existingData = new JSONObject(existing);
return existingData.has("assetIndex")
? existingData.getJSONObject("assetIndex").getString("id")
: existingData.getString("assets");
}
// A stale/partial client.jar (e.g. aborted by the download timeout) must be
// removed, otherwise it would be skipped as "already installed" and stay corrupt.
try {
Files.deleteIfExists(versionDir.resolve(versionId + ".jar"));
} catch (IOException e) {
LauncherLogger.warn("Could not remove stale client.jar: " + e.getMessage());
}
ProgressBar.show("Fetching version info", 0, 1, "files"); String versionJson = downloadString(versionUrl);
String versionJson; Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
try {
// Prefers Zern server Mojang proxy, falls back to direct piston-meta
LauncherLogger.info("[VERSION] Fetching version json for " + versionId + " (server proxy, then direct piston-meta)");
versionJson = ZHttpClient.getMojangVersionJson(versionId).toString();
LauncherLogger.info("[VERSION] Version json fetched for " + versionId);
} catch (Exception e) {
LauncherLogger.error("[VERSION] Failed to fetch version info: " + e.getMessage());
throw e;
}
try {
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
} catch (Exception e) {
LauncherLogger.error("[VERSION] Failed to write version info: " + e.getMessage());
throw e;
}
ProgressBar.show("Version info", 1, 1, "files");
JSONObject versionData = new JSONObject(versionJson); JSONObject versionData = new JSONObject(versionJson);
// client.jar // client.jar
ProgressBar.show("Downloading client.jar", 0, 1, "files"); downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
LauncherLogger.info("[VERSION] Downloading client.jar for " + versionId + "..."); versionDir.resolve(versionId + ".jar"), "client.jar");
Path clientJar = versionDir.resolve(versionId + ".jar");
if (!Files.exists(clientJar)) {
try {
ZHttpClient.downloadFileWithSmartProxy(
versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
clientJar);
LauncherLogger.info("[VERSION] client.jar downloaded for " + versionId);
} catch (Exception e) {
LauncherLogger.error("[VERSION] Failed to download client.jar: " + e.getMessage());
throw e;
}
} else {
LauncherLogger.info("[VERSION] client.jar already present for " + versionId);
}
ProgressBar.show("Client.jar", 1, 1, "files");
// Libraries // Libraries
ProgressBar.setStage("Libraries", 1, 5); System.out.println(ZAnsi.cyan("Downloading libraries..."));
LauncherLogger.info("Downloading libraries...");
downloadLibraries(versionData.getJSONArray("libraries")); downloadLibraries(versionData.getJSONArray("libraries"));
// Natives
ProgressBar.setStage("Natives", 2, 5);
LauncherLogger.info("Extracting natives...");
extractNatives(versionData.getJSONArray("libraries"));
String assetIndex; String assetIndex;
if (versionData.has("assetIndex")) { if (versionData.has("assetIndex")) {
assetIndex = versionData.getJSONObject("assetIndex").getString("id"); assetIndex = versionData.getJSONObject("assetIndex").getString("id");
@@ -139,91 +84,16 @@ public class VersionInstaller {
assetIndex = versionData.getString("assets"); assetIndex = versionData.getString("assets");
} }
LauncherLogger.info("Asset index: " + assetIndex); System.out.println(ZAnsi.cyan("Asset index: " + assetIndex));
// Download assets using correct index // Download assets using correct index
ProgressBar.setStage("Assets", 3, 5); System.out.println(ZAnsi.cyan("Downloading assets..."));
LauncherLogger.info("Downloading assets...");
downloadAssets(versionData, assetIndex); downloadAssets(versionData, assetIndex);
ProgressBar.setStage("Done", 4, 5); System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " fully installed!"));
LauncherLogger.info("\nMinecraft " + versionId + " fully installed!");
return assetIndex; return assetIndex;
} }
private void extractNatives(JSONArray libraries) throws Exception {
Path nativesDir = minecraftDir.resolve("natives");
Files.createDirectories(nativesDir);
// Determine OS classifier
String osName = System.getProperty("os.name").toLowerCase();
String classifier;
if (osName.contains("win")) {
classifier = "natives-windows";
} else if (osName.contains("mac")) {
classifier = "natives-macos";
} else {
classifier = "natives-linux";
}
int total = libraries.length();
int extracted = 0;
int failed = 0;
for (int i = 0; i < total; i++) {
JSONObject lib = libraries.getJSONObject(i);
if (!lib.has("downloads") || !lib.getJSONObject("downloads").has("classifiers")) continue;
JSONObject classifiers = lib.getJSONObject("downloads").getJSONObject("classifiers");
if (!classifiers.has(classifier)) continue;
JSONObject nativeArtifact = classifiers.getJSONObject(classifier);
String url = nativeArtifact.getString("url");
String path = nativeArtifact.getString("path");
Path libJar = minecraftDir.resolve("libraries").resolve(path);
// Download native library if not already present
if (!Files.exists(libJar)) {
try {
Files.createDirectories(libJar.getParent());
ZHttpClient.downloadFileWithSmartProxy(url, libJar);
} catch (Exception e) {
failed++;
continue;
}
}
// Extract .so/.dll/.dylib from the native jar
try (JarFile jar = new JarFile(libJar.toFile())) {
var entries = jar.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String name = entry.getName();
if (name.endsWith(".so") || name.endsWith(".dll") || name.endsWith(".dylib") || name.endsWith(".jnilib")) {
Path outFile = nativesDir.resolve(name);
if (!entry.isDirectory()) {
Files.createDirectories(outFile.getParent());
try (InputStream is = jar.getInputStream(entry)) {
Files.copy(is, outFile, StandardCopyOption.REPLACE_EXISTING);
}
}
}
}
} catch (Exception e) {
failed++;
}
extracted++;
ProgressBar.show("Natives", extracted, total, "files");
}
if (extracted > 0) {
ProgressBar.finish("Natives extracted (" + extracted + " ok, " + failed + " failed)");
} else {
LauncherLogger.warn("No natives to extract for " + classifier);
}
}
private void downloadLibraries(JSONArray libraries) throws Exception { private void downloadLibraries(JSONArray libraries) throws Exception {
int total = libraries.length(); int total = libraries.length();
int count = 0; int count = 0;
@@ -238,16 +108,10 @@ public class VersionInstaller {
Path target = minecraftDir.resolve("libraries").resolve(path); Path target = minecraftDir.resolve("libraries").resolve(path);
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
if (Files.exists(target)) {
count++;
ProgressBar.show("Libraries", count, total, "files");
continue;
}
try { try {
ZHttpClient.downloadFileWithSmartProxy(url, target); downloadFile(url, target, "library");
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.warn("[LIB] Failed to download " + path + ": " + e.getMessage()); // Skip problematic libraries
} }
} }
count++; count++;
@@ -265,18 +129,14 @@ public class VersionInstaller {
Files.createDirectories(indexesDir); Files.createDirectories(indexesDir);
Path indexPath = indexesDir.resolve(assetIndex + ".json"); Path indexPath = indexesDir.resolve(assetIndex + ".json");
if (!Files.exists(indexPath)) { System.out.println(ZAnsi.cyan("Downloading asset index (" + assetIndex + ")..."));
LauncherLogger.info("Downloading asset index (" + assetIndex + ")..."); downloadFile(indexUrl, indexPath, "asset index");
ZHttpClient.downloadFileWithSmartProxy(indexUrl, indexPath);
} else {
LauncherLogger.info("Asset index already present (" + assetIndex + ")");
}
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");
LauncherLogger.info("Downloading " + objects.length() + " asset objects (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};
@@ -296,32 +156,23 @@ public class VersionInstaller {
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
if (Files.exists(target) && Files.size(target) > 0) {
synchronized (this) {
success[0]++;
ProgressBar.show("Assets", success[0], total, "files");
}
return;
}
} catch (IOException ignored) {}
boolean downloaded = false; boolean downloaded = false;
for (int attempt = 1; attempt <= 3; attempt++) { for (int attempt = 1; attempt <= 3; attempt++) {
try { try {
ZHttpClient.downloadFileWithSmartProxy(url, target); downloadFile(url, target, "");
synchronized (this) {
success[0]++;
ProgressBar.show("Assets", success[0], total, "files");
}
downloaded = true;
break;
} catch (Exception e) {
if (attempt == 3) {
synchronized (this) { synchronized (this) {
failed[0]++; success[0]++;
ProgressBar.show("Assets", success[0], total, "files");
} }
LauncherLogger.error("[VERSION] Failed to download asset " + 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) {}
} }
} }
@@ -331,40 +182,64 @@ public class VersionInstaller {
futures.add(future); futures.add(future);
} }
try { CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.get(10, TimeUnit.MINUTES);
} catch (TimeoutException e) {
futures.forEach(f -> f.cancel(true));
LauncherLogger.error("[VERSION] Asset download timed out after 10 minutes");
} catch (CancellationException e) {
// one of the futures was cancelled
} catch (CompletionException e) {
// one of the futures failed exceptionally
}
ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)"); ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)");
if (failed[0] > 0) { if (failed[0] > 0) {
LauncherLogger.warn("Warning: " + failed[0] + " asset files could not be downloaded."); System.out.println(ZAnsi.yellow("Warning: " + failed[0] + " asset files could not be downloaded."));
LauncherLogger.warn("The game will launch, but some textures/sounds may be missing."); System.out.println(ZAnsi.yellow("The game will launch, but some textures/sounds may be missing."));
}
}
private boolean isValidJar(Path path) {
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(path.toFile())) {
return true;
} catch (Exception e) {
return false;
} }
} }
public String getAssetIndexId(String versionId) throws Exception { public String getAssetIndexId(String versionId) throws Exception {
JSONObject versionData = ZHttpClient.getMojangVersionJson(versionId); String versionUrl = getVersionUrl(versionId);
if (versionUrl == null) throw new Exception("Version not found");
String versionJson = downloadString(versionUrl);
JSONObject versionData = new JSONObject(versionJson);
if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) { if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) {
return versionData.getJSONObject("assetIndex").getString("id"); return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1
}
return versionData.getString("assets"); // fallback (very old versions)
}
private String getVersionUrl(String versionId) throws Exception {
for (MinecraftVersion v : getAvailableVersions()) {
if (v.getId().equals(versionId)) return v.getUrl();
}
return null;
}
private String downloadString(String url) throws Exception {
HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) throw new IOException("HTTP " + resp.statusCode());
return resp.body();
}
private void downloadFile(String url, Path target, String label) throws Exception {
if (!label.isEmpty()) {
ProgressBar.clearLine();
System.out.println(ZAnsi.cyan("Downloading " + label + "..."));
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) {
if (label.isEmpty()) return; // for assets silently
throw new IOException("HTTP " + response.statusCode() + " while downloading " + label);
}
if (!label.isEmpty()) {
long size = Files.size(target);
ProgressBar.finish(label + " (" + ProgressBar.formatBytes(size) + ")");
} }
return versionData.getString("assets");
} }
} }
@@ -2,34 +2,18 @@ package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
public class LaunchCommandBuilder { public class LaunchCommandBuilder {
private static final List<Path> tempFiles = new ArrayList<>();
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
for (Path p : tempFiles) {
try { Files.deleteIfExists(p); } catch (Exception ignored) {}
}
}));
}
private final Instance instance; private final Instance instance;
public LaunchCommandBuilder(Instance instance) { public LaunchCommandBuilder(Instance instance) {
@@ -37,7 +21,7 @@ public class LaunchCommandBuilder {
} }
public List<String> build(LaunchOptions options) throws Exception { public List<String> build(LaunchOptions options) throws Exception {
LauncherLogger.info(ZAnsi.cyan("Generating launch command for " + instance.getName() + "...")); System.out.println(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
@@ -45,183 +29,50 @@ public class LaunchCommandBuilder {
? options.getJavaPath() : "java"; ? options.getJavaPath() : "java";
command.add(javaPath); command.add(javaPath);
command.addAll(getJvmArguments(options));
Path nativesDir = instance.getPath().resolve("natives"); Path nativesDir = instance.getPath().resolve("natives");
if (!Files.exists(nativesDir)) { if (!Files.exists(nativesDir)) {
Files.createDirectories(nativesDir); Files.createDirectories(nativesDir);
} }
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
String loaderType = instance.getLoaderType().toLowerCase(); String loaderType = instance.getLoaderType().toLowerCase();
boolean isModloader = "fabric".equals(loaderType) || "forge".equals(loaderType) || "neoforge".equals(loaderType);
VersionManifest manifest = resolveVersionManifest(); VersionManifest manifest = resolveVersionManifest();
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) { // For modloaders, always use vanilla classpath with all libraries
// Forge/NeoForge: Pass version.json JVM args and game args through if (isModloader) {
// with placeholder substitution. Do NOT manually add -p, -cp, System.out.println(ZAnsi.cyan(" Modloader detected (" + loaderType + "), using vanilla classpath"));
// --add-modules, --add-opens, or -DignoreList. The version.json command.add("-cp");
// defines all of these. This matches AstralRinth's approach. command.add(buildVanillaClasspath());
LauncherLogger.info(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution")); command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
} else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest);
// Ensure version jar exists at versions/<versionId>/<versionId>.jar // Fallback if classpath is empty
// so securejarhandler creates module "minecraft" (from Automatic-Module-Name) if (classpath.isEmpty() || classpath.equals(instance.getPath().resolve("versions").resolve(getVersionId()).resolve(getVersionId() + ".jar").toAbsolutePath().toString())) {
// instead of "_1._20._1" (automatic module from filename 1.20.1.jar). System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
ensureVersionJarForForge();
// Parse version.json JVM args (child only) and game args (merged with parent).
// Forge/NeoForge defines its own -p, --add-modules, --add-opens.
// -Djava.library.path is NOT in child args, so it's added manually below.
List<String> allJvmArgs = manifest != null ? manifest.getJvmArguments() : new ArrayList<>();
List<String> allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>();
// Build variable map for placeholder substitution (${library_directory},
// ${classpath_separator}, ${version_name}, ${natives_directory}, ...)
Map<String, String> vars = buildVariableMap(options);
// Real Forge/NeoForge version.json (1.17+ / 47.x) declares a fixed -p
// module path of bootstrap jars and references ${library_directory} /
// ${classpath_separator} / ${version_name} but it does NOT reference
// ${classpath} and does NOT contain -cp. To let the JVM find
// cpw.mods.bootstraplauncher.BootstrapLauncher we must supply -cp
// ourselves (version jar + all libraries, exactly like the official
// launcher). If a version.json DOES reference ${classpath}, we substitute
// the same full classpath into it first.
String fullClasspath;
if (manifest != null) {
fullClasspath = buildClasspathFromManifest(manifest, true);
} else {
fullClasspath = buildVanillaClasspath();
}
boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs);
if (referencesClasspath) {
vars.put("classpath", writeClasspathFile(fullClasspath));
LauncherLogger.info(ZAnsi.green(" ${classpath} referenced, substituted full classpath"));
} else {
LauncherLogger.info(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting"));
}
boolean hasCpInJson = containsCpArg(allJvmArgs);
boolean hasLibraryPathInJson = containsLibraryPathArg(allJvmArgs);
if (!allJvmArgs.isEmpty()) {
for (String arg : allJvmArgs) {
String resolved = resolveVariable(arg, vars);
if (!resolved.isEmpty()) {
command.add(resolved);
}
}
LauncherLogger.info(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json"));
} else {
LauncherLogger.info(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
command.addAll(getJvmArguments(options));
}
// Forge/NeoForge child version.json usually doesn't include
// -Djava.library.path (47.3.0 doesn't) add it unless already present.
if (!hasLibraryPathInJson) {
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
}
// Append memory/GC args (always after version.json args, like AstralRinth) skip if systemBasedJvm adds its own GC (ZGC/G1)
boolean useSystemGc = me.sashegdev.zernmc.launcher.utils.Config.isSystemBasedJvm();
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
command.add("-Xmx" + ramMB + "M");
command.add("-Xms" + Math.max(512, ramMB / 2) + "M");
if (!useSystemGc) {
command.add("-XX:+UseG1GC");
command.add("-XX:+UnlockExperimentalVMOptions");
command.add("-XX:G1NewSizePercent=20");
command.add("-XX:G1ReservePercent=20");
command.add("-XX:MaxGCPauseMillis=50");
command.add("-XX:G1HeapRegionSize=32M");
}
// Append custom user JVM args
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
command.addAll(options.getExtraJvmArgs());
}
// Real Forge/NeoForge version.json has no -cp, so the JVM would not find the
// main class. Add -cp with version jar + all libraries (official-launcher
// behaviour); skip only if the version.json itself declares -cp.
if (!hasCpInJson) {
command.add("-cp"); command.add("-cp");
command.add(writeClasspathFile(fullClasspath)); command.add(buildVanillaClasspath());
LauncherLogger.info(ZAnsi.green(" Added -cp classpath for main class lookup"));
}
// Main class from version.json
String mainClass = null;
if (manifest != null) {
mainClass = manifest.getMainClass();
}
if (mainClass == null || mainClass.isEmpty()) {
mainClass = "cpw.mods.bootstraplauncher.BootstrapLauncher";
LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
} else {
LauncherLogger.info(ZAnsi.green(" Main class from manifest: " + mainClass));
}
command.add(mainClass);
// Parse version.json game args (merged with parent) with placeholder
// substitution. Parent provides --username, --version, etc.;
// child provides --launchTarget, --fml.*.
if (!allGameArgs.isEmpty()) {
for (String arg : allGameArgs) {
String resolved = resolveVariable(arg, vars);
if (!resolved.isEmpty()) {
command.add(resolved);
}
}
LauncherLogger.info(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json"));
} else {
LauncherLogger.info(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback"));
command.addAll(getVanillaGameArguments(options));
command.addAll(getModloaderLaunchArgs());
}
// NOTE: --width/--height are NOT appended here because version.json
// game args already include them via ${resolution_width}/${resolution_height}
// placeholders. Appending them again causes MultipleArgumentsForOptionException.
} else {
// Fabric, vanilla, and other loaders: use manual JVM args
command.addAll(getJvmArguments(options));
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
if ("fabric".equals(loaderType)) {
LauncherLogger.info(ZAnsi.cyan(" Fabric: using vanilla classpath"));
command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath()));
String mainClass = null;
if (manifest != null) {
mainClass = manifest.getMainClass();
LauncherLogger.info(ZAnsi.cyan(" Main class from manifest: " + mainClass));
}
if (mainClass == null || mainClass.isEmpty()) {
mainClass = getVanillaMainClass();
LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
}
command.add(mainClass);
command.addAll(getVanillaGameArguments(options));
} else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest, true);
if (classpath.isEmpty()) {
LauncherLogger.info(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath()));
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
} else {
command.add("-cp");
command.add(writeClasspathFile(classpath));
String mainClass = resolveMainClass(manifest);
command.add(mainClass);
command.addAll(resolveGameArguments(manifest, options));
}
} else {
command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath()));
command.add(getVanillaMainClass()); command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options)); 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; return command;
@@ -233,20 +84,15 @@ public class LaunchCommandBuilder {
if (versionJson != null && Files.exists(versionJson)) { if (versionJson != null && Files.exists(versionJson)) {
String content = Files.readString(versionJson); String content = Files.readString(versionJson);
JSONObject json = new JSONObject(content); JSONObject json = new JSONObject(content);
LauncherLogger.info(ZAnsi.green("Found version.json: " + versionJson.getFileName())); System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
VersionManifest manifest = new VersionManifest(json); return new VersionManifest(json);
manifest.resolveParent(instance.getPath().resolve("versions"));
if (manifest.getInheritsFrom() != null) {
LauncherLogger.info(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
}
return manifest;
} else { } else {
LauncherLogger.info(ZAnsi.yellow("version.json not found for " + instance.getName())); System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName()));
LauncherLogger.info(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion())); System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
LauncherLogger.info(ZAnsi.yellow(" path=" + instance.getPath())); System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
} }
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.info(ZAnsi.yellow("Failed to load version.json: " + e.getMessage())); System.out.println(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
} }
return null; return null;
} }
@@ -366,107 +212,13 @@ public class LaunchCommandBuilder {
if ("fabric".equals(loaderType)) { if ("fabric".equals(loaderType)) {
return "net.fabricmc.loader.impl.launch.knot.KnotClient"; return "net.fabricmc.loader.impl.launch.knot.KnotClient";
} else if ("forge".equals(loaderType)) { } else if ("forge".equals(loaderType)) {
return "cpw.mods.bootstraplauncher.BootstrapLauncher"; return "net.minecraftforge.client.main.ForgeClient";
} else if ("neoforge".equals(loaderType)) { } else if ("neoforge".equals(loaderType)) {
return "cpw.mods.bootstraplauncher.BootstrapLauncher"; return "cpw.mods.bootstraplauncher.BootstrapLauncher";
} }
return "net.minecraft.client.main.Main"; return "net.minecraft.client.main.Main";
} }
private List<String> getModloaderLaunchArgs() {
List<String> args = new ArrayList<>();
String loaderType = instance.getLoaderType().toLowerCase();
String mcVer = instance.getMinecraftVersion();
String loaderVer = instance.getLoaderVersion();
if ("forge".equals(loaderType)) {
args.add("--launchTarget");
args.add("forgeclient");
if (loaderVer != null && !loaderVer.isEmpty()) {
args.add("--fml.forgeVersion");
args.add(loaderVer);
}
if (mcVer != null && !mcVer.isEmpty()) {
args.add("--fml.mcVersion");
args.add(mcVer);
}
args.add("--fml.forgeGroup");
args.add("net.minecraftforge");
// Read mcpVersion from version.json if possible, fallback to empty
String mcpVer = getMcpVersion(mcVer);
if (mcpVer != null && !mcpVer.isEmpty()) {
args.add("--fml.mcpVersion");
args.add(mcpVer);
}
} else if ("neoforge".equals(loaderType)) {
args.add("--launchTarget");
args.add("neoforgeclient");
if (loaderVer != null && !loaderVer.isEmpty()) {
args.add("--fml.neoForgeVersion");
args.add(loaderVer);
}
if (mcVer != null && !mcVer.isEmpty()) {
args.add("--fml.mcVersion");
args.add(mcVer);
}
args.add("--fml.neoForgeGroup");
args.add("net.neoforged");
}
return args;
}
private String getMcpVersion(String mcVersion) {
if (mcVersion == null) return null;
Path versionsDir = instance.getPath().resolve("versions");
String loaderType = instance.getLoaderType().toLowerCase();
String loaderVer = instance.getLoaderVersion();
// Try the same candidate paths as findVersionJson()
String[] candidates = {
getVersionId(),
mcVersion + "-" + loaderType + "-" + loaderVer,
loaderType + "-" + loaderVer,
mcVersion + "-" + loaderVer,
mcVersion
};
for (String candidate : candidates) {
try {
Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json");
if (Files.exists(jsonPath)) {
String content = Files.readString(jsonPath);
JSONObject json = new JSONObject(content);
if (json.has("+mcpVersion")) {
return json.getString("+mcpVersion");
}
if (json.has("mcpVersion")) {
return json.getString("mcpVersion");
}
}
} catch (Exception ignored) {}
}
// Fallback: known MCP versions for common MC releases
switch (mcVersion) {
case "1.21": return "20240808.143909";
case "1.20.6": return "20240429.145301";
case "1.20.4": return "20231214.122116";
case "1.20.3": return "20231214.122116";
case "1.20.2": return "20231119.165538";
case "1.20.1": return "20231207.220116";
case "1.20": return "20231013.142340";
case "1.19.4": return "20230316.153408";
case "1.19.3": return "20221207.173059";
case "1.19.2": return "20221124.190222";
case "1.19.1": return "20220810.141647";
case "1.19": return "20220617.144144";
case "1.18.2": return "20220404.170452";
case "1.18.1": return "20220116.232031";
case "1.18": return "20211214.190725";
case "1.17.1": return "20210727.130541";
case "1.17": return "20210701.125435";
default: return null;
}
}
private List<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) { private List<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) {
List<String> args = new ArrayList<>(); List<String> args = new ArrayList<>();
Map<String, String> vars = buildVariableMap(options); Map<String, String> vars = buildVariableMap(options);
@@ -491,7 +243,7 @@ public class LaunchCommandBuilder {
List<String> args = new ArrayList<>(); List<String> args = new ArrayList<>();
args.add("--version"); args.add("--version");
args.add(getVersionId()); args.add(instance.getName());
args.add("--gameDir"); args.add("--gameDir");
args.add(instance.getPath().toAbsolutePath().toString()); args.add(instance.getPath().toAbsolutePath().toString());
args.add("--assetsDir"); args.add("--assetsDir");
@@ -500,32 +252,9 @@ public class LaunchCommandBuilder {
String assetIndex = instance.getAssetIndex(); String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) { if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion(); assetIndex = instance.getMinecraftVersion();
LauncherLogger.info(ZAnsi.yellow("Asset index not found, using version: " + assetIndex)); System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
} else { } else {
// Validate that the index file exists. A wrong/stale asset index System.out.println(ZAnsi.green("Using asset index: " + assetIndex));
// (e.g. the Zern-OBT "1.20.1" bug, real index is "5") makes the game
// fail to load the base resource pack (grey panorama, no sounds).
Path indexPath = instance.getPath().resolve("assets").resolve("indexes").resolve(assetIndex + ".json");
if (!Files.exists(indexPath)) {
LauncherLogger.info(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index..."));
try (java.util.stream.Stream<Path> stream = Files.list(instance.getPath().resolve("assets").resolve("indexes"))) {
java.util.List<Path> existing = stream
.filter(p -> p.getFileName().toString().endsWith(".json"))
.sorted()
.collect(java.util.stream.Collectors.toList());
if (!existing.isEmpty()) {
String found = existing.get(existing.size() - 1).getFileName().toString()
.replace(".json", "");
LauncherLogger.info(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")"));
assetIndex = found;
} else {
LauncherLogger.info(ZAnsi.yellow("No asset index files found in assets/indexes/"));
}
} catch (Exception e) {
LauncherLogger.info(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage()));
}
}
LauncherLogger.info(ZAnsi.green("Using asset index: " + assetIndex));
} }
args.add(assetIndex); args.add(assetIndex);
args.add("--username"); args.add("--username");
@@ -548,7 +277,7 @@ public class LaunchCommandBuilder {
Path nativesDir = gameDir.resolve("natives"); Path nativesDir = gameDir.resolve("natives");
Path librariesDir = gameDir.resolve("libraries"); Path librariesDir = gameDir.resolve("libraries");
vars.put("version_name", getVersionId()); vars.put("version_name", instance.getName());
vars.put("game_directory", gameDir.toString()); vars.put("game_directory", gameDir.toString());
vars.put("assets_root", assetsDir.toString()); vars.put("assets_root", assetsDir.toString());
vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion()); vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion());
@@ -588,120 +317,39 @@ public class LaunchCommandBuilder {
return result; return result;
} }
private boolean referencesClasspath(List<String> args) { private String buildClasspathFromManifest(VersionManifest manifest) throws Exception {
if (args == null) return false;
for (String arg : args) {
if (arg != null && arg.contains("${classpath}")) return true;
}
return false;
}
private boolean containsCpArg(List<String> args) {
if (args == null) return false;
for (String arg : args) {
if (arg != null && (arg.equals("-cp") || arg.equals("-classpath"))) return true;
}
return false;
}
private boolean containsLibraryPathArg(List<String> args) {
if (args == null) return false;
for (String arg : args) {
if (arg != null && arg.startsWith("-Djava.library.path=")) return true;
}
return false;
}
private String buildClasspathFromManifest(VersionManifest manifest, boolean includeVersionJar) throws Exception {
List<String> paths = new ArrayList<>(); List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)")); System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getLibraries().size() + " libraries in manifest"));
String loaderType = instance.getLoaderType().toLowerCase(); for (VersionManifest.Library lib : manifest.getLibraries()) {
boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType); Path libPath = librariesDir.resolve(lib.artifactPath);
if (Files.exists(libPath)) {
for (VersionManifest.Library lib : manifest.getAllLibraries()) { paths.add(libPath.toAbsolutePath().toString());
// For Forge/NeoForge: include ALL libraries including net.minecraft:*
// in the classpath string. Forge version.json uses "-p ${classpath}" to
// set BOTH classpath and module path to the same string. The Minecraft
// client JAR must be on the module path so securejarhandler can load it
// as a proper module. Skipping it causes ClassNotFoundException.
Path libPath = lib.artifactPath != null ? librariesDir.resolve(lib.artifactPath) : null;
if (libPath != null && Files.exists(libPath)) {
if (isValidJar(libPath)) {
paths.add(libPath.toAbsolutePath().toString());
} else {
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
Files.delete(libPath);
}
} else { } else {
String mavenPath = mavenToPath(lib.name); String mavenPath = mavenToPath(lib.name);
Path fallbackPath = librariesDir.resolve(mavenPath); Path fallbackPath = librariesDir.resolve(mavenPath);
if (Files.exists(fallbackPath)) { if (Files.exists(fallbackPath)) {
if (isValidJar(fallbackPath)) { paths.add(fallbackPath.toAbsolutePath().toString());
paths.add(fallbackPath.toAbsolutePath().toString());
} else {
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
Files.delete(fallbackPath);
}
} else { } else {
// Last resort: scan libraries dir for a JAR matching this artifact System.out.println(ZAnsi.yellow(" Library not found: " + lib.name));
String artifactName = lib.name.split(":")[1];
Path found = scanForJar(librariesDir, artifactName);
if (found != null) {
if (isValidJar(found)) {
LauncherLogger.info(ZAnsi.green(" Found by scan: " + lib.name + "" + librariesDir.relativize(found)));
paths.add(found.toAbsolutePath().toString());
} else {
LauncherLogger.info(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName()));
Files.delete(found);
}
} else {
LauncherLogger.info(ZAnsi.yellow(" Library not found (even after scan): " + lib.name));
}
} }
} }
} }
LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath")); System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
if (isForgeLike) {
LauncherLogger.info(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
LauncherLogger.info(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
}
if (includeVersionJar) { Path versionJar = findVersionJar();
Path versionJar = findVersionJar(); if (versionJar != null) {
if (versionJar != null) { paths.add(0, versionJar.toAbsolutePath().toString());
if (isValidJar(versionJar)) { System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
paths.add(0, versionJar.toAbsolutePath().toString());
LauncherLogger.info(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
} else {
LauncherLogger.info(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
Files.delete(versionJar);
}
}
} }
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
return String.join(separator, paths); return String.join(separator, paths);
} }
private Path scanForJar(Path dir, String artifactName) throws IOException {
if (!Files.exists(dir)) return null;
try (var stream = Files.walk(dir)) {
return stream
.filter(p -> p.toString().endsWith(".jar"))
.filter(p -> {
String name = p.getFileName().toString();
// Match JARs like "artifactName-version.jar" but not "artifactName-other-version.jar"
return name.startsWith(artifactName + "-") || name.equals(artifactName + ".jar");
})
.findFirst()
.orElse(null);
}
}
private String buildVanillaClasspath() throws Exception { private String buildVanillaClasspath() throws Exception {
List<String> paths = new ArrayList<>(); List<String> paths = new ArrayList<>();
String versionId = getVersionId(); String versionId = getVersionId();
@@ -770,152 +418,6 @@ public class LaunchCommandBuilder {
return null; return null;
} }
/**
* Ensure the version jar exists at versions/<versionId>/<versionId>.jar
* so that securejarhandler creates module "minecraft" (from
* Automatic-Module-Name: minecraft) instead of "_1._20._1" (automatic
* module derived from filename 1.20.1.jar).
*
* Without this, findVersionJar() falls back to versions/1.20.1/1.20.1.jar
* which produces module _1._20._1, causing split-package conflicts with
* the minecraft module.
*/
private void ensureVersionJarForForge() throws IOException {
String versionId = getVersionId();
String mcVersion = instance.getMinecraftVersion();
Path versionsDir = instance.getPath().resolve("versions");
Path targetDir = versionsDir.resolve(versionId);
Path targetJar = targetDir.resolve(versionId + ".jar");
if (Files.exists(targetJar) && isValidJar(targetJar)) {
LauncherLogger.info(ZAnsi.green(" Version jar already exists: " + targetJar));
return;
}
Files.createDirectories(targetDir);
// Copy vanilla client jar it has Automatic-Module-Name: minecraft
Path vanillaJar = versionsDir.resolve(mcVersion).resolve(mcVersion + ".jar");
if (Files.exists(vanillaJar) && isValidJar(vanillaJar)) {
Files.copy(vanillaJar, targetJar);
LauncherLogger.info(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + "" + targetJar));
return;
}
LauncherLogger.info(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar));
}
/**
* Find the SRG-mapped client jar in Forge libraries.
* Path: libraries/net/minecraftforge/client/<mcVersion>-<mcpVersion>/client-<mcVersion>-<mcpVersion>-srg.jar
* This jar contains SRG-mapped classes and has Automatic-Module-Name: minecraft.
*/
private Path findSrgClientJar() {
Path librariesDir = instance.getPath().resolve("libraries");
if (!Files.exists(librariesDir)) return null;
String mcVersion = instance.getMinecraftVersion();
String[] searchDirs = {
"net/minecraftforge/client",
"net/minecraft/client",
"net/minecraftforge/forge"
};
for (String subdir : searchDirs) {
Path dir = librariesDir.resolve(subdir);
if (!Files.exists(dir)) continue;
try (var stream = Files.list(dir)) {
Path found = stream
.filter(Files::isDirectory)
.filter(d -> d.getFileName().toString().startsWith(mcVersion + "-"))
.flatMap(d -> {
try { return Files.list(d); } catch (Exception e) { return java.util.stream.Stream.empty(); }
})
.filter(p -> p.getFileName().toString().endsWith("-srg.jar"))
.filter(this::isValidJar)
.findFirst()
.orElse(null);
if (found != null) {
LauncherLogger.info(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")"));
return found;
}
} catch (Exception e) { /* continue */ }
}
// Fallback: scan libraries root for any *-srg.jar matching mcVersion
try {
Path found = java.nio.file.Files.walk(librariesDir, 5)
.filter(p -> p.toString().endsWith("-srg.jar"))
.filter(p -> p.toString().contains(mcVersion))
.filter(this::isValidJar)
.findFirst()
.orElse(null);
if (found != null) {
LauncherLogger.info(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")"));
return found;
}
} catch (Exception e) { /* continue */ }
return null;
}
private String getModuleName(Path jar) {
try (var jarFile = new java.util.jar.JarFile(jar.toFile())) {
var manifest = jarFile.getManifest();
if (manifest == null) return "null manifest";
String name = manifest.getMainAttributes().getValue("Automatic-Module-Name");
return name != null ? name : "not set";
} catch (Exception e) {
return "error: " + e.getMessage();
}
}
private boolean hasMinecraftModuleName(Path jar) {
try (var jarFile = new java.util.jar.JarFile(jar.toFile())) {
var manifest = jarFile.getManifest();
if (manifest == null) return false;
String moduleName = manifest.getMainAttributes().getValue("Automatic-Module-Name");
return "minecraft".equals(moduleName);
} catch (Exception e) {
return false;
}
}
/**
* Find the patched Forge client jar in libraries/.
* The Forge installer creates it via binarypatcher processor:
* libraries/net/minecraftforge/forge/<version>/forge-<version>-client.jar
* This jar contains all vanilla MC classes + Forge patches.
* Using it instead of vanilla 1.20.1.jar avoids the _1._20._1 vs minecraft
* split-package conflict, because the filename produces a different automatic
* module name.
*/
private Path findPatchedForgeJar() {
Path librariesDir = instance.getPath().resolve("libraries");
String mcVersion = instance.getMinecraftVersion();
String loaderVer = instance.getLoaderVersion();
String fullVersion = mcVersion + "-" + loaderVer;
String[] candidates = {
"net/minecraftforge/forge/" + fullVersion + "/forge-" + fullVersion + "-client.jar",
"net/minecraftforge/forge/" + fullVersion + "/forge-" + fullVersion + "-universal.jar",
"net/neoforged/neoforge/" + fullVersion + "/neoforge-" + fullVersion + "-client.jar",
"net/neoforged/neoforge/" + fullVersion + "/neoforge-" + fullVersion + "-universal.jar"
};
for (String candidate : candidates) {
Path jarPath = librariesDir.resolve(candidate);
if (Files.exists(jarPath) && isValidJar(jarPath)) {
LauncherLogger.info(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName()));
return jarPath;
}
}
LauncherLogger.info(ZAnsi.yellow(" No Forge client/universal jar found!"));
return null;
}
private String mavenToPath(String mavenName) { private String mavenToPath(String mavenName) {
String[] parts = mavenName.split(":"); String[] parts = mavenName.split(":");
if (parts.length < 3) return mavenName; if (parts.length < 3) return mavenName;
@@ -989,69 +491,4 @@ public class LaunchCommandBuilder {
return jvmArgs; return jvmArgs;
} }
private boolean isValidJar(Path path) {
if (path == null || !Files.exists(path)) return false;
try (ZipFile zf = new ZipFile(path.toFile())) {
return true;
} catch (ZipException e) {
return false;
} catch (IOException e) {
return false;
}
}
/**
* Removes classpath entries that are already listed on the module path
* (from -p or --module-path in manifest JVM arguments). Prevents split-package
* conflicts when --add-modules=ALL-MODULE-PATH resolves classpath JARs
* as automatic modules.
*/
private String filterClasspathAgainstModulePath(String classpath, List<String> manifestJvmArgs) {
if (classpath == null || classpath.isEmpty() || manifestJvmArgs == null || manifestJvmArgs.isEmpty()) {
return classpath;
}
Set<String> modulePathFilenames = new HashSet<>();
// Parse -p / --module-path values from manifest JVM arguments
for (int i = 0; i < manifestJvmArgs.size(); i++) {
String arg = manifestJvmArgs.get(i);
if (("-p".equals(arg) || "--module-path".equals(arg)) && i + 1 < manifestJvmArgs.size()) {
String modulePathValue = manifestJvmArgs.get(i + 1);
String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
for (String entry : modulePathValue.split(sep)) {
int lastSep = Math.max(entry.lastIndexOf('/'), entry.lastIndexOf('\\'));
modulePathFilenames.add(lastSep >= 0 ? entry.substring(lastSep + 1) : entry);
}
}
}
if (modulePathFilenames.isEmpty()) return classpath;
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
StringBuilder filtered = new StringBuilder();
for (String entry : classpath.split(separator)) {
if (entry.isEmpty()) continue;
int lastSep = Math.max(entry.lastIndexOf('/'), entry.lastIndexOf('\\'));
String filename = lastSep >= 0 ? entry.substring(lastSep + 1) : entry;
if (!modulePathFilenames.contains(filename)) {
if (filtered.length() > 0) filtered.append(separator);
filtered.append(entry);
}
}
String result = filtered.toString();
return result.isEmpty() ? classpath : result;
}
// For very long classpaths (Windows cmd truncation), write to a temp argfile
private String writeClasspathFile(String classpath) throws IOException {
if (classpath.length() < 8000) return classpath;
Path tempFile = Files.createTempFile("zernmc-cp-", ".txt");
Files.writeString(tempFile, classpath);
tempFiles.add(tempFile);
LauncherLogger.info(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile));
return "@" + tempFile.toAbsolutePath();
}
} }
@@ -1,7 +1,5 @@
package me.sashegdev.zernmc.launcher.minecraft.launch; package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@@ -17,15 +15,13 @@ public class VersionManifest {
private final String id; private final String id;
private final String mainClass; private final String mainClass;
private final String assetIndexId; private final String assetIndexId;
private final String inheritsFrom;
private final List<String> jvmArguments; private final List<String> jvmArguments;
private final List<String> gameArguments; private final List<String> gameArguments;
private final List<Library> libraries; private final List<Library> libraries;
private VersionManifest parent;
public VersionManifest(JSONObject json) { public VersionManifest(JSONObject json) {
this.id = json.getString("id"); this.id = json.getString("id");
this.mainClass = json.optString("mainClass", "net.minecraft.client.main.Main"); this.mainClass = json.getString("mainClass");
if (json.has("assetIndex")) { if (json.has("assetIndex")) {
JSONObject ai = json.getJSONObject("assetIndex"); JSONObject ai = json.getJSONObject("assetIndex");
@@ -34,7 +30,6 @@ public class VersionManifest {
this.assetIndexId = "unknown"; this.assetIndexId = "unknown";
} }
this.inheritsFrom = json.optString("inheritsFrom", null);
this.jvmArguments = parseArguments(json, "jvm"); this.jvmArguments = parseArguments(json, "jvm");
this.gameArguments = parseArguments(json, "game"); this.gameArguments = parseArguments(json, "game");
this.libraries = parseLibraries(json); this.libraries = parseLibraries(json);
@@ -43,65 +38,10 @@ public class VersionManifest {
public String getId() { return id; } public String getId() { return id; }
public String getMainClass() { return mainClass; } public String getMainClass() { return mainClass; }
public String getAssetIndexId() { return assetIndexId; } public String getAssetIndexId() { return assetIndexId; }
public String getInheritsFrom() { return inheritsFrom; }
public List<String> getJvmArguments() { return jvmArguments; } public List<String> getJvmArguments() { return jvmArguments; }
public List<String> getGameArguments() { return gameArguments; } public List<String> getGameArguments() { return gameArguments; }
public List<Library> getLibraries() { return libraries; } public List<Library> getLibraries() { return libraries; }
public List<String> getAllJvmArguments() {
List<String> all = new ArrayList<>();
if (parent != null) {
all.addAll(parent.getAllJvmArguments());
}
all.addAll(jvmArguments);
return all;
}
public List<String> getAllGameArguments() {
List<String> all = new ArrayList<>();
if (parent != null) {
all.addAll(parent.getAllGameArguments());
}
all.addAll(gameArguments);
return all;
}
public void resolveParent(Path versionsDir) {
if (inheritsFrom == null || parent != null) return;
Path parentDir = versionsDir.resolve(inheritsFrom);
Path parentJson = parentDir.resolve(inheritsFrom + ".json");
if (Files.exists(parentJson)) {
try {
String content = Files.readString(parentJson);
JSONObject json = new JSONObject(content);
this.parent = new VersionManifest(json);
this.parent.resolveParent(versionsDir);
} catch (Exception e) {
LauncherLogger.warn("Failed to load parent version " + inheritsFrom + ": " + e.getMessage());
}
}
}
public List<Library> getAllLibraries() {
List<Library> all = new ArrayList<>();
if (parent != null) {
all.addAll(parent.getAllLibraries());
}
for (Library lib : libraries) {
if (!containsLibrary(all, lib.name)) {
all.add(lib);
}
}
return all;
}
private boolean containsLibrary(List<Library> list, String name) {
for (Library l : list) {
if (l.name.equals(name)) return true;
}
return false;
}
private List<String> parseArguments(JSONObject json, String type) { private List<String> parseArguments(JSONObject json, String type) {
List<String> args = new ArrayList<>(); List<String> args = new ArrayList<>();
if (!json.has("arguments")) return args; if (!json.has("arguments")) return args;
@@ -189,30 +129,29 @@ public class VersionManifest {
JSONArray arr = json.getJSONArray("libraries"); JSONArray arr = json.getJSONArray("libraries");
for (int i = 0; i < arr.length(); i++) { for (int i = 0; i < arr.length(); i++) {
JSONObject libJson = arr.getJSONObject(i); JSONObject libJson = arr.getJSONObject(i);
String name = libJson.getString("name");
String artifactPath = null;
if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) { if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) {
artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path"); String name = libJson.getString("name");
} String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path");
Library lib = new Library(name, artifactPath); Library lib = new Library(name, artifactPath);
if (libJson.has("natives")) { if (libJson.has("natives")) {
JSONObject natives = libJson.getJSONObject("natives"); JSONObject natives = libJson.getJSONObject("natives");
for (String key : natives.keySet()) { for (String key : natives.keySet()) {
String osKey = key.toLowerCase(); String osKey = key.toLowerCase();
lib.natives.put(osKey, natives.getString(key)); lib.natives.put(osKey, natives.getString(key));
}
} }
}
boolean allowed = true; if (libJson.has("rules")) {
if (libJson.has("rules")) { JSONObject dummyObj = new JSONObject();
JSONObject dummyObj = new JSONObject(); dummyObj.put("rules", libJson.getJSONArray("rules"));
dummyObj.put("rules", libJson.getJSONArray("rules")); dummyObj.put("value", "");
dummyObj.put("value", ""); if (ruleMatches(dummyObj)) {
allowed = ruleMatches(dummyObj); libs.add(lib);
} }
if (allowed) { } else {
libs.add(lib); libs.add(lib);
}
} }
} }
return libs; return libs;
@@ -59,7 +59,7 @@ public class ArrowMenu {
if (next == -1) { if (next == -1) {
return -1; return -1;
} }
if (next == 0x5B || next == 0x4F) { // '[' (CSI) or 'O' (SS3) if (next == 0x5B) { // '['
int arrow = nonBlockingRead(); int arrow = nonBlockingRead();
if (arrow == 0x41) { // Up if (arrow == 0x41) { // Up
selected = (selected - 1 + options.size()) % options.size(); selected = (selected - 1 + options.size()) % options.size();
@@ -1,101 +0,0 @@
package me.sashegdev.zernmc.launcher.ui.jfx;
/**
* Safe reflection bridge to JFXLauncher for use from non-JFX code (CLI, menus,
* installers).
*
* IMPORTANT: JFXLauncher extends javafx.application.Application. In CLI mode
* JavaFX is NOT on the module path, so loading JFXLauncher (or any class that
* references JavaFX) throws NoClassDefFoundError (an Error, not an Exception).
* Therefore the bridge must catch Throwable and never let JFX-launcher errors
* leak into CLI/game-launch code paths.
*
* Rule: any call into JFX UI state from non-JFX context MUST go through this
* bridge. Direct JFXLauncher.x() calls are only allowed inside JFXLauncher.java.
*/
public final class JFXBridge {
private JFXBridge() {}
private static final String JFX_CLASS = "me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher";
private static volatile java.lang.reflect.Method appendGameLogMethod;
private static volatile java.lang.reflect.Method appendLauncherLogFromLoggerMethod;
private static volatile java.lang.reflect.Method setInstallProgressMethod;
private static volatile java.lang.reflect.Method setInstallStageMethod;
private static java.lang.reflect.Method method(String name, Class<?>... params) {
java.lang.reflect.Method m = null;
try {
Class<?> clazz = Class.forName(JFX_CLASS);
m = clazz.getMethod(name, params);
} catch (Throwable t) {
// JavaFX not available (CLI mode) or JFXLauncher not initialized.
}
return m;
}
/** Forward a game log line to the JFX UI. Safe no-op in CLI mode. */
public static void appendGameLog(String msg) {
if (appendGameLogMethod == null) {
appendGameLogMethod = method("appendGameLog", String.class);
}
if (appendGameLogMethod == null) {
return;
}
try {
appendGameLogMethod.invoke(null, msg);
} catch (Throwable t) {
appendGameLogMethod = null;
}
}
/**
* Forward a launcher log line to the JFX UI without re-logging it through
* LauncherLogger. Called from LauncherLogger.write so all logged lines
* (including installer output) become visible in the UI. Safe no-op in CLI mode.
*/
public static void appendLauncherLogFromLogger(String msg) {
if (appendLauncherLogFromLoggerMethod == null) {
appendLauncherLogFromLoggerMethod = method("appendLauncherLogFromLogger", String.class);
}
if (appendLauncherLogFromLoggerMethod == null) {
return;
}
try {
appendLauncherLogFromLoggerMethod.invoke(null, msg);
} catch (Throwable t) {
appendLauncherLogFromLoggerMethod = null;
}
}
/** Forward install progress to the JFX UI. Safe no-op in CLI mode. */
public static void setInstallProgress(String label, int current, int total) {
if (setInstallProgressMethod == null) {
setInstallProgressMethod = method("setInstallProgress", String.class, int.class, int.class);
}
if (setInstallProgressMethod == null) {
return;
}
try {
setInstallProgressMethod.invoke(null, label, current, total);
} catch (Throwable t) {
setInstallProgressMethod = null;
}
}
/** Forward install stage to the JFX UI. Safe no-op in CLI mode. */
public static void setInstallStage(String stageName, int stageIndex, int stageCount) {
if (setInstallStageMethod == null) {
setInstallStageMethod = method("setInstallStage", String.class, int.class, int.class);
}
if (setInstallStageMethod == null) {
return;
}
try {
setInstallStageMethod.invoke(null, stageName, stageIndex, stageCount);
} catch (Throwable t) {
setInstallStageMethod = null;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -10,26 +10,21 @@ public class Config {
private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc"); 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 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 final Properties props = new Properties();
private static volatile int maxMemory = 4096; private static int maxMemory = 4096;
private static volatile String serverUrl = "https://api.zern.cc"; private static String serverUrl = "http://87.120.187.36:1582";
private static volatile String lastUsername = "Player"; private static String lastUsername = "Player";
private static volatile int windowWidth = 1280; private static int windowWidth = 1280;
private static volatile int windowHeight = 720; private static int windowHeight = 720;
private static volatile String extraJvmArgs = ""; private static String extraJvmArgs = "";
private static volatile String javaPath = "java"; private static String javaPath = "java";
private static volatile boolean ramManuallySet = false;
private static volatile String locale = "en";
private static volatile boolean systemBasedJvm = false;
private static volatile String onboardedVersion = "";
private static volatile String completedOnboardBlocks = "";
static { static {
load(); load();
if (!ramManuallySet) { applySmartRamRecommendation();
applySmartRamRecommendation();
}
} }
private static void load() { private static void load() {
@@ -41,30 +36,13 @@ public class Config {
} }
} }
try { maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
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); serverUrl = props.getProperty("serverUrl", serverUrl);
lastUsername = props.getProperty("lastUsername", lastUsername); lastUsername = props.getProperty("lastUsername", lastUsername);
try { windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280"));
windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280")); windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720"));
} 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", ""); extraJvmArgs = props.getProperty("extraJvmArgs", "");
javaPath = props.getProperty("javaPath", "java"); javaPath = props.getProperty("javaPath", "java");
locale = props.getProperty("locale", "en");
systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false"));
onboardedVersion = props.getProperty("onboardedVersion", "");
completedOnboardBlocks = props.getProperty("completedOnboardBlocks", "");
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage()); System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage());
@@ -74,17 +52,12 @@ public class Config {
public static void save() { public static void save() {
try { try {
props.setProperty("maxMemory", String.valueOf(maxMemory)); props.setProperty("maxMemory", String.valueOf(maxMemory));
props.setProperty("ramManuallySet", String.valueOf(ramManuallySet));
props.setProperty("serverUrl", serverUrl); props.setProperty("serverUrl", serverUrl);
props.setProperty("lastUsername", lastUsername); props.setProperty("lastUsername", lastUsername);
props.setProperty("windowWidth", String.valueOf(windowWidth)); props.setProperty("windowWidth", String.valueOf(windowWidth));
props.setProperty("windowHeight", String.valueOf(windowHeight)); props.setProperty("windowHeight", String.valueOf(windowHeight));
props.setProperty("extraJvmArgs", extraJvmArgs); props.setProperty("extraJvmArgs", extraJvmArgs);
props.setProperty("javaPath", javaPath); props.setProperty("javaPath", javaPath);
props.setProperty("locale", locale);
props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm));
props.setProperty("onboardedVersion", onboardedVersion);
props.setProperty("completedOnboardBlocks", completedOnboardBlocks);
try (var os = Files.newOutputStream(CONFIG_FILE)) { try (var os = Files.newOutputStream(CONFIG_FILE)) {
props.store(os, "ZernMC Launcher Configuration"); props.store(os, "ZernMC Launcher Configuration");
@@ -95,54 +68,37 @@ public class Config {
} }
private static void applySmartRamRecommendation() { private static void applySmartRamRecommendation() {
long totalRamMB = getTotalSystemRamMB(); long totalRamMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
if (totalRamMB <= 0) return;
long recommended; long recommended = (long) (totalRamMB * 0.70);
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) { recommended = Math.max(1536, recommended);
recommended = Math.min(recommended, totalRamMB - 1024);
if (Math.abs(maxMemory - recommended) > 1024) {
maxMemory = (int) recommended; maxMemory = (int) recommended;
save(); save();
System.out.println(ZAnsi.cyan("Auto-recommended RAM: " + maxMemory + " MB")); 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() { public static int getMaxMemory() {
return maxMemory; return maxMemory;
} }
public static boolean isZernMCBuild() {
return "zernmc".equalsIgnoreCase(BUILD_PROFILE);
}
public static boolean isGlobalBuild() {
return !isZernMCBuild();
}
public static void setMaxMemory(int memory) { public static void setMaxMemory(int memory) {
if (memory < 1024) memory = 1536; if (memory < 1024) memory = 1536;
if (memory > 32768) memory = 32768; if (memory > 32768) memory = 32768;
maxMemory = memory; maxMemory = memory;
ramManuallySet = true;
save(); save();
} }
@@ -150,11 +106,6 @@ public class Config {
return serverUrl; return serverUrl;
} }
public static void setServerUrl(String url) {
serverUrl = url != null && !url.isBlank() ? url : serverUrl;
save();
}
public static String getLastUsername() { public static String getLastUsername() {
return lastUsername; return lastUsername;
} }
@@ -212,69 +163,8 @@ public class Config {
save(); save();
} }
public static String getLocale() {
return locale;
}
public static void setLocale(String lang) {
if (lang != null && (lang.equals("en") || lang.equals("ru"))) {
locale = lang;
save();
}
}
public static boolean isSystemBasedJvm() {
return systemBasedJvm;
}
public static void setSystemBasedJvm(boolean enabled) {
systemBasedJvm = enabled;
save();
}
public static int getSystemCpuCores() {
return Runtime.getRuntime().availableProcessors();
}
public static long getSystemTotalRamMB() {
long totalMb = getTotalSystemRamMB();
if (totalMb > 0) return totalMb;
try {
java.lang.management.OperatingSystemMXBean osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
if (osBean instanceof com.sun.management.OperatingSystemMXBean sunBean) {
return sunBean.getTotalMemorySize() / (1024 * 1024);
}
} catch (Exception ignored) {}
long jvmMax = Runtime.getRuntime().maxMemory() / (1024 * 1024);
return Math.max(jvmMax, 4096);
}
public static String getSystemJvmFlags() {
int cores = getSystemCpuCores();
long ramMB = getSystemTotalRamMB();
StringBuilder sb = new StringBuilder();
sb.append("-XX:ParallelGCThreads=").append(Math.max(1, cores));
sb.append(" -XX:ConcGCThreads=").append(Math.max(1, cores / 2));
sb.append(" -XX:+AlwaysPreTouch");
if (ramMB >= 8192) {
sb.append(" -XX:+UseZGC");
sb.append(" -XX:ZAllocationSpikeTolerance=2.0");
} else {
sb.append(" -XX:+UseG1GC");
sb.append(" -XX:MaxGCPauseMillis=50");
sb.append(" -XX:G1HeapRegionSize=16M");
}
sb.append(" -Xss4M");
return sb.toString();
}
public static String getRamInfo() { public static String getRamInfo() {
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB"; return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB";
} }
public static String getOnboardedVersion() { return onboardedVersion; }
public static void setOnboardedVersion(String v) { onboardedVersion = v != null ? v : ""; save(); }
public static String getCompletedOnboardBlocks() { return completedOnboardBlocks; }
public static void setCompletedOnboardBlocks(String s) { completedOnboardBlocks = s != null ? s : ""; save(); }
} }
@@ -1,133 +0,0 @@
package me.sashegdev.zernmc.launcher.utils;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Picks the fastest reachable ZernMC API domain at launcher startup.
* TSPU/SNI-обход: если основной api.zern.cc недоступен (заблокирован ТСПУ),
* пробиваем legacy-домены и гео-зеркала и переключаемся на первый живой/быстрый.
*/
public final class DomainSelector {
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8))
.version(HttpClient.Version.HTTP_1_1)
.build();
private static final List<String> FALLBACK_CANDIDATES = List.of(
"https://api.zern.cc",
"https://api.zernmc.ru",
"https://api.zernmc.online",
"https://api.pl.zern.cc",
"https://api.swe.zern.cc"
);
private static final String MIRRORS_PATH = "/launcher/mirrors";
private static final String VERSION_PATH = "/launcher/version";
private DomainSelector() {
}
/**
* @return выбранный base URL или null, если ни один домен не ответил.
*/
public static String selectBest() {
List<String> candidates = new ArrayList<>();
String configured = Config.getServerUrl();
if (configured != null && !configured.isBlank() && !candidates.contains(configured)) {
candidates.add(configured);
}
for (String c : FALLBACK_CANDIDATES) {
if (!candidates.contains(c)) {
candidates.add(c);
}
}
candidates.addAll(fetchMirrors());
String best = null;
long bestMs = Long.MAX_VALUE;
for (String base : candidates) {
Probe r = probe(base);
if (r.ok && r.latencyMs >= 0 && r.latencyMs < bestMs) {
bestMs = r.latencyMs;
best = base;
}
}
if (best != null) {
// Make every candidate (incl. geo mirrors) available as a fallback proxy base.
for (String c : candidates) {
ZHttpClient.registerProxyBase(c);
}
}
if (best == null) {
// nothing reachable: keep whatever is configured
System.err.println(ZAnsi.brightRed("DomainSelector: no reachable API domain"));
return null;
}
if (!best.equals(configured)) {
ZHttpClient.setBaseUrl(best);
Config.setServerUrl(best);
System.out.println(ZAnsi.cyan("Selected API domain: " + best)
+ " (" + bestMs + " ms)");
}
return best;
}
private static List<String> fetchMirrors() {
List<String> mirrors = new ArrayList<>();
for (String candidate : FALLBACK_CANDIDATES) {
Probe p = probe(candidate);
if (!p.ok || p.body == null) {
continue;
}
try {
org.json.JSONObject json = new org.json.JSONObject(p.body);
var arr = json.optJSONArray("mirrors");
if (arr == null) {
continue;
}
for (int i = 0; i < arr.length(); i++) {
String url = arr.optJSONObject(i) != null
? arr.optJSONObject(i).optString("url", null) : null;
if (url != null && !url.isBlank() && !mirrors.contains(url)) {
mirrors.add(url);
}
}
break; // первый живой источник списка достаточен
} catch (Exception ignored) {
// continue to next candidate
}
}
return mirrors;
}
private static Probe probe(String baseUrl) {
long t0 = System.currentTimeMillis();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + MIRRORS_PATH))
.timeout(Duration.ofSeconds(8))
.GET()
.header("User-Agent", "ZernMC-Launcher/DomainSelector")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
long ms = System.currentTimeMillis() - t0;
boolean ok = response.statusCode() == 200;
return new Probe(ok, ms, ok ? response.body() : null);
} catch (Exception e) {
return new Probe(false, System.currentTimeMillis() - t0, null);
}
}
private record Probe(boolean ok, long latencyMs, String body) {
}
}
@@ -1,42 +0,0 @@
package me.sashegdev.zernmc.launcher.utils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Resolves the java executable used to run the launcher itself, so child
* processes (Forge/NeoForge/Fabric installers) run on the bundled JRE
* instead of relying on a system java that may not exist or be too old.
*
* Order of preference:
* 1. The JVM currently running the launcher (bundled jre21 in lib/jre21).
* 2. A jre21 next to the working directory (dev layout).
* 3. System PATH lookup (last resort).
*/
public final class JavaResolver {
private JavaResolver() {}
public static String getJavaExecutable() {
String os = System.getProperty("os.name", "").toLowerCase();
String javaExe = os.contains("win") ? "java.exe" : "java";
Path fromHome = Paths.get(System.getProperty("java.home"), "bin", javaExe);
if (Files.exists(fromHome)) {
return fromHome.toAbsolutePath().toString();
}
Path bundled = Paths.get("").toAbsolutePath().resolve("lib").resolve("jre21").resolve("bin").resolve(javaExe);
if (Files.exists(bundled)) {
return bundled.toString();
}
Path devJre = Paths.get("").toAbsolutePath().resolve("jre21").resolve("bin").resolve(javaExe);
if (Files.exists(devJre)) {
return devJre.toString();
}
return javaExe;
}
}
@@ -1,209 +0,0 @@
package me.sashegdev.zernmc.launcher.utils;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Pattern;
public class LauncherLogger {
private static Path logFile;
private static boolean initialized = false;
private static final ReentrantLock lock = new ReentrantLock();
private static final int STDOUT_QUEUE_CAPACITY = 2048;
private static final BlockingQueue<String> stdoutQueue = new LinkedBlockingQueue<>(STDOUT_QUEUE_CAPACITY);
private static final Thread stdoutWriterThread;
private static final Pattern ANSI_PATTERN = Pattern.compile("\u001B\\[[0-9;]*[A-Za-z]");
static {
stdoutWriterThread = new Thread(() -> {
while (true) {
try {
String line = stdoutQueue.take();
System.out.println(line);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception ignored) {
// stdout may be a closed or full pipe; never let it kill the writer
}
}
}, "launcher-log-stdout");
stdoutWriterThread.setDaemon(true);
stdoutWriterThread.start();
}
public static synchronized void init() {
if (initialized) return;
initialized = true;
try {
Path logsDir = Paths.get("").toAbsolutePath().resolve("logs");
Files.createDirectories(logsDir);
rotateLogs(logsDir);
logFile = logsDir.resolve("latest.log");
Files.writeString(logFile, sessionHeader(), StandardOpenOption.CREATE);
System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath());
} catch (Exception e) {
System.err.println("[LauncherLogger] init error: " + e.getMessage());
e.printStackTrace();
}
}
private static String sessionHeader() {
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
String os = System.getProperty("os.name") + " " + System.getProperty("os.version")
+ " " + System.getProperty("os.arch");
String java = System.getProperty("java.version") + " (" + System.getProperty("java.vendor") + ")";
String mem = (Runtime.getRuntime().maxMemory() / (1024 * 1024)) + " MB";
return "== ZernMC Launcher =======================================================\n"
+ "== Started: " + ts + "\n"
+ "== OS: " + os + "\n"
+ "== Java: " + java + "\n"
+ "== Max memory: " + mem + "\n"
+ "====================================================================\n";
}
/**
* Minecraft-style rotation: compress the previous latest.log into a dated
* .log.gz archive (unlimited history), then remove the legacy launcher.log.
*/
private static void rotateLogs(Path logsDir) {
// Legacy one-instance log from older builds: delete it, it is replaced by latest.log.
try {
Files.deleteIfExists(logsDir.resolve("launcher.log"));
} catch (Exception ignored) {
}
Path latest = logsDir.resolve("latest.log");
try {
if (Files.exists(latest) && Files.size(latest) > 0) {
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
int n = 1;
Path archive;
do {
archive = logsDir.resolve(ts + (n == 1 ? "" : "-" + n) + ".log.gz");
n++;
} while (Files.exists(archive));
gzip(logsDir, archive, latest);
Files.deleteIfExists(latest);
}
} catch (Exception e) {
System.err.println("[LauncherLogger] rotate error: " + e.getMessage());
}
}
private static void gzip(Path logsDir, Path archive, Path source) {
try (var in = Files.newInputStream(source);
var gz = new java.util.zip.GZIPOutputStream(Files.newOutputStream(archive))) {
byte[] buf = new byte[65536];
int n;
while ((n = in.read(buf)) > 0) {
gz.write(buf, 0, n);
}
} catch (Exception e) {
System.err.println("[LauncherLogger] gzip error: " + e.getMessage());
}
}
public static Path getLogFile() {
return logFile;
}
public static void info(String msg) {
write("INFO", msg, null);
}
public static void warn(String msg) {
write("WARN", msg, null);
}
public static void error(String msg) {
write("ERROR", msg, null);
}
public static void error(String msg, Throwable t) {
write("ERROR", msg, t);
}
public static void debug(String msg) {
write("DEBUG", msg, null);
}
private static void write(String level, String msg, Throwable t) {
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"));
String line = "[" + ts + "] [" + level + "] " + msg;
// stdout goes through a dedicated writer thread with a bounded queue so a
// full/hung stdout pipe (e.g. javaw piped to the parent exe) can never
// block application threads.
offerStdout(line);
// File and UI log never get ANSI escape codes; the console keeps them.
String plainLine = ANSI_PATTERN.matcher(line).replaceAll("");
// Forward to the JFX UI log so installer/progress output is visible
// in the GUI. The bridge is a safe no-op when JavaFX is unavailable.
JFXBridge.appendLauncherLogFromLogger(plainLine);
if (t != null) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
t.printStackTrace(pw);
pw.flush();
offerStdout(sw.toString());
}
if (logFile != null) {
// Never block indefinitely on the file write; give up after a short wait
// so a stuck writer cannot deadlock every logging thread.
boolean acquired = false;
try {
acquired = lock.tryLock(200, TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
if (acquired) {
try {
Files.writeString(logFile, plainLine + "\n", StandardOpenOption.APPEND);
if (t != null) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
t.printStackTrace(pw);
pw.flush();
Files.writeString(logFile, sw.toString(), StandardOpenOption.APPEND);
}
} catch (IOException e) {
System.err.println("[LauncherLogger] write error: " + e.getMessage());
} finally {
lock.unlock();
}
}
}
}
private static void offerStdout(String line) {
try {
if (!stdoutQueue.offer(line, 10, TimeUnit.MILLISECONDS)) {
// Queue full; drop rather than block the caller.
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
}
@@ -1,118 +1,105 @@
package me.sashegdev.zernmc.launcher.utils; package me.sashegdev.zernmc.launcher.utils;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
import java.text.DecimalFormat; import java.text.DecimalFormat;
/**
* Progress reporting for installs/downloads.
*
* IMPORTANT: never writes directly to System.out/System.err. In JFX mode the
* launcher runs under javaw.exe whose stdout pipe is drained by the parent exe;
* once that pipe fills (or the exe stops reading), a synchronous
* System.out.write/flush blocks the calling (install/UI) thread forever.
* All console/progress output must go through LauncherLogger, which is
* non-blocking by design (bounded stdout queue + tryLock file write + SSE queue).
*/
public class ProgressBar { public class ProgressBar {
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 String currentLabel = "";
private static long currentTotal = 0; private static long currentTotal = 0;
private static final long DOWNLOAD_LOG_INTERVAL_MS = 500;
private static volatile long lastDownloadLogMs = 0;
private static volatile long lastIndeterminateLogMs = 0;
public static void show(String label, long current, long total, String unit) { public static void show(String label, long current, long total, String unit) {
currentLabel = label; currentLabel = label;
currentTotal = total; currentTotal = total;
JFXBridge.setInstallProgress(label, (int) current, (int) 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) {
LauncherLogger.info(label + " ..."); System.out.print("\r" + ZAnsi.cyan(label) + " ...");
return; return;
} }
int percent = (int) ((double) current / total * 100); double progress = (double) current / total;
LauncherLogger.info(label + " " + percent + "% (" + current + "/" + total + " " + unit + ")"); int filled = (int) (progress * BAR_LENGTH);
String bar = "".repeat(filled) + "".repeat(BAR_LENGTH - filled);
int percent = (int) (progress * 100);
String text = String.format("%s [%s] %3d%% (%d/%d %s)",
ZAnsi.cyan(label), bar, percent, current, total, unit);
System.out.print("\r" + text);
System.out.flush();
} }
public static void showDownload(String label, long downloaded, long totalBytes) { public static void showDownload(String label, long downloaded, long totalBytes) {
currentLabel = label; currentLabel = label;
currentTotal = totalBytes; currentTotal = totalBytes;
JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) 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) {}
// Throttle console/file logging: called once per chunk during downloads. if (totalBytes <= 0) {
long now = System.currentTimeMillis(); System.out.print("\r" + ZAnsi.cyan(label) + " ...");
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) { return;
lastDownloadLogMs = now;
LauncherLogger.info(label + " " + formatBytes(downloaded)
+ (totalBytes > 0 ? "/" + formatBytes(totalBytes) : ""));
} }
double progress = (double) downloaded / totalBytes;
int filled = (int) (progress * BAR_LENGTH);
String bar = "".repeat(filled) + "".repeat(BAR_LENGTH - filled);
String percent = DF.format(progress * 100);
String text = String.format("%s [%s] %6s%% %s / %s",
ZAnsi.cyan(label),
bar,
percent,
formatBytes(downloaded),
formatBytes(totalBytes));
System.out.print("\r" + text);
System.out.flush();
} }
public static void showAnimated(String label, long current, long total, String unit) { public static void showAnimated(String label, long current, long total, String unit) {
currentLabel = label; currentLabel = label;
currentTotal = total; currentTotal = total;
JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100)); 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) {}
long now = System.currentTimeMillis(); if (total <= 0) {
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) { char[] spinner = {'|', '/', '-', '\\'};
lastDownloadLogMs = now; int idx = (int) (current / 1024) % 4;
LauncherLogger.info(label + ": " + formatBytes(current) System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
+ (total > 0 ? "/" + formatBytes(total) + " " + unit : "")); } else {
show(label, (int) ((current * 100) / total), 100, unit);
} }
} }
public static void finish(String message) { public static void finish(String message) {
LauncherLogger.info(message + " done"); 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 ✓"));
* Shows a stage without a known total (e.g. running the Forge/NeoForge/Fabric System.out.flush();
* installer subprocess). Always updates the UI label so the user sees what the
* launcher is doing right now; console/file logging is throttled to avoid spam.
*/
public static void showIndeterminate(String label) {
currentLabel = label;
currentTotal = 0;
JFXBridge.setInstallProgress(label, 0, 0);
long now = System.currentTimeMillis();
if (now - lastIndeterminateLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
lastIndeterminateLogMs = now;
LauncherLogger.info(label + " ...");
}
}
/**
* Strips ANSI codes, indentation and [category] prefixes from a child-process
* output line so it can be used as a short, human-readable progress label.
*/
public static String cleanLine(String line) {
if (line == null) return "";
String s = line.replaceAll("\\u001B\\[[;\\d]*m", "").trim();
int bracket = s.indexOf("] ");
if (s.startsWith("[") && bracket > 0) {
s = s.substring(bracket + 2).trim();
}
if (s.length() > 60) {
s = s.substring(0, 57) + "...";
}
return s;
}
public static void setStage(String stageName, int stageIndex, int stageCount) {
JFXBridge.setInstallStage(stageName, stageIndex, stageCount);
} }
public static void clearLine() { public static void clearLine() {
// No-op: console progress now goes through LauncherLogger. System.out.print("\r" + " ".repeat(110) + "\r");
System.out.flush();
} }
public static String formatBytes(long bytes) { public static String formatBytes(long bytes) {
@@ -11,110 +11,37 @@ import java.net.URLEncoder;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public class ZHttpClient { public class ZHttpClient {
/**
* Dedicated daemon executor for the shared HttpClient. sendAsync() work runs
* here (never the common ForkJoinPool, so a stalled download cannot starve
* JFX's pools), and daemon threads never keep the launcher JVM alive.
*/
private static final ExecutorService CLIENT_EXECUTOR = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "zern-http-client");
t.setDaemon(true);
return t;
});
private static final HttpClient client = HttpClient.newBuilder() private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15)) .connectTimeout(Duration.ofSeconds(15))
.version(HttpClient.Version.HTTP_1_1) .version(HttpClient.Version.HTTP_1_1)
.executor(CLIENT_EXECUTOR)
.build(); .build();
private static String BASE_URL = "https://api.zern.cc"; private static String BASE_URL = "http://87.120.187.36:1582";
/**
* Alternate proxy endpoints (geo mirrors) used when BASE_URL's proxy fails with
* a connection error (e.g. CDN "Connection reset" from the current region).
* Stay in sync with LAUNCHER_MIRRORS in server/main.py.
*/
private static final List<String> GEO_PROXY_BASES = List.of(
"https://api.zernmc.ru",
"https://api.zernmc.online",
"https://api.pl.zern.cc",
"https://api.swe.zern.cc"
);
/**
* Extra proxy bases discovered at runtime (e.g. mirrors fetched from
* /launcher/mirrors by DomainSelector). Kept in insertion order, deduplicated.
*/
private static final List<String> extraProxyBases = new ArrayList<>();
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
private static final AtomicBoolean healthThreadStarted = new AtomicBoolean(false);
public static void setBaseUrl(String url) { public static void setBaseUrl(String url) {
BASE_URL = url; BASE_URL = url;
} }
/**
* Registers an additional proxy base URL (mirror/geo domain) that the smart
* proxy fallback may rotate through. No-op if already present.
*/
public static void registerProxyBase(String url) {
if (url == null || url.isBlank()) return;
String normalized = url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
synchronized (extraProxyBases) {
if (!extraProxyBases.contains(normalized)) {
extraProxyBases.add(normalized);
}
}
}
/**
* Ordered, deduplicated list of proxy endpoints to attempt: the primary
* BASE_URL first, then geo mirrors and any runtime-discovered mirrors.
*/
private static List<String> getProxyBases() {
List<String> bases = new ArrayList<>();
bases.add(BASE_URL);
for (String geo : GEO_PROXY_BASES) {
if (!bases.contains(geo)) bases.add(geo);
}
synchronized (extraProxyBases) {
for (String m : extraProxyBases) {
if (!bases.contains(m)) bases.add(m);
}
}
return bases;
}
public static String getBaseUrl() { public static String getBaseUrl() {
return BASE_URL; return BASE_URL;
} }
public enum ServiceType { public enum ServiceType {
ZERN_SERVER("https://api.zern.cc", 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),
@@ -141,7 +68,7 @@ public class ZHttpClient {
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 = 1; private static final int MAX_FAILS_BEFORE_PROXY = 2;
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; private static final long HEALTH_CHECK_INTERVAL_MS = 60000;
private static final long CHECK_TIMEOUT_MS = 7000; private static final long CHECK_TIMEOUT_MS = 7000;
@@ -198,51 +125,21 @@ public class ZHttpClient {
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
} }
} }
LauncherLogger.info("[NET] " + service.name() + ": "
+ (isHealthy ? "OK" : "unavailable") +
(serviceProxyMode.get(service) ? " (proxy mode)" : " (direct)"));
} }
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
if (verbose) { if (verbose) {
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!")); System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
} }
LauncherLogger.warn("[NET] Critical: Zern server unreachable");
} else {
String ip = getClientPublicIp();
LauncherLogger.info("[NET] Client public IP: " + (ip != null ? ip : "unknown"));
} }
proxyTested.set(true); proxyTested.set(true);
startHealthCheckThread();
if (verbose) { if (verbose) {
startHealthCheckThread();
printStats(); printStats();
} }
} }
/**
* Asks the Zern server what public IP it sees for this client (used to
* attribute user logs when login/registration fails). Returns null on failure.
*/
public static String getClientPublicIp() {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/launcher/ip"))
.timeout(Duration.ofMillis(15000))
.GET()
.header("User-Agent", "ZernMC-Launcher/IPCheck")
.build();
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 15);
if (response.statusCode() == 200) {
JSONObject json = new JSONObject(response.body());
return json.optString("ip", null);
}
} catch (Exception e) {
return null;
}
return null;
}
public static void forceCheckMojangServices() { public static void forceCheckMojangServices() {
System.out.println(ZAnsi.cyan("Forcing Mojang services check...")); System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
@@ -263,9 +160,6 @@ public class ZHttpClient {
} }
private static boolean checkServiceHealth(ServiceType service) { private static boolean checkServiceHealth(ServiceType service) {
if (service == ServiceType.ZERN_SERVER) {
return checkDirectConnection(BASE_URL);
}
return checkDirectConnection(service.getBaseUrl()); return checkDirectConnection(service.getBaseUrl());
} }
@@ -286,7 +180,7 @@ public class ZHttpClient {
.header("User-Agent", "ZernMC-Launcher/HealthCheck") .header("User-Agent", "ZernMC-Launcher/HealthCheck")
.build(); .build();
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 10); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
int code = response.statusCode(); int code = response.statusCode();
return code == 200 || code == 404; return code == 200 || code == 404;
} catch (Exception e) { } catch (Exception e) {
@@ -295,8 +189,6 @@ public class ZHttpClient {
} }
private static void startHealthCheckThread() { private static void startHealthCheckThread() {
if (!healthThreadStarted.compareAndSet(false, true)) return;
Thread healthThread = new Thread(() -> { Thread healthThread = new Thread(() -> {
while (true) { while (true) {
try { try {
@@ -321,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);
LauncherLogger.info("[NET] " + service.name() + " restored, switched to direct connection"); 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);
@@ -329,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);
LauncherLogger.warn("[NET] " + service.name() + " unavailable, proxy mode enabled"); System.out.println(ZAnsi.yellow("[NET] " + service.name() + " unavailable, proxy mode enabled"));
} }
} }
} }
@@ -341,13 +233,10 @@ public class ZHttpClient {
if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com")) if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com"))
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") || url.contains("files.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("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;
if (url.contains("libraries.minecraft.net")) return ServiceType.MOJANG_RESOURCES;
if (url.contains("piston-data.mojang.com")) return ServiceType.MOJANG_META;
if (url.contains("repo1.maven.org") || url.contains("repo.maven.apache.org")) return ServiceType.MOJANG_RESOURCES;
return null; return null;
} }
@@ -357,9 +246,7 @@ public class ZHttpClient {
ServiceType service = detectService(url); ServiceType service = detectService(url);
if (service == null || service.isAlwaysDirect()) return false; if (service == null || service.isAlwaysDirect()) return false;
if (serviceProxyMode.getOrDefault(service, false)) return true; return serviceProxyMode.getOrDefault(service, false);
return serviceFailCount.getOrDefault(service, 0) >= MAX_FAILS_BEFORE_PROXY;
} }
private static boolean isConnectionError(Throwable e) { private static boolean isConnectionError(Throwable e) {
@@ -369,24 +256,10 @@ public class ZHttpClient {
return cause instanceof java.net.ConnectException || return cause instanceof java.net.ConnectException ||
cause instanceof java.net.UnknownHostException || cause instanceof java.net.UnknownHostException ||
cause instanceof java.nio.channels.ClosedChannelException || cause instanceof java.nio.channels.ClosedChannelException ||
cause instanceof java.net.SocketException ||
msg.contains("connection") || msg.contains("connection") ||
msg.contains("timeout") || msg.contains("timeout") ||
msg.contains("refused") || msg.contains("refused") ||
msg.contains("received within") || msg.contains("closed");
msg.contains("closed") ||
msg.contains("reset") ||
msg.contains("abort");
}
/**
* Whether an error should be retried. Includes connection errors and transient
* server-side failures (5xx / 429) from the Zern proxy or upstream services.
*/
private static boolean isRetryableError(Throwable e) {
if (isConnectionError(e)) return true;
String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : "";
return msg.contains("http 5") || msg.contains("http 429");
} }
private static void markServiceAsBlocked(String url) { private static void markServiceAsBlocked(String url) {
@@ -399,45 +272,7 @@ 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);
LauncherLogger.warn("[NET] " + service.name() + " blocked, switching to proxy"); System.out.println(ZAnsi.yellow("[NET] " + service.name() + " blocked, switching to proxy"));
}
}
/**
* HTTP send with a hard, actually-enforced timeout.
*
* NB: HttpRequest.timeout() alone is NOT reliable for streaming body handlers
* (BodyHandlers.ofFile/ofString): the JDK cancels the request timer as soon as
* response headers arrive, so a download that stalls mid-body blocks
* client.send() forever (no exception, no retry, no proxy fallback the
* exact "install hangs at 0%" bug). Therefore we drive the exchange through
* sendAsync() + future.get(timeout) and abort it with cancel() on timeout.
*
* The client runs on a dedicated daemon executor (see CLIENT_EXECUTOR), so
* this does not reintroduce the v1.0.14.2 JFX hang (ForkJoinPool starvation /
* non-daemon executor keeping the JVM alive) that forced the old sync-send
* implementation.
*/
private static <T> HttpResponse<T> sendBounded(HttpRequest request, HttpResponse.BodyHandler<T> handler, long timeoutSeconds) throws IOException, InterruptedException {
HttpRequest timed = HttpRequest.newBuilder(request, (name, value) -> true)
.timeout(Duration.ofSeconds(timeoutSeconds))
.build();
CompletableFuture<HttpResponse<T>> future = client.sendAsync(timed, handler);
try {
return future.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new HttpTimeoutException("Response not received within " + timeoutSeconds + "s: " + timed.uri());
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof IOException) throw (IOException) cause;
if (cause instanceof InterruptedException) throw (InterruptedException) cause;
if (cause instanceof RuntimeException) throw (RuntimeException) cause;
throw new IOException("HTTP request failed: " + timed.uri(), cause);
} catch (InterruptedException e) {
future.cancel(true);
Thread.currentThread().interrupt();
throw e;
} }
} }
@@ -451,190 +286,99 @@ public class ZHttpClient {
.GET() .GET()
.build(); .build();
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 30); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
directSuccessCount++; directSuccessCount++;
return response.body(); return response.body();
} }
} catch (Exception e) {
if (e instanceof IOException && isConnectionError(e)) { if (response.statusCode() >= 400) {
directFailCount++; throw new IOException("HTTP " + response.statusCode());
markServiceAsBlocked(url);
} else if (!(e instanceof IOException)) {
throw e;
} }
// any direct failure: the same resource is retried via the proxy below
}
}
List<String> proxyBases = getProxyBases();
int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
// Rotate through every proxy base so one region's Connection Reset does not
// burn all retries on the same endpoint.
String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size());
try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl))
.timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 45);
if (response.statusCode() != 200) {
throw new IOException("Proxy HTTP " + response.statusCode());
}
proxySuccessCount++;
return response.body();
} catch (Exception e) {
if (attempt == maxRetries || !isRetryableError(e)) {
throw new IOException("Failed to fetch data directly or via proxy (" + proxyBase + "): " + e.getMessage(), e);
}
LauncherLogger.warn("[NET] Proxy fetch attempt " + attempt + "/" + maxRetries
+ " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base");
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
}
}
throw new IOException("Failed to fetch data: " + url);
}
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
Path tmp = target.resolveSibling(target.getFileName() + ".part");
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {}
if (!shouldUseProxyForUrl(url)) {
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET();
if (url.startsWith(BASE_URL)) {
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
}
}
HttpRequest request = requestBuilder.build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 45);
if (response.statusCode() == 200) {
moveIntoPlace(tmp, target);
directSuccessCount++;
return;
}
// non-200 response: fall through to proxy below
} catch (Exception e) { } catch (Exception e) {
if (isConnectionError(e)) { if (isConnectionError(e)) {
directFailCount++; directFailCount++;
markServiceAsBlocked(url); markServiceAsBlocked(url);
} else {
throw e;
} }
// any direct failure: the same resource is retried via the proxy below
} }
} }
List<String> proxyBases = getProxyBases(); try {
int maxRetries = 3; String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
for (int attempt = 1; attempt <= maxRetries; attempt++) { String proxyUrl = BASE_URL + "/download?url=" + encodedUrl;
// Rotate through every proxy base so one region's Connection Reset does not
// burn all retries on the same endpoint. HttpRequest request = HttpRequest.newBuilder()
String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size()); .uri(URI.create(proxyUrl))
.timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Proxy HTTP " + response.statusCode());
}
proxySuccessCount++;
return response.body();
} catch (Exception e) {
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
}
}
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
if (!shouldUseProxyForUrl(url)) {
try { try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl;
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {}
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl)) .uri(URI.create(url))
.timeout(Duration.ofMinutes(5)) .timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET() .GET()
.build(); .build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 300); HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) { if (response.statusCode() == 200) {
throw new IOException("Proxy download failed: HTTP " + response.statusCode()); directSuccessCount++;
return;
} }
moveIntoPlace(tmp, target);
proxySuccessCount++;
return;
} catch (Exception e) { } catch (Exception e) {
try { if (isConnectionError(e)) {
Files.deleteIfExists(tmp); directFailCount++;
} catch (IOException ignored) {} markServiceAsBlocked(url);
if (attempt == maxRetries || !isRetryableError(e)) {
throw new IOException("Proxy download failed (" + proxyBase + "): " + e.getMessage(), e);
}
LauncherLogger.warn("[NET] Proxy download attempt " + attempt + "/" + maxRetries
+ " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base");
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
}
}
}
/**
* Atomically replaces the destination with the fully-downloaded temp file.
* Prevents a Connection Reset mid-transfer from leaving a corrupt partial
* file at the final path that later "file exists" checks would treat as installed.
*/
private static void moveIntoPlace(Path tmp, Path target) throws IOException {
Files.createDirectories(target.getParent());
try {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING,
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
} catch (java.nio.file.AtomicMoveNotSupportedException e) {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
/**
* Retries a request against the Zern server. The server proxies are slow on cold cache
* and may return transient 502/5xx when upstreams are flaky, so a single failure should
* not immediately force a direct fallback.
*/
public static String getRetry(String endpoint, int maxAttempts) throws IOException, InterruptedException {
IOException last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return get(endpoint);
} catch (IOException e) {
last = e;
if (!isRetryableError(e)) throw e;
if (attempt < maxAttempts) {
LauncherLogger.warn("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint + " (" + e.getMessage() + ")");
try { Thread.sleep(500L * attempt); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw ie;
}
} }
} }
} }
throw last;
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl))
.timeout(Duration.ofMinutes(5))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) {
throw new IOException("Proxy download failed: HTTP " + response.statusCode());
}
proxySuccessCount++;
} }
public static String get(String endpoint) throws IOException, InterruptedException { public static String get(String endpoint) throws IOException, InterruptedException {
checkAllServicesOnStartup();
if (useProxyMode.get()) {
return proxyGet(endpoint);
}
try { try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + endpoint)) .uri(URI.create(BASE_URL + endpoint))
@@ -648,7 +392,7 @@ public class ZHttpClient {
} }
HttpRequest request = requestBuilder.build(); HttpRequest request = requestBuilder.build();
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 20); 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());
@@ -660,65 +404,55 @@ public class ZHttpClient {
} }
} }
private static String proxyGet(String endpoint) throws IOException {
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/proxy" + endpoint))
.timeout(Duration.ofSeconds(30))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET();
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
}
HttpRequest request = requestBuilder.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode());
}
proxySuccessCount++;
return response.body();
} catch (Exception e) {
throw new IOException("Proxy error: " + e.getMessage(), e);
}
}
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));
} }
public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException { public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException {
try { String url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
return getMojangVersionManifestViaServer(); String response = getWithSmartProxy(url);
} catch (Exception e) { return new JSONObject(response);
LauncherLogger.warn("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta...");
return new JSONObject(getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"));
}
}
/**
* Fetch the Mojang version manifest through the Zern server proxy.
* The launcher's own API is reliably reachable, and the manifest is cached server-side.
*/
public static JSONObject getMojangVersionManifestViaServer() throws IOException, InterruptedException {
return new JSONObject(getRetry("/proxy/mojang/version_manifest", 3));
}
/**
* Fetch a single Mojang version JSON through the Zern server proxy.
* Avoids direct connections to piston-meta.mojang.com entirely.
*/
public static JSONObject getMojangVersionViaServer(String versionId) throws IOException, InterruptedException {
return new JSONObject(getRetry("/proxy/mojang/version/" + URLEncoder.encode(versionId, StandardCharsets.UTF_8), 3));
} }
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException { public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
long deadline = System.currentTimeMillis() + 90_000; JSONObject manifest = getMojangVersionManifest();
LauncherLogger.info("[NET] Fetching version info for " + versionId + " via server proxy..."); JSONArray versions = manifest.getJSONArray("versions");
try {
JSONObject result = getMojangVersionViaServer(versionId);
LauncherLogger.info("[NET] Version info for " + versionId + " fetched via proxy (" + (System.currentTimeMillis() - (deadline - 90_000)) + "ms)");
return result;
} catch (Exception e) {
if (System.currentTimeMillis() >= deadline) {
throw new IOException("Version " + versionId + " fetch timed out after 90s: " + e.getMessage(), e);
}
LauncherLogger.warn("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta...");
JSONObject manifest = getMojangVersionManifest();
JSONArray versions = manifest.getJSONArray("versions");
for (int i = 0; i < versions.length(); i++) { for (int i = 0; i < versions.length(); i++) {
JSONObject v = versions.getJSONObject(i); JSONObject v = versions.getJSONObject(i);
if (v.getString("id").equals(versionId)) { if (v.getString("id").equals(versionId)) {
if (System.currentTimeMillis() >= deadline) { return new JSONObject(getWithSmartProxy(v.getString("url")));
throw new IOException("Version " + versionId + " fetch timed out after 90s");
}
LauncherLogger.info("[NET] Fetching version info for " + versionId + " directly...");
JSONObject result = new JSONObject(getWithSmartProxy(v.getString("url")));
LauncherLogger.info("[NET] Version info for " + versionId + " fetched directly");
return result;
}
} }
throw new IOException("Version " + versionId + " not found");
} }
throw new IOException("Version " + versionId + " not found");
} }
public static String getForgeVersionsXml() throws IOException, InterruptedException { public static String getForgeVersionsXml() throws IOException, InterruptedException {
@@ -739,209 +473,6 @@ public class ZHttpClient {
return getWithSmartProxy(url); return getWithSmartProxy(url);
} }
/**
* Scans every installed version JSON in the instance and downloads any library file
* that is missing. Used after loader installers run (their subprocess downloads can
* time out on restricted networks), so remaining libraries are pulled through the smart proxy.
*/
public static void repairMissingLibraries(Path minecraftDir) {
Path versionsDir = minecraftDir.resolve("versions");
if (!Files.isDirectory(versionsDir)) return;
try (Stream<Path> stream = Files.list(versionsDir)) {
stream.filter(Files::isDirectory).forEach(versionDir -> {
Path versionJson = versionDir.resolve(versionDir.getFileName() + ".json");
if (Files.exists(versionJson)) {
try {
repairLibrariesFromJson(versionJson, minecraftDir);
} catch (Exception e) {
LauncherLogger.warn("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage());
}
}
});
} catch (IOException e) {
LauncherLogger.warn("[LIB] Repair scan failed: " + e.getMessage());
}
}
/**
* Downloads every missing library listed in a version/loadermod JSON into minecraftDir/libraries.
* Supports both the Minecraft format (downloads.artifact.path/url) and the maven-coordinates
* format (name + base url) used by Fabric loadermod JSONs.
*/
public static void repairLibrariesFromJson(Path jsonPath, Path minecraftDir) throws Exception {
JSONObject root = new JSONObject(Files.readString(jsonPath));
if (!root.has("libraries")) return;
JSONArray libraries = root.getJSONArray("libraries");
int repaired = 0;
for (int i = 0; i < libraries.length(); i++) {
JSONObject lib = libraries.getJSONObject(i);
try {
if (lib.has("downloads") && lib.getJSONObject("downloads").has("artifact")) {
JSONObject art = lib.getJSONObject("downloads").getJSONObject("artifact");
String url = art.getString("url");
String path = art.getString("path");
Path target = minecraftDir.resolve("libraries").resolve(path);
if (!Files.exists(target)) {
Files.createDirectories(target.getParent());
downloadFileWithSmartProxy(url, target);
repaired++;
}
} else if (lib.has("name") && lib.has("url")) {
Path target = resolveCoordinatesTarget(lib);
if (target != null && !Files.exists(target)) {
Files.createDirectories(target.getParent());
downloadCoordinatesArtifact(lib, target);
repaired++;
}
}
} catch (Exception e) {
String libName = lib.optString("name", lib.optString("path", "?"));
LauncherLogger.warn("[LIB] Repair failed for " + libName + ": " + e.getMessage());
}
}
if (repaired > 0) {
LauncherLogger.info("[LIB] Repaired " + repaired + " missing libraries");
}
}
/**
* Pre-downloads every library referenced by a loader installer jar (Forge/NeoForge).
* The installer later runs as a separate JVM subprocess that downloads libraries
* directly from the loader's maven repository, bypassing this launcher's smart proxy.
* On blocked networks (e.g. maven.minecraftforge.net unreachable) that subprocess
* fails with "Connection reset". Because the installer reuses any library already
* present with a matching checksum ("File exists: Checksum validated"), pre-seeding
* those files through the proxy lets the install complete without direct downloads.
*
* @return number of new library files downloaded
*/
public static int prefetchInstallerLibraries(Path installerJar, Path minecraftDir) {
int prefetched = 0;
try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(installerJar.toFile())) {
for (String jsonName : List.of("version.json", "install_profile.json")) {
java.util.zip.ZipEntry entry = zip.getEntry(jsonName);
if (entry == null) continue;
String content = new String(zip.getInputStream(entry).readAllBytes(), StandardCharsets.UTF_8);
JSONObject root = new JSONObject(content);
JSONArray libs = root.optJSONArray("libraries");
if (libs == null) continue;
for (int i = 0; i < libs.length(); i++) {
JSONObject lib = libs.optJSONObject(i);
if (lib == null) continue;
JSONObject downloads = lib.optJSONObject("downloads");
if (downloads == null) continue;
JSONObject artifact = downloads.optJSONObject("artifact");
if (artifact == null) continue;
String url = artifact.optString("url", null);
String path = artifact.optString("path", null);
String sha1 = artifact.optString("sha1", null);
if (url == null || path == null) continue;
Path target = minecraftDir.resolve("libraries").resolve(path);
try {
if (Files.exists(target)) {
if (sha1 == null || sha1.isEmpty() || sha1Matches(target, sha1)) {
continue;
}
Files.delete(target);
}
Files.createDirectories(target.getParent());
downloadFileWithSmartProxy(url, target);
if (sha1 != null && !sha1.isEmpty() && !sha1Matches(target, sha1)) {
Files.delete(target);
LauncherLogger.warn("[LIB] Checksum mismatch after prefetch: " + path);
continue;
}
prefetched++;
LauncherLogger.info("[LIB] Prefetched " + path);
} catch (Exception e) {
LauncherLogger.warn("[LIB] Prefetch failed for " + path + ": " + e.getMessage());
}
}
}
} catch (Exception e) {
LauncherLogger.warn("[LIB] Installer prefetch scan failed: " + e.getMessage());
}
return prefetched;
}
private static boolean sha1Matches(Path file, String expectedSha1) throws Exception {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-1");
try (java.io.InputStream in = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
StringBuilder sb = new StringBuilder();
for (byte b : digest.digest()) {
sb.append(String.format("%02x", b));
}
return sb.toString().equalsIgnoreCase(expectedSha1);
}
private static String resolveCoordinatesUrl(JSONObject lib) {
String name = lib.getString("name");
String base = lib.getString("url");
String[] parts = name.split(":");
String group = parts[0];
String artifact = parts[1];
String version = parts[2];
String classifier = parts.length > 3 ? parts[3] : null;
String basePath = group.replace('.', '/') + "/" + artifact + "/" + version + "/";
String fileName = artifact + "-" + version + (classifier != null ? "-" + classifier : "") + ".jar";
return base.endsWith("/") ? base + basePath + fileName : base + "/" + basePath + fileName;
}
private static String resolveCoordinatesUrlV2(JSONObject lib) {
String name = lib.getString("name");
String base = lib.getString("url");
String[] parts = name.split(":");
String group = parts[0];
String artifact = parts[1];
String version = parts[2];
String classifier = parts.length > 3 ? parts[3] : null;
String basePath = group.replace('.', '/') + "/" + artifact + "/" + version + "/";
String fileName = artifact + "-" + version + "-v2" + (classifier != null ? "-" + classifier : "") + ".jar";
return base.endsWith("/") ? base + basePath + fileName : base + "/" + basePath + fileName;
}
private static void downloadCoordinatesArtifact(JSONObject lib, Path target) throws Exception {
IOException last = null;
for (String candidateUrl : List.of(resolveCoordinatesUrl(lib), resolveCoordinatesUrlV2(lib))) {
try {
downloadFileWithSmartProxy(candidateUrl, target);
return;
} catch (IOException e) {
last = e;
Files.deleteIfExists(target);
}
}
throw last != null ? last : new IOException("Could not resolve coordinates artifact: " + lib.optString("name", "?"));
}
private static Path resolveCoordinatesTarget(JSONObject lib) {
String name = lib.getString("name");
String[] parts = name.split(":");
if (parts.length < 3) return null;
String group = parts[0];
String artifact = parts[1];
String version = parts[2];
String classifier = parts.length > 3 ? parts[3] : null;
String basePath = group.replace('.', '/') + "/" + artifact + "/" + version + "/";
String fileName = artifact + "-" + version + (classifier != null ? "-" + classifier : "") + ".jar";
return Path.of(basePath, fileName);
}
private static List<String> parseFabricVersionsFromJson(String json) { private static List<String> parseFabricVersionsFromJson(String json) {
JSONArray array = new JSONArray(json); JSONArray array = new JSONArray(json);
List<String> versions = new ArrayList<>(); List<String> versions = new ArrayList<>();
@@ -978,37 +509,6 @@ public class ZHttpClient {
return useProxyMode.get(); return useProxyMode.get();
} }
public static boolean isNetworkInitialized() {
return proxyTested.get();
}
public static boolean isZernServerHealthy() {
return serviceHealthy.getOrDefault(ServiceType.ZERN_SERVER, false);
}
public static Map<String, Object> getNetworkStatus() {
Map<String, Object> status = new HashMap<>();
status.put("initialized", proxyTested.get());
status.put("globalProxy", useProxyMode.get());
status.put("directSuccess", directSuccessCount);
status.put("directFail", directFailCount);
status.put("proxySuccess", proxySuccessCount);
List<Map<String, Object>> services = new ArrayList<>();
for (ServiceType type : ServiceType.values()) {
if (type.isAlwaysDirect()) continue;
Map<String, Object> s = new HashMap<>();
s.put("name", type.name());
s.put("baseUrl", type.getBaseUrl());
s.put("healthy", serviceHealthy.getOrDefault(type, false));
s.put("proxy", serviceProxyMode.getOrDefault(type, false));
s.put("fails", serviceFailCount.getOrDefault(type, 0));
services.add(s);
}
status.put("services", services);
return status;
}
public static void printStats() { public static void printStats() {
System.out.println(ZAnsi.cyan("\n=== Network Stats ===")); System.out.println(ZAnsi.cyan("\n=== Network Stats ==="));
System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF")); System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF"));
Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

+113 -399
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title> <title>ZernMC Launcher</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<canvas id="bg-canvas"></canvas> <canvas id="bg-canvas"></canvas>
@@ -12,10 +15,10 @@
<div id="app"> <div id="app">
<!-- Login Screen --> <!-- Login Screen -->
<div id="login-screen" class="screen"> <div id="login-screen" class="screen">
<div class="login-container" id="login-container"> <div class="login-container">
<div class="login-header"> <div class="login-brand">
<div class="brand-icon"> <div class="brand-icon">
<svg width="40" height="40" viewBox="0 0 56 56" fill="none"> <svg width="56" height="56" viewBox="0 0 56 56" fill="none">
<rect width="56" height="56" rx="14" fill="url(#brandGrad)"/> <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"/> <path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
<defs> <defs>
@@ -26,74 +29,33 @@
</defs> </defs>
</svg> </svg>
</div> </div>
<h1 class="login-title" id="login-title" data-i18n="login.title">Sign In</h1> <h1 class="brand-title">ZernMC</h1>
<p class="login-subtitle" id="login-subtitle" data-i18n="login.subtitle">Welcome back to ZernMC</p> <p class="brand-sub">Launcher <span id="version">1.0.9</span></p>
<span id="version" class="hidden">__LAUNCHER_VERSION__</span>
</div> </div>
<form id="login-form" class="login-form"> <form id="login-form" class="login-form">
<div class="field"> <div class="field">
<label for="username" data-i18n="login.username">Username</label> <input type="text" id="username" placeholder="Username" autocomplete="username" required>
<input type="text" id="username" autocomplete="username" required> <label for="username">Username</label>
</div> </div>
<div class="field"> <div class="field">
<label for="password" data-i18n="login.password">Password</label> <input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
<input type="password" id="password" autocomplete="current-password" required> <label for="password">Password</label>
<span class="field-hint hidden" id="password-hint" data-i18n="login.passTooShort"></span>
</div>
<div class="field hidden" id="confirm-field">
<label for="confirm-password" data-i18n="login.confirm">Confirm Password</label>
<input type="password" id="confirm-password" autocomplete="new-password">
</div> </div>
<p id="login-error" class="error-msg hidden"></p> <p id="login-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="login-btn"> <button type="submit" class="btn-primary" id="login-btn">
<span class="btn-label" id="login-btn-label" data-i18n="login.title">Sign In</span> <span class="btn-label">Sign In</span>
<div class="spinner hidden"></div> <div class="spinner hidden"></div>
</button> </button>
<div class="login-footer" id="login-footer"> <p class="login-hint">New account will be created automatically on first login</p>
<span id="footer-hint" data-i18n="login.hint">Need an account?</span>
<span class="login-footer-action" id="footer-action" data-i18n="login.register">Register</span>
</div>
</form> </form>
</div> </div>
</div> </div>
<!-- Offline Screen -->
<div id="offline-screen" class="screen hidden">
<div class="offline-container">
<div class="offline-badge">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
</div>
<h2 class="offline-title" data-i18n="offline.title">Offline Mode</h2>
<p class="offline-subtitle" data-i18n="offline.subtitle">The Zern server is unreachable. You can still launch locally installed packs without an account.</p>
<form id="offline-form" class="login-form">
<div class="field">
<label for="offline-nickname" data-i18n="offline.nickname">Nickname</label>
<input type="text" id="offline-nickname" maxlength="16" autocomplete="off">
</div>
<div class="field">
<label for="offline-instance" data-i18n="offline.instance">Pack</label>
<select id="offline-instance"></select>
</div>
<p id="offline-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="offline-launch-btn">
<span class="btn-label" data-i18n="offline.launch">Launch Offline</span>
<div class="spinner hidden"></div>
</button>
</form>
<div class="offline-footer">
<button type="button" class="btn-ghost" id="offline-retry-btn" data-i18n="offline.retry">Try again online</button>
<button type="button" class="btn-ghost" id="offline-exit-btn" data-i18n="offline.exit">Exit</button>
</div>
</div>
</div>
<!-- Loading Overlay --> <!-- Loading Overlay -->
<div id="loading-overlay" class="overlay hidden"> <div id="loading-overlay" class="overlay hidden">
<div class="loader-ring"></div> <div class="loader-ring"></div>
<p class="loader-text" data-i18n="loading.text">Loading...</p> <p class="loader-text">Loading...</p>
</div> </div>
<!-- Main Screen --> <!-- Main Screen -->
@@ -114,57 +76,40 @@
</svg> </svg>
<div class="sidebar-brand-text"> <div class="sidebar-brand-text">
<span class="sidebar-brand-name">ZernMC</span> <span class="sidebar-brand-name">ZernMC</span>
<span class="sidebar-brand-ver">v<span id="header-version">__LAUNCHER_VERSION__</span></span> <span class="sidebar-brand-ver">v<span id="header-version">1.0.9</span></span>
</div> </div>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<div class="sidebar-nav-primary"> <button class="nav-btn active" data-view="packs" title="Packs">
<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>
<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> Packs
<span data-i18n="nav.packs">Packs</span> </button>
</button> <button class="nav-btn" data-view="news" title="News">
<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>
<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> News
<span data-i18n="nav.news">News</span> </button>
</button> <button class="nav-btn" data-view="settings" title="Settings">
</div> <svg width="20" height="20" 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>
<div class="sidebar-nav-secondary" id="nav-secondary"> Settings
<button class="nav-btn" data-view="friends" title="Friends"> </button>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="nav.friends">Friends</span>
</button>
<button class="nav-btn hidden" data-view="admin" title="Admin" id="admin-nav-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
<span data-i18n="nav.admin">Admin</span>
</button>
</div>
</nav> </nav>
<div class="sidebar-section section-collapsible" id="server-packs-section"> <div class="sidebar-section">
<div class="section-header" id="server-packs-header" title="Toggle"> <div class="section-header">
<span class="section-title-wrap"> <span class="section-title">Server Packs</span>
<svg class="section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
</span>
<button class="btn-icon" id="add-server-pack-btn" title="Install server pack">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div> </div>
<div class="pack-list-collapse"><div id="server-packs-list" class="pack-list"></div></div> <div id="server-packs-list" class="pack-list"></div>
</div> </div>
<div class="sidebar-section section-collapsible" id="local-packs-section"> <div class="sidebar-section" id="local-packs-section">
<div class="section-header" id="local-packs-header" title="Toggle"> <div class="section-header">
<span class="section-title-wrap"> <span class="section-title">Local Packs</span>
<svg class="section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
</span>
<button class="btn-icon" id="add-pack-btn" title="Add pack"> <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> <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> </button>
</div> </div>
<div class="pack-list-collapse"><div id="local-packs-list" class="pack-list"></div></div> <div id="local-packs-list" class="pack-list"></div>
</div> </div>
</div> </div>
@@ -178,9 +123,6 @@
<span id="account-role" class="badge badge-role hidden"></span> <span id="account-role" class="badge badge-role hidden"></span>
</span> </span>
</div> </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"> <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> <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> </button>
@@ -199,11 +141,11 @@
<div class="view-actions"> <div class="view-actions">
<button id="update-btn" class="btn-secondary hidden"> <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> <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> Update
</button> </button>
<button id="delete-pack-btn" class="btn-secondary btn-danger hidden"> <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> <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> Delete
</button> </button>
</div> </div>
</div> </div>
@@ -211,8 +153,8 @@
<div class="pack-detail" id="pack-detail"> <div class="pack-detail" id="pack-detail">
<div class="pack-empty" id="pack-empty-state"> <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> <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> <h3>No pack selected</h3>
<p data-i18n="pack.emptyState.desc">Select a pack from the sidebar or add a new one</p> <p>Select a pack from the sidebar or add a new one</p>
</div> </div>
<div id="pack-detail-content" class="pack-detail-content hidden"> <div id="pack-detail-content" class="pack-detail-content hidden">
<div class="pack-hero"> <div class="pack-hero">
@@ -224,54 +166,30 @@
<div class="detail-tags"> <div class="detail-tags">
<span class="tag tag-mc" id="detail-mc">1.21</span> <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-loader" id="detail-loader">fabric</span>
<span class="tag tag-server hidden" id="detail-server">v1</span> <span class="tag tag-server hidden" id="detail-server">v1</span>
</div>
</div>
</div> </div>
</div> <div class="pack-stats">
</div> <div class="stat"><span class="stat-value" id="detail-loader-ver">-</span><span class="stat-label">Loader Ver</span></div>
<div class="pack-stats"> <div class="stat"><span class="stat-value" id="detail-files">0</span><span class="stat-label">Files</span></div>
<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-size">-</span><span class="stat-label">Size</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>
<div id="pack-description" class="pack-description"> <div id="pack-description" class="pack-description">
<p id="pack-description-text" class="pack-description-text" data-i18n="pack.description.loading">Loading description...</p> <p id="pack-description-text" class="pack-description-text">Loading description...</p>
<div id="pack-gallery" class="pack-gallery"> <div id="pack-gallery" class="pack-gallery">
</div> </div>
</div> </div>
<div id="whitelist-mods-section" class="whitelist-mods-section hidden">
<div class="section-header"><span data-i18n="whitelist.title">Additional Mods</span></div>
<div id="whitelist-mods-list" class="whitelist-mods-list">
<div class="whitelist-loading" data-i18n="whitelist.loading">Loading...</div>
</div>
</div>
<div id="local-mods-section" class="mods-section hidden">
<div class="section-header">
<span data-i18n="mods.title">Mods</span>
<span id="mods-count" class="mods-count"></span>
<input type="text" id="mods-search" class="mods-search" placeholder="Search mods..." data-i18n-placeholder="mods.search" oninput="app.filterLocalMods()">
</div>
<div id="local-mods-list" class="mods-list">
<div class="mods-loading" data-i18n="mods.loading">Loading...</div>
</div>
</div>
</div> </div>
</div> </div>
<div id="inline-install-progress" class="inline-install-progress hidden">
<div class="progress-track">
<div class="progress-fill" id="inline-progress-fill"></div>
</div>
<p class="progress-label" id="inline-progress-label">Installing...</p>
<p class="progress-stage hidden" id="inline-progress-stage"></p>
</div>
<div class="play-bar" id="play-bar"> <div class="play-bar" id="play-bar">
<div class="play-bar-info"> <div class="play-bar-info">
<span id="play-bar-name">Select a pack</span> <span id="play-bar-name">Select a pack</span>
</div> </div>
<button id="play-btn" class="btn-play" disabled> <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> <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> Play
</button> </button>
</div> </div>
</div> </div>
@@ -279,170 +197,50 @@
<!-- News View --> <!-- News View -->
<div id="view-news" class="view"> <div id="view-news" class="view">
<div class="view-header"> <div class="view-header">
<h2 class="view-title" data-i18n="news.title">News</h2> <h2 class="view-title">News</h2>
</div> </div>
<div id="news-grid" class="news-grid"> <div class="news-grid">
<div class="news-loading" data-i18n="news.loading">Loading news...</div> <article class="news-card news-placeholder">
</div> <div class="news-card-badge">Coming Soon</div>
</div> <h3>ZernMC Server Updates</h3>
<p>News and announcements will appear here. Stay tuned for the latest updates about the server and launcher.</p>
<!-- Friends View --> <time>Soon</time>
<div id="view-friends" class="view"> </article>
<div class="view-header"> <article class="news-card news-placeholder">
<h2 class="view-title" data-i18n="friends.title">Friends</h2> <div class="news-card-badge">Info</div>
<div class="view-actions"> <h3>Launcher v1.0.9</h3>
<button id="friends-add-btn" class="btn-primary btn-sm" onclick="app.showAddFriend()"> <p>English UI, JavaFX redesign, improved pack management, and more. Check the GitHub for the full changelog.</p>
<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> <time>v1.0.9</time>
<span data-i18n="friends.add">Add</span> </article>
</button> <article class="news-card news-placeholder">
</div> <div class="news-card-badge">Guide</div>
</div> <h3>Getting Started</h3>
<div class="friends-search"> <p>Install a pack, activate your pass via the website, and start playing. Need help? Contact a moderator.</p>
<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> <time>Guide</time>
<input type="text" id="friends-search-input" placeholder="Search friends..." data-i18n-placeholder="friends.search" oninput="app.filterFriends()"> </article>
</div>
<div id="friends-list" class="friends-list">
<div class="friends-empty" data-i18n="friends.empty">No friends yet</div>
</div>
<div id="friend-requests-section" class="friend-requests-section hidden">
<div class="section-header"><span data-i18n="friends.requests">Friend Requests</span></div>
<div id="friend-requests-list" class="friend-requests-list"></div>
</div>
</div>
<!-- Admin View -->
<div id="view-admin" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="admin.title">Admin Panel</h2>
</div>
<div class="admin-tabs">
<button class="admin-tab active" data-atab="clients"><span data-i18n="admin.clients">Clients</span></button>
<button class="admin-tab" data-atab="mods"><span data-i18n="admin.mods">Mods</span></button>
<button class="admin-tab" data-atab="users"><span data-i18n="admin.users">Users</span></button>
<button class="admin-tab" data-atab="passes"><span data-i18n="admin.passes">Passes</span></button>
<button class="admin-tab" data-atab="packs"><span data-i18n="admin.packs">Packs</span></button>
<button class="admin-tab" data-atab="news"><span data-i18n="admin.news">News</span></button>
</div>
<div class="admin-content">
<!-- Clients -->
<div id="atab-clients" class="admin-tab-content active">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.clientsOnline">Online Clients</span></div>
<div id="admin-clients-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Whitelist Mods -->
<div id="atab-mods" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.modsUpload">Upload Mod</span></div>
<div class="admin-row">
<input type="file" id="admin-mod-file" accept=".jar" class="admin-file-input">
<button class="btn-primary btn-sm" onclick="app.adminUploadMod()"><span data-i18n="admin.modsUploadBtn">Upload</span></button>
</div>
<div id="admin-mod-status" class="admin-status hidden"></div>
</div>
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.modsList">Whitelist Mods</span></div>
<div id="admin-mods-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Users -->
<div id="atab-users" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.usersSearch">User Management</span></div>
<div class="admin-row">
<input type="text" id="admin-user-search" class="admin-input" placeholder="Username..." data-i18n-placeholder="admin.usersSearchPlaceholder">
<button class="btn-primary btn-sm" onclick="app.adminSearchUser()"><span data-i18n="admin.search">Search</span></button>
</div>
<div id="admin-user-results" class="admin-list"></div>
</div>
<div id="admin-user-detail" class="admin-card hidden">
<div class="admin-card-header" id="admin-user-detail-name"></div>
<div class="admin-form">
<div class="admin-row">
<span data-i18n="admin.role">Role:</span>
<select id="admin-user-role-select" class="admin-select">
<option value="0">USER</option>
<option value="1">PASS_HOLDER</option>
<option value="2">MODERATOR</option>
<option value="3">ELDER</option>
<option value="4">CREATOR</option>
</select>
<button class="btn-primary btn-sm" onclick="app.adminSetRole()"><span data-i18n="admin.save">Save</span></button>
</div>
<div class="admin-row">
<button class="btn-secondary btn-sm btn-danger" onclick="app.adminResetPassword()"><span data-i18n="admin.resetPass">Reset Password</span></button>
</div>
</div>
</div>
</div>
<!-- Passes -->
<div id="atab-passes" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.passesList">Pass Management</span></div>
<div id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Packs -->
<div id="atab-packs" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.packsList">Pack Management</span></div>
<div id="admin-packs-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- News -->
<div id="atab-news" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.newsCreate">Create News</span></div>
<div class="admin-form">
<div class="admin-row">
<input type="text" id="admin-news-title" class="admin-input" placeholder="Title" data-i18n-placeholder="admin.newsTitlePlaceholder">
</div>
<div class="admin-row">
<select id="admin-news-type" class="admin-select">
<option value="Update">Update</option>
<option value="Announcement">Announcement</option>
<option value="Event">Event</option>
</select>
<input type="text" id="admin-news-version" class="admin-input" placeholder="Version" style="max-width:120px">
</div>
<div class="admin-row">
<textarea id="admin-news-body" class="admin-textarea" placeholder="Body (Markdown)" data-i18n-placeholder="admin.newsBodyPlaceholder"></textarea>
</div>
<div class="admin-row">
<button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button>
</div>
<div id="admin-news-status" class="admin-status hidden"></div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.newsManage">Manage News</span></div>
<div id="admin-news-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Settings View --> <!-- Settings View -->
<div id="view-settings" class="view"> <div id="view-settings" class="view">
<div class="view-header"> <div class="view-header">
<h2 class="view-title" data-i18n="settings.title">Settings</h2> <h2 class="view-title">Settings</h2>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4 data-i18n="settings.activatePass.title">Activate Pass</h4> <h4>Activate Pass</h4>
<p data-i18n="settings.activatePass.desc">Enter your pass code to access server packs</p> <p>Enter your pass code to access server packs</p>
</div> </div>
<div class="setting-control setting-pass"> <div class="setting-control setting-pass">
<input type="text" id="pass-code" placeholder="Pass code" data-i18n-placeholder="settings.activatePass.placeholder" class="pass-input"> <input type="text" id="pass-code" placeholder="Pass code" 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> <button id="activate-pass-btn" class="btn-primary btn-sm">Activate</button>
</div> </div>
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4 data-i18n="settings.ram.title">Allocated RAM</h4> <h4>Allocated RAM</h4>
<p id="ram-info" data-i18n="settings.ram.info">Loading...</p> <p id="ram-info">Loading...</p>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<input type="range" id="ram-slider" min="1024" max="16384" step="512" value="4096"> <input type="range" id="ram-slider" min="1024" max="16384" step="512" value="4096">
@@ -451,8 +249,8 @@
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4 data-i18n="settings.resolution.title">Game Resolution</h4> <h4>Game Resolution</h4>
<p data-i18n="settings.resolution.desc">Width x Height</p> <p>Width x Height</p>
</div> </div>
<div class="setting-control" style="gap:6px"> <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"> <input type="number" id="win-width" min="640" max="7680" step="1" value="1280" class="setting-input" style="width:80px">
@@ -462,8 +260,8 @@
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4 data-i18n="settings.jvmArgs.title">Extra JVM Arguments</h4> <h4>Extra JVM Arguments</h4>
<p data-i18n="settings.jvmArgs.desc">Additional Java VM options</p> <p>Additional Java VM options</p>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<input type="text" id="jvm-args" placeholder="-XX:+UseZGC" class="setting-input" style="width:280px"> <input type="text" id="jvm-args" placeholder="-XX:+UseZGC" class="setting-input" style="width:280px">
@@ -471,7 +269,7 @@
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4 data-i18n="settings.javaPath.title">Java Path</h4> <h4>Java Path</h4>
<p id="java-path">~/.zernmc/jre/</p> <p id="java-path">~/.zernmc/jre/</p>
</div> </div>
<div class="setting-control"> <div class="setting-control">
@@ -480,165 +278,82 @@
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4 data-i18n="settings.server.title">Server</h4> <h4>Server</h4>
<p id="server-url">http://87.120.187.36:1582</p> <p id="server-url">http://87.120.187.36:1582</p>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<span class="setting-badge" id="server-status">Checking...</span> <span class="setting-badge" id="server-status">Checking...</span>
</div> </div>
</div> </div>
<div class="setting-card" id="network-card">
<div class="setting-info">
<h4 data-i18n="settings.network.title">Network</h4>
<p data-i18n="settings.network.desc">Per-service connection mode (direct / via proxy)</p>
</div>
<div class="setting-network-list" id="network-list">
<span class="setting-badge" data-i18n="settings.network.loading">Checking...</span>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.language.title">Language</h4>
<p data-i18n="settings.language.desc">Interface language</p>
</div>
<div class="setting-control">
<select id="locale-select" class="setting-input" style="width:160px">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.systemJvm.title">System-based JVM Optimization</h4>
<p id="system-jvm-info">-</p>
</div>
<div class="setting-control">
<label class="toggle" id="system-jvm-toggle-wrapper">
<input type="checkbox" id="system-jvm-toggle">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.logViewer.title">Game Log</h4>
<p data-i18n="settings.logViewer.desc">View real-time game logs</p>
</div>
<div class="setting-control">
<button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button>
</div>
</div>
<div class="setting-card" id="onboard-settings-card">
<div class="setting-info">
<h4 data-i18n="onboard.showAgain">Show tutorial again</h4>
<p data-i18n="onboard.infoDesc">You can replay the tutorial in Settings.</p>
</div>
<div class="setting-control">
<button class="btn-primary btn-sm" id="replay-onboard-btn" onclick="app.replayOnboarding()"><span data-i18n="onboard.showAgain">Show tutorial again</span></button>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
</div> </div>
</div> </div>
<!-- Add Friend Modal -->
<div id="add-friend-modal" class="modal-backdrop hidden">
<div class="modal modal-sm">
<div class="modal-head">
<h3 data-i18n="friends.addTitle">Add Friend</h3>
<button class="modal-close" onclick="app.closeAddFriend()">&times;</button>
</div>
<div class="modal-body">
<div class="field">
<input type="text" id="add-friend-input" placeholder=" " data-i18n-placeholder="friends.addPlaceholder">
<label data-i18n="friends.addLabel">Username</label>
</div>
<button id="add-friend-submit" class="btn-primary" onclick="app.submitAddFriend()"><span data-i18n="friends.add">Add Friend</span></button>
<p id="add-friend-error" class="error-msg hidden"></p>
</div>
</div>
</div>
<!-- Log Viewer Overlay -->
<div id="log-viewer-overlay" class="modal-backdrop hidden">
<div class="modal modal-log">
<div class="modal-head">
<h3 data-i18n="logViewer.title">Game Log</h3>
<div class="log-viewer-actions">
<button class="btn-secondary btn-sm" id="copy-log-btn" onclick="app.copyLogs()"><span data-i18n="logViewer.copy">Copy</span></button>
<button class="btn-secondary btn-sm" onclick="app.openLogFile()"><span data-i18n="logViewer.openFile">Open File</span></button>
<button class="modal-close" id="close-log-viewer-btn" onclick="app.closeLogViewer()">&times;</button>
</div>
</div>
<div class="modal-body log-viewer-body">
<div id="log-viewer-content" class="log-viewer-content"></div>
</div>
</div>
</div>
<!-- Install Modal --> <!-- Install Modal -->
<div id="install-modal" class="modal-backdrop hidden"> <div id="install-modal" class="modal-backdrop hidden">
<div class="modal"> <div class="modal">
<div class="modal-head"> <div class="modal-head">
<h3 data-i18n="install.title">Install Pack</h3> <h3>Install Pack</h3>
<button class="modal-close" id="close-modal-btn">&times;</button> <button class="modal-close" id="close-modal-btn">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="modal-tabs"> <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 active" data-tab="zernmc">Server Pack</button>
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span></button> <button class="modal-tab" data-tab="custom">Custom</button>
</div> </div>
<div id="tab-zernmc" class="modal-tab-content active"> <div id="tab-zernmc" class="modal-tab-content active">
<div class="field"> <div class="field">
<label data-i18n="install.serverPack.label">Server Pack</label> <label>Server Pack</label>
<select id="zernmc-pack-select"> <select id="zernmc-pack-select">
<option value="">Loading...</option> <option value="">Loading...</option>
</select> </select>
</div> </div>
<div id="preset-select-container" class="field hidden"> <div class="field">
<label data-i18n="install.preset.label">Preset (optional)</label> <label>Local Name</label>
<select id="preset-select"> <input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
<option value=""><span data-i18n="install.preset.default">Default</span></option>
</select>
</div> </div>
<button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button> <button id="install-zernmc-btn" class="btn-primary">Download & Install</button>
</div> </div>
<div id="tab-custom" class="modal-tab-content"> <div id="tab-custom" class="modal-tab-content">
<div class="field"> <div class="field">
<label data-i18n="install.custom.name">Pack Name</label> <label>Minecraft Version</label>
<input type="text" id="custom-instance-name" placeholder="MyPack" maxlength="32"> <div class="select-wrap">
<select id="mc-version-select"><option>Loading...</option></select>
</div>
</div> </div>
<div class="field"> <div class="field">
<label data-i18n="install.custom.mcVersion">Minecraft Version</label> <label>Mod Loader</label>
<select id="mc-version-select"><option>Loading...</option></select> <div class="select-wrap">
</div> <select id="loader-select">
<div class="field"> <option value="vanilla">Vanilla (no loader)</option>
<label data-i18n="install.custom.loader">Loader</label> <option value="fabric">Fabric</option>
<select id="loader-select"> <option value="forge">Forge</option>
<option value="vanilla">Vanilla</option> <option value="neoforge">NeoForge</option>
<option value="fabric">Fabric</option> </select>
<option value="forge">Forge</option> </div>
<option value="neoforge">NeoForge</option>
</select>
</div> </div>
<div class="field hidden" id="loader-ver-field"> <div class="field hidden" id="loader-ver-field">
<label data-i18n="install.custom.loaderVersion">Loader Version</label> <label>Loader Version</label>
<select id="loader-ver-select"><option data-i18n="select.selectMCFirst">Select MC version first</option></select> <div class="select-wrap">
<select id="loader-ver-select"><option>Select loader version</option></select>
</div>
</div> </div>
<button id="install-custom-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button> <div class="field">
<label>Local Name</label>
<input type="text" id="custom-instance-name" placeholder="my-minecraft">
</div>
<button id="install-custom-btn" class="btn-primary">Download & Install</button>
</div> </div>
<div id="install-progress" class="install-progress hidden"> <div id="install-progress" class="install-progress hidden">
<div class="progress-track"> <div class="progress-track">
<div class="progress-fill" id="progress-fill"></div> <div class="progress-fill" id="progress-fill"></div>
</div> </div>
<p class="progress-label" id="progress-label" data-i18n="install.progress.installing">Installing...</p> <p class="progress-label" id="progress-label">Installing...</p>
<p class="progress-stage hidden" id="progress-stage"></p>
</div> </div>
</div> </div>
</div> </div>
@@ -648,7 +363,6 @@
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
</div> </div>
<script src="marked.min.js"></script>
<script src="launcher.js"></script> <script src="launcher.js"></script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+60 -564
View File
@@ -23,17 +23,12 @@
--shadow: 0 4px 24px rgba(0,0,0,0.5); --shadow: 0 4px 24px rgba(0,0,0,0.5);
--shadow-glow: 0 0 40px var(--accent-glow); --shadow-glow: 0 0 40px var(--accent-glow);
--transition: 200ms ease; --transition: 200ms ease;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', Arial, sans-serif; --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Monaco', monospace; --mono: 'JetBrains Mono', 'Consolas', monospace;
} }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { -webkit-user-select: none; user-select: none; }
.pack-entry, .pack-entry *, .ctx-menu, .ctx-menu * { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable], .log-viewer-content, .news-modal-body, .pack-description-text { -webkit-user-select: text; user-select: text; }
.pack-entry { -webkit-touch-callout: default; }
html { font-size: 14px; } html { font-size: 14px; }
body { body {
@@ -47,11 +42,8 @@ body {
#bg-canvas { #bg-canvas {
position: fixed; inset: 0; width: 100%; height: 100%; position: fixed; inset: 0; width: 100%; height: 100%;
z-index: 0; opacity: 0.14; pointer-events: none; z-index: 0; opacity: 0.08; pointer-events: none;
} }
#login-screen.exiting { pointer-events: none; }
.login-container.exit { will-change: transform, opacity, filter; }
#bg-canvas.bg-exit { will-change: transform; }
#app { position: relative; z-index: 1; height: 100vh; display: flex; } #app { position: relative; z-index: 1; height: 100vh; display: flex; }
@@ -67,94 +59,85 @@ body {
/* ========== LOGIN ========== */ /* ========== LOGIN ========== */
.login-container { .login-container {
position: relative; z-index: 1;
background: var(--bg-elevated); background: var(--bg-elevated);
border-radius: var(--radius-md); border: 1px solid var(--border);
padding: 32px 32px 24px; border-radius: var(--radius-lg);
padding: 48px 40px 40px;
width: 100%; width: 100%;
max-width: 360px; max-width: 380px;
box-shadow: 0 8px 40px rgba(0,0,0,0.5); box-shadow: var(--shadow);
transition: transform 0.35s ease, opacity 0.35s ease; animation: floatIn 0.5s ease forwards;
} }
.login-header { text-align: center; margin-bottom: 28px; overflow: hidden; } @keyframes floatIn {
.brand-icon { margin-bottom: 12px; } from { opacity: 0; transform: translateY(20px); }
.login-title { to { opacity: 1; transform: translateY(0); }
font-size: 22px; font-weight: 700;
color: var(--text); transition: transform 0.3s ease, opacity 0.3s ease;
}
.login-subtitle {
color: var(--text-muted); font-size: 13px; margin-top: 4px;
transition: transform 0.3s ease, opacity 0.3s ease;
} }
.login-form { display: flex; flex-direction: column; gap: 16px; } .login-brand { text-align: center; margin-bottom: 36px; }
.brand-icon { margin-bottom: 16px; }
.brand-title {
font-size: 28px; font-weight: 800;
background: linear-gradient(135deg, #fff 60%, var(--accent));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.brand-sub { color: var(--text-muted); font-size: 13px; margin-top: 4px; }
.field { display: flex; flex-direction: column; gap: 6px; } .login-form { display: flex; flex-direction: column; gap: 20px; }
.field { position: relative; }
.field label { .field label {
font-size: 11px; font-weight: 700; position: absolute; top: 50%; left: 14px; transform: translateY(-50%);
text-transform: uppercase; letter-spacing: 0.5px; font-size: 13px; color: var(--text-muted);
color: var(--text-secondary); 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 { .field input {
width: 100%; padding: 10px 12px; font-size: 15px; width: 100%; padding: 14px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border); background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: 4px; color: var(--text); border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); transition: border-color 150ms ease, box-shadow 150ms ease; font-family: var(--font); transition: var(--transition);
outline: none; outline: none;
} }
.field input:focus { .field input:focus {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow); box-shadow: 0 0 0 3px var(--accent-glow);
} }
.field input.invalid {
border-color: var(--error);
box-shadow: 0 0 0 2px rgba(248,113,113,0.15);
}
.field .field-hint {
font-size: 12px; color: var(--error); line-height: 1.4;
}
.field .field-hint.hidden { display: none; }
.field select { .field select {
width: 100%; padding: 10px 12px; font-size: 15px; width: 100%; padding: 12px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border); background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: 4px; color: var(--text); border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); cursor: pointer; outline: none; font-family: var(--font); cursor: pointer; outline: none;
} }
.field select:focus { border-color: var(--accent); } .field select:focus { border-color: var(--accent); }
#confirm-field {
transition: opacity 0.3s ease, max-height 0.3s ease, margin 0.3s ease;
overflow: hidden; max-height: 60px; opacity: 1; margin: 0;
}
#confirm-field.hidden { max-height: 0; opacity: 0; margin: 0; display: block !important; padding: 0; overflow: hidden; pointer-events: none; }
.btn-primary { .btn-primary {
width: 100%; padding: 14px; border: none; border-radius: 4px; width: 100%; padding: 14px; border: none; border-radius: var(--radius-sm);
background: var(--accent); color: #fff; background: linear-gradient(135deg, var(--accent), #ff6b6b);
font-size: 14px; font-weight: 600; cursor: pointer; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
font-family: var(--font); transition: background 150ms ease, box-shadow 150ms ease; font-family: var(--font); transition: var(--transition);
display: flex; align-items: center; justify-content: center; gap: 8px; display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 44px; position: relative; min-height: 48px; position: relative;
} }
.btn-primary:hover { background: #d63850; } .btn-primary:hover { transform: translateY(-1px); box-shadow: var(--shadow-glow); }
.btn-primary:active { background: #c22f46; transform: scale(0.98); } .btn-primary:active { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.error-msg { .error-msg {
color: var(--error); font-size: 13px; text-align: center; color: var(--error); font-size: 13px; text-align: center;
padding: 10px; background: rgba(248,113,113,0.1); padding: 10px; background: rgba(248,113,113,0.1);
border-radius: 4px; animation: shake 0.4s ease; border-radius: var(--radius-sm); animation: shake 0.4s ease;
} }
@keyframes shake { @keyframes shake {
0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)} 0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}
} }
.login-footer { .login-hint { text-align: center; font-size: 12px; color: var(--text-muted); margin-top: 4px; }
display: flex; justify-content: center; gap: 4px;
font-size: 12px; color: var(--text-muted); margin-top: 4px;
}
.login-footer-action { color: var(--accent); cursor: pointer; }
.login-footer-action:hover { text-decoration: underline; }
.spinner { .spinner {
position: absolute; width: 20px; height: 20px; position: absolute; width: 20px; height: 20px;
@@ -193,7 +176,7 @@ body {
padding: 16px 12px; padding: 16px 12px;
} }
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; } .sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 20px; overflow: hidden; }
.sidebar-brand { .sidebar-brand {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@@ -204,14 +187,8 @@ body {
.sidebar-brand-ver { font-size: 11px; color: var(--text-muted); } .sidebar-brand-ver { font-size: 11px; color: var(--text-muted); }
.sidebar-nav { .sidebar-nav {
display: flex; flex-direction: column; gap: 4px;
padding-bottom: 16px; border-bottom: 1px solid var(--border);
}
.sidebar-nav-primary, .sidebar-nav-secondary {
display: flex; gap: 4px; display: flex; gap: 4px;
} padding-bottom: 16px; border-bottom: 1px solid var(--border);
.sidebar-nav-primary .nav-btn, .sidebar-nav-secondary .nav-btn {
flex: 1;
} }
.nav-btn { .nav-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
@@ -227,38 +204,12 @@ body {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px; padding: 0 4px; margin-bottom: 8px; padding: 0 4px;
} }
.section-collapsible .section-header { cursor: pointer; user-select: none; } .section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
.section-collapsible .section-header:hover .section-title { color: var(--text-secondary); }
.section-title-wrap {
display: flex; align-items: center; gap: 6px; min-width: 0;
}
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); transition: var(--transition); }
.section-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform .2s var(--ease, ease); }
.section-collapsible { display: flex; flex-direction: column; min-height: 0; }
.section-collapsible:not(.collapsed) { flex: 1 1 auto; }
.section-collapsible:not(.collapsed) .section-chevron { transform: rotate(0deg); }
.section-collapsible.collapsed .section-chevron { transform: rotate(-90deg); }
.section-collapsible.collapsed .section-header { margin-bottom: 0; }
.section-collapsible.collapsed .section-title { color: var(--text-muted); }
.pack-list-collapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows .3s var(--ease, ease);
min-height: 0;
overflow: hidden;
}
.section-collapsible:not(.collapsed) .pack-list-collapse {
grid-template-rows: 1fr;
flex: 1;
}
.pack-list-collapse > .pack-list {
overflow-y: auto;
min-height: 0;
}
.pack-list { .pack-list {
display: flex; flex-direction: column; gap: 3px; display: flex; flex-direction: column; gap: 3px;
overflow-y: auto; max-height: calc((100vh - 460px) / 2);
min-height: 40px;
} }
.pack-list:empty::after { .pack-list:empty::after {
content: 'No packs'; display: block; padding: 12px 8px; content: 'No packs'; display: block; padding: 12px 8px;
@@ -274,16 +225,6 @@ body {
.pack-entry:hover { background: var(--bg-card); } .pack-entry:hover { background: var(--bg-card); }
.pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); } .pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); }
.pack-entry-icon { position: relative; }
.pack-entry-icon .update-badge {
position: absolute; top: -3px; right: -3px; width: 10px; height: 10px;
border-radius: 50%; background: #e94560; border: 2px solid var(--bg-main, #0f0f1a);
}
.update-label {
display: inline-block; font-size: 10px; font-weight: 600; color: #e94560;
background: rgba(233,69,96,0.12); padding: 0 5px; border-radius: 3px;
margin-left: 4px; vertical-align: middle; line-height: 16px;
}
.pack-entry-icon { .pack-entry-icon {
width: 32px; height: 32px; border-radius: 6px; width: 32px; height: 32px; border-radius: 6px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
@@ -331,14 +272,10 @@ body {
flex: 1; display: flex; flex-direction: column; flex: 1; display: flex; flex-direction: column;
padding: 24px 32px; min-width: 0; padding: 24px 32px; min-width: 0;
position: relative; position: relative;
overflow-y: auto;
} }
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; } .view { display: none; flex-direction: column; height: 100%; }
.view.active { display: flex; } .view.active { display: flex; }
#view-packs { overflow: hidden; }
#view-packs .pack-detail { flex: 1; overflow-y: auto; min-height: 0; }
#view-packs .play-bar { position: sticky; bottom: 0; z-index: 5; flex-shrink: 0; margin-top: 12px; }
.view-header { .view-header {
display: flex; align-items: flex-start; justify-content: space-between; display: flex; align-items: flex-start; justify-content: space-between;
@@ -422,12 +359,6 @@ body {
.pack-gallery-item img { .pack-gallery-item img {
width: 100%; height: 100%; object-fit: cover; 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 { .btn-play {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
@@ -441,84 +372,17 @@ body {
.btn-play:active:not(:disabled) { transform: translateY(0); } .btn-play:active:not(:disabled) { transform: translateY(0); }
.btn-play:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; } .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 ========== */
.news-grid { .news-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px; overflow-y: auto; padding-bottom: 24px; 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 { .news-card {
background: var(--bg-card); border: 1px solid var(--border); background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 24px; display: flex; border-radius: var(--radius-md); padding: 24px; display: flex;
flex-direction: column; gap: 12px; transition: var(--transition); 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:hover { border-color: var(--border-light); }
.news-card-badge { .news-card-badge {
align-self: flex-start; font-size: 10px; font-weight: 600; text-transform: uppercase; align-self: flex-start; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 1px; padding: 4px 10px; border-radius: 4px; letter-spacing: 1px; padding: 4px 10px; border-radius: 4px;
@@ -527,26 +391,6 @@ body {
.news-card h3 { font-size: 16px; font-weight: 600; } .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 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 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 ========== */
.settings-grid { display: flex; flex-direction: column; gap: 12px; } .settings-grid { display: flex; flex-direction: column; gap: 12px; }
@@ -572,18 +416,6 @@ body {
background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-light); background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-light);
} }
.setting-pass { display: flex; align-items: center; gap: 8px; } .setting-pass { display: flex; align-items: center; gap: 8px; }
.setting-network-list {
display: flex; flex-direction: column; gap: 6px; min-width: 260px; flex-shrink: 0;
}
.network-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
font-size: 12px; padding: 4px 10px; border-radius: 4px;
background: var(--bg-surface); border: 1px solid var(--border-light);
}
.network-name {
font-family: var(--mono); color: var(--text-secondary); text-transform: uppercase;
}
.network-state { font-weight: 600; }
.pass-input { .pass-input {
width: 160px; padding: 6px 12px; border-radius: var(--radius-sm); width: 160px; padding: 6px 12px; border-radius: var(--radius-sm);
background: var(--bg-inset); border: 1px solid var(--border-light); background: var(--bg-inset); border: 1px solid var(--border-light);
@@ -598,24 +430,6 @@ body {
.setting-input:focus { border-color: var(--accent); } .setting-input:focus { border-color: var(--accent); }
.btn-sm { padding: 6px 14px !important; font-size: 12px !important; } .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 ========== */
.modal-backdrop { .modal-backdrop {
position: fixed; inset: 0; background: rgba(7,7,10,0.85); position: fixed; inset: 0; background: rgba(7,7,10,0.85);
@@ -625,7 +439,7 @@ body {
.modal { .modal {
background: var(--bg-elevated); border: 1px solid var(--border); background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); width: 90%; max-width: 480px; border-radius: var(--radius-lg); width: 90%; max-width: 480px;
max-height: 85vh; box-shadow: var(--shadow); max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);
animation: floatIn 0.3s ease; animation: floatIn 0.3s ease;
} }
.modal-head { .modal-head {
@@ -658,17 +472,6 @@ body {
background: none; padding: 0; 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 { .select-wrap select {
width: 100%; padding: 10px 12px; font-size: 13px; width: 100%; padding: 10px 12px; font-size: 13px;
background: var(--bg-surface); border: 1px solid var(--border-light); background: var(--bg-surface); border: 1px solid var(--border-light);
@@ -678,7 +481,6 @@ body {
.select-wrap select:focus { border-color: var(--accent); } .select-wrap select:focus { border-color: var(--accent); }
.install-progress { padding-top: 16px; border-top: 1px solid var(--border); } .install-progress { padding-top: 16px; border-top: 1px solid var(--border); }
.inline-install-progress { padding: 16px 20px; border-top: 1px solid var(--border); background: var(--bg-surface); }
.progress-track { .progress-track {
height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden; height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden;
} }
@@ -687,41 +489,7 @@ body {
background: linear-gradient(90deg, var(--accent), #ff6b6b); background: linear-gradient(90deg, var(--accent), #ff6b6b);
border-radius: 3px; transition: width 0.3s ease; border-radius: 3px; transition: width 0.3s ease;
} }
.progress-fill.indeterminate {
width: 30%;
transition: none;
animation: indeterminate 1.3s ease-in-out infinite;
}
@keyframes indeterminate {
0% { margin-left: -30%; }
100% { margin-left: 100%; }
}
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; } .progress-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 ========== */
.toast { .toast {
@@ -734,7 +502,6 @@ body {
} }
.toast.error { border-color: rgba(248,113,113,0.3); color: var(--error); } .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.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 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; } } @keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
@@ -751,285 +518,14 @@ body {
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.sidebar { width: 56px; min-width: 56px; } .sidebar { width: 56px; min-width: 56px; }
.sidebar-brand-text, .sidebar-nav-primary .nav-btn span, .sidebar-nav-secondary .nav-btn span, .sidebar-brand-text, .sidebar-nav .nav-btn span,
.section-header, .pack-entry-info, .user-info, .section-header, .pack-entry-info, .user-info,
.sidebar-bottom .user-card .btn-icon:first-child { display: none; } .sidebar-bottom .user-card .btn-icon:first-child { display: none; }
.sidebar-brand { justify-content: center; padding: 8px; } .sidebar-brand { justify-content: center; padding: 8px; }
.sidebar-nav-primary, .sidebar-nav-secondary { flex-direction: column; } .sidebar-nav { flex-direction: column; }
.nav-btn { padding: 8px; } .nav-btn { padding: 8px; }
.pack-entry { justify-content: center; padding: 8px; } .pack-entry { justify-content: center; padding: 8px; }
.content { padding: 12px; } .content { padding: 12px; }
.play-bar { flex-direction: column; gap: 12px; } .play-bar { flex-direction: column; gap: 12px; }
.view-header { flex-direction: column; } .view-header { flex-direction: column; }
} }
/* ========== FRIENDS ========== */
.friends-search {
display: flex; align-items: center; gap: 8px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 12px;
}
.friends-search svg { flex-shrink: 0; color: var(--text-muted); }
.friends-search input {
flex: 1; background: transparent; border: none; outline: none;
color: var(--text); font-size: 13px; font-family: var(--font);
}
.friends-search input::placeholder { color: var(--text-muted); }
.friends-list { display: flex; flex-direction: column; gap: 4px; }
.friends-empty {
text-align: center; padding: 40px 20px; color: var(--text-muted); font-size: 13px;
}
.friends-group-label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); padding: 8px 4px 4px; letter-spacing: 0.5px;
}
.friend-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius-sm); transition: var(--transition);
}
.friend-item:hover { background: var(--bg-card-hover); }
.friend-item:hover .friend-remove-btn { opacity: 1; }
.friend-avatar {
width: 36px; height: 36px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-weight: 600; font-size: 14px;
flex-shrink: 0; position: relative;
background: var(--accent-soft); color: var(--accent);
}
.friend-avatar.online::after {
content: ''; position: absolute; bottom: 0; right: 0;
width: 10px; height: 10px; border-radius: 50%;
background: var(--success); border: 2px solid var(--bg-surface);
}
.friend-avatar.offline { opacity: 0.6; }
.friend-info { flex: 1; min-width: 0; }
.friend-name-row { display: flex; align-items: center; gap: 6px; }
.friend-name { font-size: 13px; font-weight: 500; color: var(--text); }
.friend-status { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.friend-status-dot {
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
}
.friend-status-dot.online { background: var(--success); }
.friend-status-dot.offline { background: var(--text-muted); }
.friend-pack { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.friend-remove-btn {
opacity: 0; transition: var(--transition); flex-shrink: 0;
width: 28px; height: 28px; color: var(--text-muted);
}
.friend-remove-btn:hover { color: var(--error); }
.friend-requests-section {
margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border);
}
.friend-requests-section .section-header {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px;
}
.friend-request-item {
display: flex; align-items: center; gap: 10px; padding: 10px;
background: var(--bg-card); border-radius: var(--radius-sm);
margin-bottom: 6px;
}
.friend-request-avatar {
width: 36px; height: 36px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-weight: 600; font-size: 14px;
flex-shrink: 0; background: var(--accent-soft); color: var(--accent);
}
.friend-request-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; }
.friend-request-name { font-size: 13px; font-weight: 500; }
.friend-request-text { font-size: 11px; color: var(--text-muted); }
.friend-request-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ========== WHITELIST MODS ========== */
.whitelist-mods-section {
margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border);
}
.whitelist-mods-section .section-header {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px;
}
.whitelist-mods-list { display: flex; flex-direction: column; gap: 4px; }
.whitelist-loading {
text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px;
}
.whitelist-mod-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius-sm); transition: var(--transition);
background: var(--bg-card);
}
.whitelist-mod-item:hover { background: var(--bg-card-hover); }
.whitelist-mod-checkbox {
width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent);
}
.whitelist-mod-info { flex: 1; min-width: 0; }
.whitelist-mod-name { font-size: 13px; font-weight: 500; color: var(--text); }
.whitelist-mod-size { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.whitelist-mod-status { font-size: 11px; color: var(--success); }
.whitelist-install-btn {
width: 100%; padding: 10px; margin-top: 8px;
background: var(--accent-soft); border: 1px solid rgba(233,69,96,0.3);
border-radius: var(--radius-sm); color: var(--accent); font-size: 13px;
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
}
.whitelist-install-btn:hover { background: var(--accent); color: #fff; }
.whitelist-install-btn:disabled { opacity: 0.4; cursor: default; }
/* ========== MODAL SM ========== */
.modal-sm { max-width: 360px; }
/* ========== BADGE SM ========== */
.badge-sm { font-size: 10px; padding: 2px 6px; }
/* ========== ADMIN PANEL ========== */
.admin-tabs {
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
}
.admin-tab {
padding: 8px 14px; background: transparent; border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12px;
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
}
.admin-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); }
.admin-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); }
.admin-tab-content { display: none; flex-direction: column; gap: 12px; }
.admin-tab-content.active { display: flex; }
.admin-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 16px;
}
.admin-card-header {
font-size: 13px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px;
border-bottom: 1px solid var(--border); color: var(--text);
}
.admin-list { display: flex; flex-direction: column; gap: 4px; }
.admin-loading, .admin-empty {
text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px;
}
.admin-row {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;
}
.admin-input {
flex: 1; padding: 8px 12px; background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text); font-size: 13px; outline: none; font-family: var(--font);
}
.admin-input:focus { border-color: var(--accent); }
.admin-select {
padding: 6px 10px; background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text); font-size: 12px; outline: none; cursor: pointer; font-family: var(--font);
}
.admin-textarea {
width: 100%; min-height: 100px; padding: 10px; background: var(--bg-surface);
border: 1px solid var(--border-light); border-radius: var(--radius-sm); color: var(--text);
font-size: 13px; outline: none; font-family: var(--font); resize: vertical;
}
.admin-textarea:focus { border-color: var(--accent); }
.admin-file-input {
font-size: 12px; color: var(--text-secondary); font-family: var(--font);
}
.admin-status {
font-size: 12px; padding: 8px 12px; border-radius: var(--radius-sm); margin-top: 8px;
}
.admin-status.success { background: rgba(74,222,128,0.1); color: var(--success); }
.admin-status.error { background: rgba(248,113,113,0.1); color: var(--error); }
.admin-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius-sm); transition: var(--transition); font-size: 13px;
}
.admin-item:hover { background: var(--bg-card-hover); }
.admin-item-name { flex: 1; color: var(--text); }
.admin-item-meta { font-size: 11px; color: var(--text-muted); }
.admin-item-actions { display: flex; gap: 6px; flex-shrink: 0; }
.admin-form { display: flex; flex-direction: column; gap: 8px; }
.admin-btn-danger { color: var(--error) !important; }
.admin-btn-danger:hover { background: rgba(248,113,113,0.1) !important; border-color: rgba(248,113,113,0.3) !important; }
.admin-news-item {
display: flex; align-items: flex-start; gap: 10px; padding: 10px;
border-radius: var(--radius-sm); transition: var(--transition); border: 1px solid var(--border);
margin-bottom: 6px;
}
.admin-news-item:hover { background: var(--bg-card-hover); }
.admin-news-info { flex: 1; min-width: 0; }
.admin-news-title { font-size: 13px; font-weight: 500; }
.admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.pack-entry.disabled { opacity: 0.5; pointer-events: none; }
.pack-entry.disabled .pack-entry-name::after { content: ' (' attr(data-disabled-text) ')'; color: var(--error); font-size: 11px; }
.admin-pack-disabled { color: var(--error) !important; font-weight: 600; }
.admin-pack-enabled { color: var(--success) !important; font-weight: 600; }
/* ========== OFFLINE MODE ========== */
.offline-container {
position: relative; z-index: 1;
background: var(--bg-elevated);
border-radius: var(--radius-md);
padding: 32px 32px 24px;
width: 100%;
max-width: 380px;
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
text-align: center;
}
.offline-badge {
width: 64px; height: 64px;
margin: 0 auto 14px;
display: flex; align-items: center; justify-content: center;
background: rgba(233,69,96,0.12);
border: 1px solid rgba(233,69,96,0.3);
border-radius: 14px;
color: var(--accent);
}
.offline-title { font-size: 22px; font-weight: 700; color: var(--text); }
.offline-subtitle {
color: var(--text-muted); font-size: 13px; margin: 8px 0 20px; line-height: 1.5;
}
.offline-container .login-form { text-align: left; }
.offline-footer {
display: flex; gap: 8px; justify-content: center; margin-top: 18px;
}
.btn-ghost {
padding: 9px 14px; font-size: 13px;
background: transparent; color: var(--text-secondary);
border: 1px solid var(--border); border-radius: 4px;
cursor: pointer; font-family: var(--font);
transition: border-color 150ms ease, color 150ms ease;
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
/* ========== LOCAL MODS ========== */
.mods-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); }
.mods-section .section-header { display:flex; align-items:center; gap:10px; flex-wrap:wrap; font-size:11px; font-weight:600; text-transform:uppercase; color:var(--text-muted); padding:4px; margin-bottom:8px; letter-spacing:0.5px; }
.mods-count { font-size:11px; color:var(--text-secondary); margin-left:auto; }
.mods-search { margin-left:auto; min-width:160px; padding:6px 10px; border-radius:var(--radius-sm); background:var(--bg-surface); border:1px solid var(--border-light); color:var(--text); font-size:12px; outline:none; }
.mods-search:focus { border-color:var(--accent); }
.mods-list { display:flex; flex-direction:column; gap:4px; }
.mods-loading { text-align:center; padding:20px; color:var(--text-muted); font-size:12px; }
.mod-item { display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:var(--radius-sm); background:var(--bg-card); border:1px solid var(--border); transition:var(--transition); }
.mod-item:hover { background:var(--bg-card-hover); }
.mod-item.mod-disabled { opacity:0.6; }
.mod-icon { width:32px; height:32px; border-radius:6px; display:flex; align-items:center; justify-content:center; background:var(--bg-inset); color:var(--text-muted); flex-shrink:0; }
.mod-info { flex:1; min-width:0; }
.mod-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mod-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mod-badge { font-size:10px; font-weight:700; padding:2px 6px; border-radius:4px; flex-shrink:0; }
.mod-badge-enabled { background:rgba(74,222,128,0.15); color:var(--success); }
.mod-badge-disabled { background:rgba(248,113,113,0.12); color:var(--error); }
/* ========== CONTEXT MENU (PKM) ========== */
.ctx-menu { position:fixed; z-index:300; min-width:200px; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-sm); box-shadow:0 12px 32px rgba(0,0,0,0.5); padding:4px; display:flex; flex-direction:column; gap:2px; animation:ctxIn 0.12s ease; }
@keyframes ctxIn { from { opacity:0; transform:translateY(4px) scale(0.98); } to { opacity:1; transform:none; } }
.ctx-item { display:flex; align-items:center; gap:10px; padding:8px 12px; border-radius:4px; font-size:13px; color:var(--text-secondary); cursor:pointer; transition:var(--transition); border:none; background:transparent; font-family:var(--font); text-align:left; width:100%; }
.ctx-item:hover { background:var(--bg-card); color:var(--text); }
.ctx-item.danger { color:var(--error); }
.ctx-item.danger:hover { background:rgba(248,113,113,0.08); }
.ctx-sep { height:1px; background:var(--border); margin:2px 0; }
.ctx-backdrop { position:fixed; inset:0; z-index:299; }
/* ========== ONBOARDING SPOTLIGHT ========== */
.onboard-overlay { position:fixed; inset:0; z-index:400; background:rgba(7,7,10,0.72); backdrop-filter:blur(2px); animation:fadeIn 0.2s ease; }
.onboard-card { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); z-index:405; max-width:420px; width:90%; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-lg); padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.6); animation:floatIn 0.25s ease; }
.onboard-card h3 { font-size:18px; font-weight:700; margin-bottom:8px; }
.onboard-card p { font-size:13px; color:var(--text-secondary); line-height:1.6; margin-bottom:16px; }
.onboard-progress { font-size:11px; color:var(--text-muted); letter-spacing:0.5px; text-transform:uppercase; margin-bottom:12px; }
.onboard-actions { display:flex; gap:8px; justify-content:flex-end; }
.onboard-spot { position:fixed; z-index:401; border:2px solid var(--accent); border-radius:var(--radius-sm); box-shadow:0 0 0 9999px rgba(7,7,10,0.55), 0 0 24px var(--accent-glow); pointer-events:auto; cursor:pointer; transition:all 0.35s ease; }
.onboard-spot-label { position:fixed; z-index:402; background:var(--accent); color:#fff; font-size:12px; font-weight:600; padding:6px 10px; border-radius:999px; pointer-events:auto; cursor:pointer; transform:translate(-50%, -100%); margin-top:-8px; white-space:nowrap; }
/* ========== PACK SETTINGS MODAL ========== */
.pack-settings-form { display:flex; flex-direction:column; gap:16px; }
@@ -1,274 +0,0 @@
package me.sashegdev.zernmc.launcher.auth;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
class AuthManagerPassTest {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static AuthManager.AuthSession createSession(String token, int role) {
AuthManager.AuthSession s = new AuthManager.AuthSession();
s.accessToken = token;
s.role = role;
s.expiresAt = System.currentTimeMillis() / 1000L + 3600;
return s;
}
@Test
void hasPass_returnsFalse_whenNotLoggedIn() {
AuthManager.resetForTest();
assertFalse(AuthManager.hasPass());
assertFalse(AuthManager.hasActivePass());
}
@Test
void hasPass_usesUserInfo_whenAvailable() {
AuthManager.UserInfo info = new AuthManager.UserInfo();
info.has_pass = true;
info.role = 1;
AuthManager.setTestUserInfo(info);
AuthManager.setTestSession(createSession("tok", 1));
assertTrue(AuthManager.hasPass());
assertTrue(AuthManager.hasActivePass());
}
@Test
void hasPass_usesRole_whenUserInfoNull() {
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
assertTrue(AuthManager.hasPass());
assertTrue(AuthManager.hasActivePass());
}
@Test
void hasPass_returnsFalse_whenRoleTooLow() {
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_USER));
assertFalse(AuthManager.hasPass());
assertFalse(AuthManager.hasActivePass());
}
@Test
void hasPass_userInfoTakesPriorityOverRole() {
AuthManager.UserInfo info = new AuthManager.UserInfo();
info.has_pass = false;
info.role = 1;
AuthManager.setTestUserInfo(info);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
assertFalse(AuthManager.hasPass());
assertFalse(AuthManager.hasActivePass());
}
@Test
void canViewPacks_usesPermissions_whenAvailable() {
AuthManager.UserInfo info = new AuthManager.UserInfo();
info.permissions = List.of("view_packs", "download_pack");
info.has_pass = true;
AuthManager.setTestUserInfo(info);
AuthManager.setTestSession(createSession("tok", 1));
assertTrue(AuthManager.canViewPacks());
assertTrue(AuthManager.canDownloadPacks());
}
@Test
void canViewPacks_fallsBackToHasPass_whenNoPermissions() {
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
assertTrue(AuthManager.canViewPacks());
assertTrue(AuthManager.canDownloadPacks());
}
@Test
void authSession_parsesFromLoginResponse() {
String json = """
{
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test",
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh",
"expires_in": 86400,
"token_type": "bearer",
"username": "testuser",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"role": 1,
"role_name": "PASS_HOLDER"
}
""";
AuthManager.AuthSession session = GSON.fromJson(json, AuthManager.AuthSession.class);
assertNotNull(session);
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test", session.accessToken);
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh", session.refreshToken);
assertEquals(86400, session.expiresIn);
assertEquals("testuser", session.username);
assertEquals("550e8400-e29b-41d4-a716-446655440000", session.uuid);
assertEquals(1, session.role);
}
@Test
void authSession_roundTrip() {
AuthManager.AuthSession original = new AuthManager.AuthSession();
original.accessToken = "access123";
original.refreshToken = "refresh123";
original.expiresIn = 86400;
original.expiresAt = System.currentTimeMillis() / 1000L + 86400;
original.username = "testuser";
original.uuid = "550e8400-e29b-41d4-a716-446655440000";
original.role = 1;
String json = GSON.toJson(original);
AuthManager.AuthSession parsed = GSON.fromJson(json, AuthManager.AuthSession.class);
assertEquals(original.accessToken, parsed.accessToken);
assertEquals(original.refreshToken, parsed.refreshToken);
assertEquals(original.expiresIn, parsed.expiresIn);
assertEquals(original.expiresAt, parsed.expiresAt);
assertEquals(original.username, parsed.username);
assertEquals(original.uuid, parsed.uuid);
assertEquals(original.role, parsed.role);
}
@Test
void userInfo_parsesFromMeEndpoint() {
String json = """
{
"id": 1,
"username": "testuser",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"role": 1,
"role_name": "PASS_HOLDER",
"has_pass": true,
"permissions": ["view_packs", "download_pack"]
}
""";
AuthManager.UserInfo info = GSON.fromJson(json, AuthManager.UserInfo.class);
assertNotNull(info);
assertEquals(1, info.id);
assertEquals("testuser", info.username);
assertEquals(1, info.role);
assertEquals("PASS_HOLDER", info.role_name);
assertTrue(info.has_pass);
assertTrue(info.permissions.contains("view_packs"));
assertTrue(info.permissions.contains("download_pack"));
assertTrue(info.hasPermission("view_packs"));
assertFalse(info.hasPermission("admin"));
}
@Test
void updateRole_updatesSessionRole() {
AuthManager.resetForTest();
AuthManager.setTestSession(createSession("tok", 0));
AuthManager.setTestUserInfo(null);
assertEquals(0, AuthManager.getRole());
assertFalse(AuthManager.hasPass());
AuthManager.updateRole(1);
assertEquals(1, AuthManager.getRole());
}
@Test
void isLoggedIn_returnsTrue_whenSessionExists() {
AuthManager.resetForTest();
assertFalse(AuthManager.isLoggedIn());
AuthManager.AuthSession s = createSession("tok", 0);
s.username = "testuser";
AuthManager.setTestSession(s);
assertTrue(AuthManager.isLoggedIn());
assertEquals("testuser", AuthManager.getUsername());
}
@Test
void getUsername_returnsSessionUsername() {
AuthManager.AuthSession s = createSession("tok", 0);
s.username = "testuser";
AuthManager.setTestSession(s);
assertEquals("testuser", AuthManager.getUsername());
}
@Test
void getRole_returnsZero_whenSessionNull() {
AuthManager.resetForTest();
assertEquals(0, AuthManager.getRole());
}
@Test
void getRoleName_fallsBackToUSER_whenUserInfoNull() {
AuthManager.resetForTest();
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", 0));
assertEquals("USER", AuthManager.getRoleName());
}
@Test
void getAccessToken_returnsToken_whenSessionValid() {
AuthManager.resetForTest();
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("valid-token", 1));
String token = AuthManager.getAccessToken();
assertEquals("valid-token", token);
}
@Test
void getAccessToken_doesNotInvalidate_whenNoRefreshToken() {
AuthManager.resetForTest();
AuthManager.setTestUserInfo(null);
AuthManager.AuthSession s = createSession("tok", 1);
s.refreshToken = null;
AuthManager.setTestSession(s);
String token = AuthManager.getAccessToken();
assertEquals("tok", token);
assertTrue(AuthManager.isLoggedIn());
}
@Test
void getAccessToken_returnsZero_whenSessionNull() {
AuthManager.resetForTest();
assertEquals("0", AuthManager.getAccessToken());
}
@Test
void invalidateSession_clearsState() {
AuthManager.resetForTest();
AuthManager.setTestSession(createSession("tok", 1));
AuthManager.setTestUserInfo(new AuthManager.UserInfo());
assertTrue(AuthManager.isLoggedIn());
AuthManager.logout();
assertFalse(AuthManager.isLoggedIn());
assertEquals(0, AuthManager.getRole());
}
@Test
void loadSavedSession_returnsFalse_whenNoAuthFile() {
AuthManager.resetForTest();
AuthManager.logout();
assertFalse(AuthManager.loadSavedSession());
}
}
@@ -1,252 +0,0 @@
package me.sashegdev.zernmc.launcher.integration;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.installer.ModLoaderInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import org.junit.jupiter.api.*;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.net.URL;
import java.net.HttpURLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
@Tag("integration")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ForgeInstallIntegrationTest {
private static final String MC_VERSION = "1.20.1";
private static final String FORGE_VERSION = "47.4.22";
private static Path instanceDir;
private static Instance instance;
private static boolean installSuccess = false;
@BeforeAll
static void setup() throws Exception {
// Check network
assumeTrue(networkReachable("https://maven.minecraftforge.net"),
"Forge maven not reachable, skipping");
assumeTrue(networkReachable("https://piston-meta.mojang.com"),
"Mojang meta not reachable, skipping");
instanceDir = Files.createTempDirectory("zernmc-forge-test-");
instance = new Instance("forge-test", instanceDir);
instance.setMinecraftVersion(MC_VERSION);
instance.setLoaderType("forge");
instance.setLoaderVersion(FORGE_VERSION);
instance.setAssetIndex(MC_VERSION);
}
@AfterAll
static void cleanup() throws Exception {
if (instanceDir != null) {
try (var stream = Files.walk(instanceDir)) {
stream.sorted((a, b) -> b.compareTo(a))
.forEach(p -> {
try { Files.deleteIfExists(p); } catch (Exception ignored) {}
});
}
}
}
@Test
@Order(1)
@DisplayName("Install Minecraft vanilla 1.20.1")
void installMinecraft() throws Exception {
System.out.println("\n=== Installing Minecraft " + MC_VERSION + " ===");
VersionInstaller versionInstaller = new VersionInstaller(instanceDir);
String assetIndex = versionInstaller.install(MC_VERSION);
assertNotNull(assetIndex, "assetIndex must not be null after install");
instance.setAssetIndex(assetIndex);
// Verify version.json exists
Path versionJson = instanceDir.resolve("versions/" + MC_VERSION + "/" + MC_VERSION + ".json");
assertTrue(Files.exists(versionJson), "version.json must exist after vanilla install");
// Verify client jar exists
Path clientJar = instanceDir.resolve("versions/" + MC_VERSION + "/" + MC_VERSION + ".jar");
assertTrue(Files.exists(clientJar), "client jar must exist after vanilla install");
// Verify libraries exist
Path libsDir = instanceDir.resolve("libraries");
assertTrue(Files.exists(libsDir) && Files.list(libsDir).findAny().isPresent(),
"At least one library must exist after vanilla install");
System.out.println("=== Minecraft install OK ===\n");
}
@Test
@Order(2)
@DisplayName("Install Forge 1.20.1-47.4.22")
void installForge() throws Exception {
System.out.println("\n=== Installing Forge " + MC_VERSION + "-" + FORGE_VERSION + " ===");
ModLoaderInstaller installer = new ModLoaderInstaller(instance);
boolean success = installer.install(MC_VERSION, FORGE_VERSION, ModLoaderInstaller.LoaderType.FORGE);
assertTrue(success, "Forge installation must succeed");
installSuccess = true;
// Verify Forge version.json exists
String forgeVersionId = MC_VERSION + "-forge-" + FORGE_VERSION;
Path forgeJson = instanceDir.resolve("versions/" + forgeVersionId + "/" + forgeVersionId + ".json");
assertTrue(Files.exists(forgeJson), "Forge version.json must exist at " + forgeJson);
System.out.println(" Forge version.json size: " + Files.size(forgeJson) + " bytes\n");
}
@Test
@Order(3)
@DisplayName("Build launch command and verify structure")
void buildAndVerifyCommand() throws Exception {
assumeTrue(installSuccess, "Skipping: Forge install did not succeed");
System.out.println("\n=== Building launch command ===");
LaunchOptions options = new LaunchOptions();
options.setUsername("TestPlayer");
options.setUuid("00000000-0000-0000-0000-000000000000");
options.setAccessToken("test-token");
options.setMaxMemory(4096);
options.setWidth(854);
options.setHeight(480);
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// === JVM args checks ===
// Real Forge version.json (1.20.1-47.x) has no -cp, so the launcher adds it
assertTrue(command.contains("-cp"),
"Launcher must add -cp for Forge (version.json has none)");
int cpIdx = command.indexOf("-cp");
assertTrue(cpIdx + 1 < command.size(), "-cp must have a value");
String classpath = command.get(cpIdx + 1);
assertNotNull(classpath, "-cp value must not be null");
assertFalse(classpath.contains("${classpath}"), "-cp must not contain unresolved ${classpath} placeholder");
// Find -Djava.library.path (added manually, not from forge child)
String libPath = null;
for (String arg : command) {
if (arg != null && arg.startsWith("-Djava.library.path=")) {
libPath = arg;
break;
}
}
assertNotNull(libPath, "Must have -Djava.library.path");
assertTrue(libPath.contains("natives"), "-Djava.library.path must point to natives dir");
// Must have -p (module path) from forge child
int pIdx = command.indexOf("-p");
assertTrue(pIdx >= 0, "Must have -p");
String modulePath = command.get(pIdx + 1);
assertNotNull(modulePath, "-p must have a value");
assertTrue(modulePath.contains("bootstraplauncher"), "-p must include bootstraplauncher");
// Must have --add-modules + ALL-MODULE-PATH as two args (forge format)
int amIdx = command.indexOf("--add-modules");
assertTrue(amIdx >= 0, "Must have --add-modules");
assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1),
"--add-modules must be followed by ALL-MODULE-PATH");
// Must have --add-opens (at least one) from forge child
boolean hasAddOpens = command.stream().anyMatch(a -> a != null && a.startsWith("--add-opens"));
assertTrue(hasAddOpens, "Must have at least one --add-opens");
// Main class
assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"),
"Main class must be BootstrapLauncher");
// Game args: parent (--username) + child (--launchTarget)
assertTrue(command.contains("--username"), "Game args must include --username from parent");
assertTrue(command.contains("--launchTarget"), "Game args must include --launchTarget from child");
// Memory/GC args
assertTrue(command.contains("-Xmx4096M"), "Must have -Xmx");
assertTrue(command.contains("-XX:+UseG1GC"), "Must have GC flags");
System.out.println("=== Command structure OK (" + command.size() + " args) ===\n");
}
@Test
@Order(4)
@DisplayName("Module path is valid (headless smoke test)")
void modulePathSmokeTest() throws Exception {
assumeTrue(installSuccess, "Skipping: Forge install did not succeed");
System.out.println("\n=== Module path smoke test ===");
LaunchOptions options = new LaunchOptions();
options.setUsername("TestPlayer");
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Extract key args
String libPath = null;
String modulePath = null;
for (int i = 0; i < command.size(); i++) {
String arg = command.get(i);
if (arg != null) {
if (arg.startsWith("-Djava.library.path=")) libPath = arg;
}
}
int pIdx = command.indexOf("-p");
if (pIdx >= 0) modulePath = command.get(pIdx + 1);
// Find --add-modules + ALL-MODULE-PATH as two args
int amIdx = command.indexOf("--add-modules");
String addModules = null;
if (amIdx >= 0 && amIdx + 1 < command.size()) {
addModules = command.get(amIdx + 1);
}
assertNotNull(libPath, "-Djava.library.path must exist");
assertNotNull(modulePath, "-p must exist");
assertNotNull(addModules, "--add-modules must exist");
assertEquals("ALL-MODULE-PATH", addModules, "--add-modules must be ALL-MODULE-PATH");
// Run: java -Djava.library.path=<path> -p <mp> --add-modules ALL-MODULE-PATH -version
ProcessBuilder pb = new ProcessBuilder(
"java",
libPath,
"-p", modulePath,
"--add-modules", addModules,
"-version"
);
pb.redirectErrorStream(true);
long start = System.currentTimeMillis();
Process proc = pb.start();
String output = new String(proc.getInputStream().readAllBytes());
int exit = proc.waitFor();
long elapsed = System.currentTimeMillis() - start;
System.out.println(" JVM accepted module path in " + elapsed + "ms");
System.out.println(" Output: " + output.trim().replace('\n', ' '));
assertEquals(0, exit,
"JVM must accept module path without errors.\nExit: " + exit + "\nOutput: " + output);
System.out.println("=== Module path smoke test OK ===\n");
}
// ================================================================
// Helper
// ================================================================
private static boolean networkReachable(String urlStr) {
try {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.setRequestMethod("GET");
int code = conn.getResponseCode();
conn.disconnect();
return true;
} catch (Exception e) {
System.out.println(" Network check failed for " + urlStr + ": " + e.getMessage());
return false;
}
}
}
@@ -1,563 +0,0 @@
package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class LaunchCommandBuilderTest {
@TempDir
Path tempDir;
// ================================================================
// Helpers
// ================================================================
private Path createJar(Path path) throws Exception {
Files.createDirectories(path.getParent());
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(path))) {
zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
zos.write("Manifest-Version: 1.0\n".getBytes());
zos.closeEntry();
}
return path;
}
private Path createJarWithModule(Path path, String moduleName) throws Exception {
Files.createDirectories(path.getParent());
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(path))) {
zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
zos.write(("Manifest-Version: 1.0\nAutomatic-Module-Name: " + moduleName + "\n").getBytes());
zos.closeEntry();
}
return path;
}
private void writeVersionJson(Path path, String content) throws Exception {
Files.createDirectories(path.getParent());
Files.writeString(path, content);
}
// ================================================================
// Fixture setup
// ================================================================
private Instance createForgeFixture(Path dir) throws Exception {
String mcVer = "1.20.1";
String forgeVer = "47.3.0";
String versionId = mcVer + "-forge-" + forgeVer;
// Vanilla parent
String vanillaJson = """
{
"id": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"],
"game": ["--username", "${auth_player_name}", "--version", "${version_name}", "--gameDir", "${game_directory}", "--assetsDir", "${assets_root}", "--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}", "--userType", "${user_type}", "--versionType", "${version_type}"]
},
"assetIndex": {"id": "1.20.1"},
"libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
createJarWithModule(dir.resolve("versions/1.20.1/1.20.1.jar"), "minecraft");
createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
// Forge child real 1.20.1-47.3.0 format: -p is a fixed bootstrap list,
// NO ${classpath} and NO -cp (the launcher must add -cp itself).
String forgeJson = """
{
"id": "1.20.1-forge-47.3.0",
"inheritsFrom": "1.20.1",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"arguments": {
"jvm": ["-Djava.net.preferIPv6Addresses=system",
"-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-,${version_name}.jar",
"-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar",
"-DlibraryDirectory=${library_directory}",
"-p", "${library_directory}/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar${classpath_separator}${library_directory}/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
"--add-modules", "ALL-MODULE-PATH",
"--add-opens", "java.base/java.util.jar=cpw.mods.securejarhandler",
"--add-opens", "java.base/java.lang.invoke=cpw.mods.securejarhandler"],
"game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0",
"--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge",
"--fml.mcpVersion", "20230612.114412"]
},
"libraries": [
{"name": "cpw.mods:bootstraplauncher:1.1.2", "downloads": {"artifact": {"path": "cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar"}}},
{"name": "cpw.mods:securejarhandler:2.1.10", "downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar"}}},
{"name": "net.minecraftforge:forge:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar"}}}
]
}
""";
writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), forgeJson);
createJar(dir.resolve("libraries/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar"));
createJar(dir.resolve("libraries/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar"));
createJar(dir.resolve("libraries/net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar"));
Files.createDirectories(dir.resolve("natives"));
Instance instance = new Instance("test-forge", dir);
instance.setMinecraftVersion(mcVer);
instance.setLoaderType("forge");
instance.setLoaderVersion(forgeVer);
instance.setAssetIndex(mcVer);
return instance;
}
private Instance createNeoForgeFixture(Path dir) throws Exception {
String mcVer = "1.20.1";
String neoVer = "47.1.106";
String versionId = mcVer + "-neoforge-" + neoVer;
String vanillaJson = """
{
"id": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"],
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"]
},
"assetIndex": {"id": "1.20.1"},
"libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
createJarWithModule(dir.resolve("versions/1.20.1/1.20.1.jar"), "minecraft");
createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
String neoJson = """
{
"id": "1.20.1-neoforge-47.1.106",
"inheritsFrom": "1.20.1",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"arguments": {
"jvm": ["-p", "${classpath}", "--add-modules=ALL-MODULE-PATH", "--add-opens=java.base/java.util.jar=ALL-UNNAMED"],
"game": ["--launchTarget", "neoforgeclient", "--fml.neoForgeVersion", "47.1.106", "--fml.mcVersion", "1.20.1"]
},
"libraries": [{"name": "net.neoforged:neoforge:1.20.1-47.1.106", "downloads": {"artifact": {"path": "net/neoforged/neoforge/1.20.1-47.1.106/neoforge-1.20.1-47.1.106.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), neoJson);
createJar(dir.resolve("libraries/net/neoforged/neoforge/1.20.1-47.1.106/neoforge-1.20.1-47.1.106.jar"));
Files.createDirectories(dir.resolve("natives"));
Instance instance = new Instance("test-neoforge", dir);
instance.setMinecraftVersion(mcVer);
instance.setLoaderType("neoforge");
instance.setLoaderVersion(neoVer);
instance.setAssetIndex(mcVer);
instance.setFabricVersionId(versionId);
return instance;
}
private Instance createFabricFixture(Path dir) throws Exception {
String mcVer = "1.20.1";
String loaderVer = "0.16.10";
String versionId = "fabric-loader-" + loaderVer + "-" + mcVer;
String vanillaJson = """
{
"id": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"]
},
"assetIndex": {"id": "1.20.1"},
"libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
createJar(dir.resolve("versions/1.20.1/1.20.1.jar"));
String fabricJson = """
{
"id": "fabric-loader-0.16.10-1.20.1",
"inheritsFrom": "1.20.1",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"arguments": {
"game": ["--username", "${auth_player_name}"]
},
"libraries": [{"name": "net.fabricmc:fabric-loader:0.16.10", "downloads": {"artifact": {"path": "net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), fabricJson);
createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
createJar(dir.resolve("libraries/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"));
Files.createDirectories(dir.resolve("natives"));
Instance instance = new Instance("test-fabric", dir);
instance.setMinecraftVersion(mcVer);
instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVer);
instance.setAssetIndex(mcVer);
instance.setFabricVersionId(versionId);
return instance;
}
private Instance createVanillaFixture(Path dir) throws Exception {
Files.createDirectories(dir.resolve("natives"));
// No version.json forces vanilla fallback
Instance instance = new Instance("test-vanilla", dir);
instance.setMinecraftVersion("1.21");
instance.setLoaderType("vanilla");
instance.setAssetIndex("1.21");
return instance;
}
// ================================================================
// Forge tests
// ================================================================
@Test
void forge_noParentJvmArgs() throws Exception {
Instance instance = createForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Real Forge version.json has no -cp (and no ${classpath}), so the launcher MUST add
// -cp with version jar + all libraries for the JVM to find BootstrapLauncher.
int cpIdx = command.indexOf("-cp");
assertTrue(cpIdx >= 0, "Launcher must add -cp for Forge (version.json has none)");
assertTrue(cpIdx + 1 < command.size(), "-cp must have a value");
String classpath = command.get(cpIdx + 1);
assertNotNull(classpath, "-cp value must not be null");
// -Djava.library.path must be added manually (not in child args)
String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath();
assertTrue(command.contains(expectedLibPath), "Forge must have -Djava.library.path");
// Child's -p (module path) must be the fixed bootstrap list passed through unchanged
int pIndex = command.indexOf("-p");
assertTrue(pIndex >= 0, "Forge must have -p");
assertTrue(pIndex + 1 < command.size(), "-p must have a value");
String modulePath = command.get(pIndex + 1);
assertNotNull(modulePath, "-p value must not be null");
assertTrue(modulePath.contains("bootstraplauncher-1.1.2.jar"), "-p must contain bootstrap jar");
assertTrue(modulePath.contains("securejarhandler-2.1.10.jar"), "-p must contain bootstrap jar");
// ${classpath} must NOT be substituted / referenced in the real-json JVM args
assertFalse(classpath.contains("${classpath}"), "-cp must not contain unresolved ${classpath} placeholder");
// Child's --add-modules must be present (two-arg format)
int amIdx = command.indexOf("--add-modules");
assertTrue(amIdx >= 0, "Forge must have --add-modules");
assertTrue(amIdx + 1 < command.size(), "--add-modules must have a value");
assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1), "--add-modules must be ALL-MODULE-PATH");
// Memory/GC args
assertTrue(command.contains("-Xmx4096M"), "Forge must have memory args");
assertTrue(command.contains("-XX:+UseG1GC"), "Forge must have GC args");
// Main class
assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"), "Forge main class must be BootstrapLauncher");
// Game args: parent + child
int launchTargetIdx = command.indexOf("--launchTarget");
assertTrue(launchTargetIdx >= 0, "Game args must include child's --launchTarget");
assertEquals("forgeclient", command.get(launchTargetIdx + 1), "Launch target must be forgeclient");
int usernameIdx = command.indexOf("--username");
assertTrue(usernameIdx >= 0, "Game args must include parent's --username");
}
@Test
void forge_classpathContainsBothVanillaAndForgeLibraries() throws Exception {
Instance instance = createForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Find -cp's classpath value (launcher-added; version.json has no -cp)
int cpIdx = command.indexOf("-cp");
assertTrue(cpIdx >= 0, "Launcher must add -cp for Forge");
String cpValue = command.get(cpIdx + 1);
// Version jar from ensureVersionJarForForge
assertTrue(cpValue.contains("1.20.1-forge-47.3.0.jar") || cpValue.contains("1.20.1.jar"),
"Classpath must include version jar");
// Forge library
assertTrue(cpValue.contains("forge-1.20.1-47.3.0.jar"),
"Classpath must include Forge library");
// Vanilla library
assertTrue(cpValue.contains("client-1.20.1.jar"),
"Classpath must include vanilla client library");
}
@Test
void forge_noClasspathRef_buildsLegacyClassPath() throws Exception {
// Regression guard for the 1.0.14.x regression: the launcher must NOT inject the
// self-invented `-DlegacyClassPath` flag. Modern Forge/NeoForge derive the MC-BOOTSTRAP
// module layer from the module path / modlauncher classpath (the version.json `-p
// ${classpath}` placeholder, resolved by the launcher), NOT from `-DlegacyClassPath`
// (a 1.12-era mechanism that, when duplicated here, causes split-package / duplicate-
// entry failures and prevents the game from starting). This matches the 1.0.13
// behaviour and AstralRinth's real Forge launch, where the classpath is supplied
// through `${classpath}` substitution only.
String mcVer = "1.20.1";
String forgeVer = "47.3.0";
String versionId = mcVer + "-forge-" + forgeVer;
String vanillaJson = """
{
"id": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {"game": ["--username", "${auth_player_name}"]},
"assetIndex": {"id": "1.20.1"},
"libraries": [
{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}},
{"name": "com.google.guava:guava:31.1-jre", "downloads": {"artifact": {"path": "com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"}}}
]
}
""";
writeVersionJson(tempDir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
createJar(tempDir.resolve("versions/1.20.1/1.20.1.jar"));
createJar(tempDir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
createJar(tempDir.resolve("libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"));
// Realistic Forge child: references ${classpath} (serves as module path + classpath,
// exactly like AstralRinth / 1.0.13), plus the standard Forge bootstrap args.
String forgeJson = """
{
"id": "1.20.1-forge-47.3.0",
"inheritsFrom": "1.20.1",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"arguments": {
"jvm": ["-DlibraryDirectory=${library_directory}",
"-DignoreList=bootstraplauncher,forge-,${version_name}.jar",
"-p", "${classpath}",
"--add-modules", "ALL-MODULE-PATH"],
"game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0",
"--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge"]
},
"libraries": [
{"name": "cpw.mods:modlauncher:10.0.9", "downloads": {"artifact": {"path": "cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar"}}},
{"name": "net.minecraftforge:fmlloader:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/fmlloader/1.20.1-47.3.0/fmlloader-1.20.1-47.3.0.jar"}}}
]
}
""";
writeVersionJson(tempDir.resolve("versions/" + versionId + "/" + versionId + ".json"), forgeJson);
Files.createDirectories(tempDir.resolve("natives"));
createJar(tempDir.resolve("libraries/cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar"));
createJar(tempDir.resolve("libraries/net/minecraftforge/fmlloader/1.20.1-47.3.0/fmlloader-1.20.1-47.3.0.jar"));
Instance instance = new Instance("test-forge-realjson", tempDir);
instance.setMinecraftVersion(mcVer);
instance.setLoaderType("forge");
instance.setLoaderVersion(forgeVer);
instance.setAssetIndex(mcVer);
LaunchOptions options = new LaunchOptions();
List<String> command = new LaunchCommandBuilder(instance).build(options);
// No self-invented -DlegacyClassPath must ever be emitted for Forge.
assertFalse(command.stream().anyMatch(a -> a != null && a.startsWith("-DlegacyClassPath=")),
"Forge must NOT have a -DlegacyClassPath (1.0.13/AstralRinth rely on ${classpath})");
// ${classpath} must have been substituted into the -p module path, carrying all libs.
int pIdx = command.indexOf("-p");
assertTrue(pIdx >= 0, "Forge must have -p");
assertTrue(pIdx + 1 < command.size(), "-p must have a value");
String modulePath = command.get(pIdx + 1);
assertNotNull(modulePath, "-p value must not be null");
assertTrue(modulePath.contains("modlauncher-10.0.9.jar"),
"-p (resolved classpath) must contain modlauncher, got: " + modulePath);
assertTrue(modulePath.contains("fmlloader-1.20.1-47.3.0.jar"),
"-p (resolved classpath) must contain forge libraries, got: " + modulePath);
// version_name resolves to the version id (not the instance name) in -DignoreList.
String ignoreList = null;
for (String arg : command) {
if (arg != null && arg.startsWith("-DignoreList=")) ignoreList = arg;
}
assertNotNull(ignoreList, "Must have -DignoreList");
assertTrue(ignoreList.contains("1.20.1-forge-47.3.0.jar"),
"-DignoreList must use version id, got: " + ignoreList);
// --add-modules ALL-MODULE-PATH as two args (forge format)
int amIdx = command.indexOf("--add-modules");
assertTrue(amIdx >= 0, "Must have --add-modules");
assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1));
// Main class + game args
assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"));
assertTrue(command.contains("--launchTarget"));
}
@Test
void forge_versionNameIsVersionIdNotInstanceName() throws Exception {
Instance instance = createForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
List<String> command = new LaunchCommandBuilder(instance).build(options);
String ignoreList = null;
for (String arg : command) {
if (arg != null && arg.startsWith("-DignoreList=")) ignoreList = arg;
}
if (ignoreList != null) {
assertTrue(ignoreList.contains("1.20.1-forge-47.3.0.jar"),
"-DignoreList must use version id, got: " + ignoreList);
}
}
// ================================================================
// NeoForge tests
// ================================================================
@Test
void neoforge_noParentJvmArgs() throws Exception {
Instance instance = createNeoForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// NeoForge child jvm args reference ${classpath} (substituted into -p); version.json
// declares no -cp, so the launcher adds -cp with version jar + all libraries.
assertTrue(command.contains("-cp"), "NeoForge must have -cp (added by launcher)");
String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath();
assertTrue(command.contains(expectedLibPath), "NeoForge must have -Djava.library.path");
assertTrue(command.contains("-p"), "NeoForge must have -p");
assertTrue(command.contains("--add-modules=ALL-MODULE-PATH"), "NeoForge must have --add-modules=ALL-MODULE-PATH");
assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"), "NeoForge main class must be BootstrapLauncher");
// Game args: parent + child
int launchTargetIdx = command.indexOf("--launchTarget");
assertTrue(launchTargetIdx >= 0, "Game args must include child's --launchTarget");
assertEquals("neoforgeclient", command.get(launchTargetIdx + 1), "Launch target must be neoforgeclient");
assertTrue(command.contains("--username"), "Game args must include parent's --username");
}
// ================================================================
// Fabric tests
// ================================================================
@Test
void fabric_followsVanillaArgPattern() throws Exception {
Instance instance = createFabricFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Fabric uses -cp (vanilla pattern), not -p
assertTrue(command.contains("-cp"), "Fabric must have -cp");
assertFalse(command.contains("-p"), "Fabric must not have -p");
// -Djava.library.path present
String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath();
assertTrue(command.contains(expectedLibPath), "Fabric must have -Djava.library.path");
// Main class is KnotClient
assertTrue(command.contains("net.fabricmc.loader.impl.launch.knot.KnotClient"),
"Fabric main class must be KnotClient");
// Game args from version.json (merged with parent)
assertTrue(command.contains("--username"), "Fabric must have game args");
}
// ================================================================
// Vanilla tests
// ================================================================
@Test
void vanilla_usesVanillaArgs() throws Exception {
Instance instance = createVanillaFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Vanilla uses -cp
assertTrue(command.contains("-cp"), "Vanilla must have -cp");
assertFalse(command.contains("-p"), "Vanilla must not have -p");
// -Djava.library.path present
String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath();
assertTrue(command.contains(expectedLibPath), "Vanilla must have -Djava.library.path");
// Memory/GC args
assertTrue(command.contains("-Xmx4096M"), "Vanilla must have memory args");
assertTrue(command.contains("-XX:+UseG1GC"), "Vanilla must have GC args");
// Main class
assertTrue(command.contains("net.minecraft.client.main.Main"),
"Vanilla main class must be net.minecraft.client.main.Main");
// Game args
assertTrue(command.contains("--username"), "Vanilla must have --username");
}
// ================================================================
// Headless smoke tests run Java with constructed args to verify
// module path validity. No display needed, uses -version.
// ================================================================
@Test
void forge_modulePathIsValid_smoke() throws Exception {
Instance instance = createForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Extract -p (module path) and its value
int pIdx = command.indexOf("-p");
assertTrue(pIdx >= 0, "Forge must have -p");
String modulePath = command.get(pIdx + 1);
// Extract --add-modules, -Djava.library.path
String libPath = null;
for (String arg : command) {
if (arg.startsWith("-Djava.library.path=")) {
libPath = arg;
}
}
String addModules = null;
for (int i = 0; i < command.size(); i++) {
if ("--add-modules".equals(command.get(i)) && i + 1 < command.size()) {
addModules = command.get(i + 1);
}
}
assertNotNull(addModules, "Must have --add-modules");
assertEquals("ALL-MODULE-PATH", addModules, "--add-modules must be ALL-MODULE-PATH");
// Build minimal Java command that validates the module path
// java -Djava.library.path=<path> -p <classpath> --add-modules ALL-MODULE-PATH -version
ProcessBuilder pb = new ProcessBuilder(
"java",
libPath,
"-p", modulePath,
"--add-modules", addModules,
"-version"
);
pb.redirectErrorStream(true);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
assertTrue(exitCode == 0,
"Java should accept module path without errors. Exit code: " + exitCode
+ "\nOutput: " + output);
}
}
+20 -36
View File
@@ -6,7 +6,7 @@
<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>${revision}</version> <version>1.0.9</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<name>ZernMC Launcher Parent</name> <name>ZernMC Launcher Parent</name>
@@ -14,13 +14,10 @@
<modules> <modules>
<module>bootstrap</module> <module>bootstrap</module>
<module>diag</module>
<module>launcher</module> <module>launcher</module>
</modules> </modules>
<properties> <properties>
<revision>1.1.1</revision>
<hotfix>5</hotfix>
<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>
@@ -75,36 +72,31 @@
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>javafx-controls</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId> <artifactId>javafx-web</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId> <artifactId>javafx-graphics</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId> <artifactId>javafx-base</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency>
<groupId>dnsjava</groupId>
<artifactId>dnsjava</artifactId>
<version>3.6.5</version>
</dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId> <artifactId>javafx-media</artifactId>
<version>23.0.1</version> <version>21</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
</dependencies> </dependencies>
@@ -112,27 +104,6 @@
<build> <build>
<plugins> <plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>1.6.0</version>
<configuration>
<updatePomFile>true</updatePomFile>
<flattenMode>resolveCiFriendliesOnly</flattenMode>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals><goal>flatten</goal></goals>
</execution>
<execution>
<id>flatten-clean</id>
<phase>clean</phase>
<goals><goal>clean</goal></goals>
</execution>
</executions>
</plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
@@ -156,7 +127,7 @@
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass> <mainClass>${mainClass}</mainClass>
<manifestEntries> <manifestEntries>
<Implementation-Version>${project.version}.${hotfix}</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>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description> <Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
@@ -171,14 +142,27 @@
</plugins> </plugins>
</build> </build>
<profiles> <profiles>
<!-- ==================== GLOBAL ==================== -->
<profile> <profile>
<id>global</id> <id>global</id>
<activation> <activation>
<activeByDefault>true</activeByDefault> <activeByDefault>true</activeByDefault>
</activation> </activation>
<properties> <properties>
<build.profile>global</build.profile>
<launcher.title>ZernMC Launcher</launcher.title> <launcher.title>ZernMC Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url> <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> </properties>
</profile> </profile>
</profiles> </profiles>
-191
View File
@@ -1,191 +0,0 @@
#!/usr/bin/env python3
import subprocess, sys, os, json, time, signal
os.chdir(os.path.join(os.path.dirname(__file__), "server"))
workdir = os.path.join(os.path.dirname(__file__), "server")
# Start server
print("Starting server...", flush=True)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "main:app",
"--host", "127.0.0.1", "--port", "1590",
"--log-level", "warning"],
cwd=workdir,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
import httpx
for i in range(30):
try:
r = httpx.get("http://127.0.0.1:1590/news", timeout=2)
if r.status_code == 200:
print("Server ready!", flush=True)
break
except Exception as e:
if i == 0:
print(f"Waiting for server ({e})...", flush=True)
time.sleep(1)
else:
print("Server failed to start", flush=True)
proc.terminate()
proc.wait(timeout=5)
sys.exit(1)
passed = 0
failed = 0
def check(name, ok, detail=""):
global passed, failed
if ok:
print(f" PASS {name}")
passed += 1
else:
print(f" FAIL {name} {detail}")
failed += 1
BASE = "http://127.0.0.1:1590"
client = httpx.Client(base_url=BASE, timeout=10)
# ===== NEWS =====
print("\n=== NEWS ===")
r = client.get("/news")
check("GET /news status", r.status_code == 200, str(r.status_code))
data = r.json()
check("news has 'news' key", "news" in data)
check("news is array", isinstance(data.get("news"), list))
check("news has items", len(data["news"]) > 0, f"got {len(data['news'])} items")
if data.get("news"):
n = data["news"][0]
check("news item has title", "title" in n)
check("news item has body", "body" in n)
check("news item has type", "type" in n)
# ===== AUTH =====
print("\n=== AUTH ===")
ts = str(int(time.time()))
tu = f"test_{ts}"
r = client.post("/auth/register", json={"username": tu, "password": "pass123"})
check("register new user", r.status_code == 200, str(r.status_code))
reg = r.json()
check("register returns access_token", "access_token" in reg)
r2 = client.post("/auth/register", json={"username": tu, "password": "pass123"})
check("register existing returns 409", r2.status_code == 409, str(r2.status_code))
r3 = client.post("/auth/login", json={"username": tu, "password": "pass123"})
check("login valid", r3.status_code == 200, str(r3.status_code))
tok = r3.json().get("access_token", "")
if not tok: tok = reg.get("access_token", "")
check("login returns token", bool(tok))
r4 = client.post("/auth/login", json={"username": tu, "password": "wrongpass123"})
check("login wrong pass", r4.status_code == 401, str(r4.status_code))
r5 = client.post("/auth/login", json={"username": "nobody_valid", "password": "pass123456"})
check("login nonexistent", r5.status_code == 401, str(r5.status_code))
# ===== TOKEN VALIDATION =====
print("\n=== TOKEN ===")
ah = {"Authorization": f"Bearer {tok}"}
authed = httpx.Client(base_url=BASE, timeout=10, headers=ah)
r = authed.get("/auth/pass/my")
check("pass/my authed", r.status_code in (200, 404), str(r.status_code))
r2 = client.get("/auth/pass/my")
check("pass/my unauthed", r2.status_code == 401, str(r2.status_code))
# ===== PACKS =====
print("\n=== PACKS ===")
r = client.get("/packs")
check("packs unauthed rejects", r.status_code in (401, 403), str(r.status_code))
r2 = authed.get("/packs")
check("packs authed succeeds", r2.status_code in (200, 403), str(r2.status_code))
if r2.status_code == 200:
pdata = r2.json()
check("packs has 'packs' key", "packs" in pdata)
# ===== FRIENDS =====
print("\n=== FRIENDS ===")
r = client.get("/api/friends/list")
check("friends/list unauthed", r.status_code == 401, str(r.status_code))
r = authed.get("/api/friends/list")
check("friends/list authed", r.status_code == 200, str(r.status_code))
check("friends/list has friends key", "friends" in r.json())
r = authed.get("/api/friends/requests")
check("friends/requests authed", r.status_code == 200, str(r.status_code))
check("friends/requests has requests key", "requests" in r.json())
# Register friend user
fts = f"friend_{ts}"
r = client.post("/auth/register", json={"username": fts, "password": "pass123"})
check("register friend user", r.status_code == 200, str(r.status_code))
ftok = r.json().get("access_token", "")
fauthed = httpx.Client(base_url=BASE, timeout=10,
headers={"Authorization": f"Bearer {ftok}"})
# Parse user id from JWT (no account endpoint on Python server)
import base64
def parse_user_id(token):
try:
payload_b64 = token.split('.')[1]
payload_b64 += '=' * (4 - len(payload_b64) % 4)
return json.loads(base64.urlsafe_b64decode(payload_b64))["sub"]
except:
return 0
friend_id = parse_user_id(ftok)
my_id = parse_user_id(tok)
# Add friend
r = authed.post("/api/friends/add", json={"username": fts})
check("friends add", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
ad = r.json()
check("friends add returns message", "message" in ad)
# Duplicate add
r = authed.post("/api/friends/add", json={"username": fts})
check("friends add duplicate", r.status_code == 400, str(r.status_code))
# Add self
r = authed.post("/api/friends/add", json={"username": tu})
check("friends add self", r.status_code == 400, str(r.status_code))
# Add nonexistent
r = authed.post("/api/friends/add", json={"username": "nobody_xyz_123"})
check("friends add nonexistent", r.status_code == 404, str(r.status_code))
# Accept friend request (as friend)
r = fauthed.post("/api/friends/accept", json={"user_id": my_id})
check("friends accept", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
# Remove nonexistent
r = authed.post("/api/friends/remove", json={"user_id": 99999})
check("friends remove nonexistent", r.status_code == 404, str(r.status_code))
# Remove friend
r = authed.post("/api/friends/remove", json={"user_id": friend_id})
check("friends remove", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
# ===== PLAYTIME =====
print("\n=== PLAYTIME ===")
r = authed.post("/api/playtime/sync", json={"pack_name": "test_pack", "minutes": 30})
check("playtime sync", r.status_code == 200, f"{r.status_code}: {r.text[:80]}")
r = authed.get("/api/playtime/stats")
check("playtime stats", r.status_code == 200, str(r.status_code))
st = r.json()
check("playtime has total_minutes", "total_minutes" in st)
check("playtime has packs", "packs" in st)
r = client.get("/api/playtime/stats")
check("playtime stats unauthed", r.status_code == 401, str(r.status_code))
# ===== SUMMARY =====
total = passed + failed
print(f"\n{'='*40}")
print(f"RESULTS: {passed}/{total} PASSED, {failed}/{total} FAILED")
if failed > 0:
print("SOME TESTS FAILED!")
proc.terminate()
proc.wait(timeout=5)
sys.exit(1 if failed else 0)
+12 -388
View File
@@ -1,17 +1,13 @@
# admin_router.py # admin_router.py
from fastapi import APIRouter, HTTPException, Depends, Request, status, UploadFile, File from fastapi import APIRouter, HTTPException, Depends, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
import structlog import structlog
import time import time
import secrets import secrets
import hashlib
from datetime import datetime from datetime import datetime
from pathlib import Path
from pack_manager import PACKS_DIR, load_packs_config, get_pack_presets, toggle_pack_disabled from auth import get_db, require_role, log_audit, get_current_user
from auth import get_db, require_role, log_audit, get_current_user, hash_password
from roles import ( from roles import (
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions, ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
@@ -64,8 +60,8 @@ async def list_users(
query += " FROM users" query += " FROM users"
if search: if search:
query += " AND username LIKE ?" query += " AND (username LIKE ? OR email LIKE ?)"
params.append(f"%{search}%") params.extend([f"%{search}%", f"%{search}%"])
query += " ORDER BY role DESC, username" query += " ORDER BY role DESC, username"
@@ -112,13 +108,19 @@ async def get_user_detail(
"""Детальная информация о пользователе""" """Детальная информация о пользователе"""
with get_db() as conn: with get_db() as conn:
row = conn.execute(""" row = conn.execute("""
SELECT id, username, uuid, role, created_at, last_login, is_active, banned_until SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until
FROM users WHERE id = ? FROM users WHERE id = ?
""", (user_id,)).fetchone() """, (user_id,)).fetchone()
if not row: if not row:
raise HTTPException(404, "Пользователь не найден") raise HTTPException(404, "Пользователь не найден")
# Модераторы не видят email обычных пользователей
if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR:
email = None
else:
email = row["email"]
# Получаем активную проходку # Получаем активную проходку
pass_info = None pass_info = None
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER: if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
@@ -149,6 +151,7 @@ async def get_user_detail(
return { return {
"id": row["id"], "id": row["id"],
"username": row["username"], "username": row["username"],
"email": email,
"uuid": row["uuid"], "uuid": row["uuid"],
"role": row["role"], "role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"), "role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
@@ -552,385 +555,6 @@ async def get_admin_stats(
} }
# ====================== WHITELIST MODS ======================
WHITELIST_DIR = Path(__file__).parent / "whitelist"
WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt"
def _read_mods_list() -> list[dict]:
"""Parse mods.txt into list of {name, size, hash}"""
if not WHITELIST_MODS_FILE.exists():
return []
mods = []
for line in WHITELIST_MODS_FILE.read_text("utf-8").strip().split("\n"):
line = line.strip()
if not line:
continue
parts = line.split("::")
if len(parts) >= 3:
mods.append({
"name": parts[0].strip(),
"size": int(parts[1].strip()),
"hash": parts[2].strip()
})
return mods
def _write_mods_list(mods: list[dict]):
"""Write mods list to mods.txt"""
lines = [f"{m['name']} :: {m['size']} :: {m['hash']}" for m in mods]
WHITELIST_DIR.mkdir(parents=True, exist_ok=True)
WHITELIST_MODS_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
@router.get("/whitelist/mods")
async def admin_list_whitelist_mods(current_user: dict = Depends(require_role(ROLE_ELDER))):
"""List all whitelist mods (JSON)"""
return {"mods": _read_mods_list()}
@router.post("/whitelist/mods")
async def add_whitelist_mod(
file: UploadFile = File(...),
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Upload a mod file — auto-computes hash, records size, adds to whitelist"""
if not file.filename or not file.filename.endswith(".jar"):
raise HTTPException(400, "Only .jar files are allowed")
contents = await file.read()
if len(contents) == 0:
raise HTTPException(400, "Empty file")
file_hash = hashlib.sha256(contents).hexdigest()
file_size = len(contents)
WHITELIST_DIR.mkdir(parents=True, exist_ok=True)
mod_path = WHITELIST_DIR / file_hash
if mod_path.exists():
raise HTTPException(409, f"Mod with hash {file_hash} already exists")
mod_path.write_bytes(contents)
mods = _read_mods_list()
mods.append({"name": file.filename, "size": file_size, "hash": file_hash})
_write_mods_list(mods)
ip = request.client.host if request.client else "unknown"
log_audit(current_user["id"], "whitelist_add", f"Added {file.filename} ({file_hash})", ip)
return {"success": True, "name": file.filename, "size": file_size, "hash": file_hash}
@router.delete("/whitelist/mods/{mod_hash}")
async def remove_whitelist_mod(
mod_hash: str,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Remove a mod from whitelist by hash"""
if ".." in mod_hash:
raise HTTPException(400, "Invalid hash")
mod_path = WHITELIST_DIR / mod_hash
deleted_file = False
if mod_path.exists() and mod_path.is_file():
mod_path.unlink()
deleted_file = True
mods = _read_mods_list()
filtered = [m for m in mods if m["hash"] != mod_hash]
if len(filtered) == len(mods) and not deleted_file:
raise HTTPException(404, "Mod not found")
_write_mods_list(filtered)
ip = request.client.host if request.client else "unknown"
log_audit(current_user["id"], "whitelist_remove", f"Removed mod with hash {mod_hash}", ip)
return {"success": True, "hash": mod_hash, "file_deleted": deleted_file}
# ====================== ADMIN CLIENTS, USERS, PASSES ======================
@router.get("/clients")
async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
"""List online clients"""
with get_db() as conn:
rows = conn.execute("""
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address
FROM users u
LEFT JOIN user_status us ON u.id = us.user_id
LEFT JOIN user_sessions s ON u.id = s.user_id AND s.is_active = 1
WHERE us.is_online = 1
GROUP BY u.id
ORDER BY us.last_seen DESC
""").fetchall()
clients = []
for row in rows:
clients.append({
"id": row["id"],
"username": row["username"],
"online": bool(row["is_online"]),
"current_pack": row["current_pack"] or "",
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
"ip": row["ip_address"] or ""
})
return {"success": True, "clients": clients}
class UserSearchRequest(BaseModel):
search: str
@router.post("/users/search")
async def admin_search_users(
req: UserSearchRequest,
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
):
"""Search users by username"""
if not req.search.strip():
return {"users": []}
with get_db() as conn:
rows = conn.execute("""
SELECT id, username, uuid, role, created_at, last_login, is_active
FROM users WHERE username LIKE ?
ORDER BY username LIMIT 20
""", (f"%{req.search.strip()}%",)).fetchall()
users = []
for row in rows:
users.append({
"id": row["id"],
"username": row["username"],
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Unknown"),
"created_at": row["created_at"],
"last_login": row["last_login"],
"is_active": bool(row["is_active"])
})
return {"success": True, "users": users}
class RoleUpdateRequest(BaseModel):
user_id: int
role: int = Field(..., ge=0, le=4)
username: Optional[str] = None
@router.post("/users/role")
async def admin_update_user_role(
body: RoleUpdateRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Update user role"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE id = ?",
(body.user_id,)
).fetchone()
if not target:
raise HTTPException(404, "User not found")
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Cannot change creator role")
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Cannot modify equal or higher role")
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Cannot assign role above your own")
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Only creator can assign Elder")
old_role = target["role"]
conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, body.user_id))
conn.commit()
log_audit(current_user["id"], "role_change",
f"Changed role of {target['username']} from {old_role} to {body.role}", ip)
return {"success": True, "user_id": body.user_id, "username": target["username"],
"old_role": old_role, "new_role": body.role}
class ResetPasswordRequest(BaseModel):
user_id: int
username: Optional[str] = None
@router.post("/users/reset-password")
async def admin_reset_password(
body: ResetPasswordRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Reset user password"""
ip = request.client.host if request.client else "unknown"
new_password = secrets.token_hex(8)
pw_hash = hash_password(new_password)
with get_db() as conn:
target = conn.execute(
"SELECT id, username FROM users WHERE id = ?",
(body.user_id,)
).fetchone()
if not target:
raise HTTPException(404, "User not found")
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?",
(pw_hash, body.user_id))
conn.commit()
log_audit(current_user["id"], "password_reset",
f"Reset password for {target['username']}", ip)
return {"success": True, "new_password": new_password, "username": target["username"]}
@router.get("/passes")
async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
"""List all passes"""
with get_db() as conn:
rows = conn.execute("""
SELECT p.code, p.is_active, p.owner, p.activated_by, p.activated_at,
p.expires_at, p.max_uses, p.uses, u.username as activated_username
FROM passes p
LEFT JOIN users u ON p.activated_by = u.id
ORDER BY p.activated_at DESC
""").fetchall()
passes = []
for row in rows:
passes.append({
"code": row["code"],
"owner": row["owner"],
"is_active": bool(row["is_active"]),
"activated_by": row["activated_by"],
"activated_username": row["activated_username"],
"activated_at": row["activated_at"],
"expires_at": row["expires_at"],
"max_uses": row["max_uses"],
"uses": row["uses"]
})
return {"success": True, "passes": passes}
@router.post("/whitelist/mods/{mod_hash}/delete")
async def admin_delete_whitelist_mod_adapter(
mod_hash: str,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Delete whitelist mod (adapter for POST-based deletion)"""
return await remove_whitelist_mod(mod_hash, current_user, request)
# ====================== ADMIN NEWS ======================
ADMIN_NEWS_DIR = Path(__file__).parent / "news"
class CreateNewsRequest(BaseModel):
title: str
type: str = "Update"
version: str = ""
body: str
class DeleteNewsRequest(BaseModel):
index: int
@router.post("/news")
async def admin_create_news(
body: CreateNewsRequest,
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
request: Request = None,
):
"""Create a news item"""
if not body.title.strip() or not body.body.strip():
raise HTTPException(400, "Title and body are required")
ADMIN_NEWS_DIR.mkdir(parents=True, exist_ok=True)
news_id = str(int(time.time()))
file_path = ADMIN_NEWS_DIR / f"{news_id}.txt"
content = f"{body.title.strip()}\n{body.type.strip()}\n{body.version.strip()}\n{body.body.strip()}"
file_path.write_text(content, encoding="utf-8")
ip = request.client.host if request.client else "unknown"
log_audit(current_user["id"], "news_create", f"Created news: {body.title}", ip)
return {"success": True, "id": news_id}
@router.post("/news/delete")
async def admin_delete_news(
body: DeleteNewsRequest,
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
request: Request = None,
):
"""Delete a news item by displayed index (0 = newest)"""
if not ADMIN_NEWS_DIR.exists():
raise HTTPException(404, "No news to delete")
files = sorted(ADMIN_NEWS_DIR.iterdir())
news_files = [f for f in files if f.is_file() and f.suffix == ".txt"]
if body.index < 0 or body.index >= len(news_files):
raise HTTPException(404, "News not found")
# Displayed list is reversed (newest first), so map index
target = news_files[len(news_files) - 1 - body.index]
target.unlink()
ip = request.client.host if request.client else "unknown"
log_audit(current_user["id"], "news_delete", f"Deleted news: {target.stem}", ip)
return {"success": True, "id": target.stem}
# ====================== PACK MANAGEMENT (ELDER+) ======================
@router.get("/packs")
async def admin_list_packs(
current_user: dict = Depends(require_role(ROLE_ELDER)),
):
"""List all packs with disabled status"""
packs = []
packs_config = load_packs_config()
packs_dir = Path("packs")
if packs_dir.exists():
for pack_dir in packs_dir.iterdir():
if pack_dir.is_dir():
entry = packs_config.get(pack_dir.name, {})
packs.append({
"name": pack_dir.name,
"disabled": entry.get("disabled", False),
"presets": get_pack_presets(pack_dir.name)
})
return {"packs": packs}
@router.post("/packs/{pack_name}/toggle")
async def admin_toggle_pack(
pack_name: str,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Toggle pack disabled/enabled status"""
pack_path = PACKS_DIR / pack_name
if not pack_path.exists() or not pack_path.is_dir():
raise HTTPException(404, "Pack not found")
new_state = toggle_pack_disabled(pack_name)
ip = request.client.host if request.client else "unknown"
log_audit(
current_user["id"],
"pack_toggle",
f"{'Disabled' if new_state else 'Enabled'} pack {pack_name}",
ip
)
return {
"success": True,
"pack_name": pack_name,
"disabled": new_state
}
@router.get("/me") @router.get("/me")
async def get_my_info(current_user: dict = Depends(get_current_user)): async def get_my_info(current_user: dict = Depends(get_current_user)):
"""Информация о текущем пользователе с правами""" """Информация о текущем пользователе с правами"""
+2 -23
View File
@@ -295,7 +295,7 @@ class LoginRequest(BaseModel):
def validate_username(cls, v): def validate_username(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v): if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания') raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
return v return v.lower()
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=32) username: str = Field(..., min_length=3, max_length=32)
@@ -305,7 +305,7 @@ class RegisterRequest(BaseModel):
def validate_username(cls, v): def validate_username(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v): if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания') raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
return v return v.lower()
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
@@ -383,7 +383,6 @@ async def register(body: RegisterRequest, request: Request):
allowed, wait = check_rate_limit(ip) allowed, wait = check_rate_limit(ip)
if not allowed: if not allowed:
logger.warning("register rate limited", username=body.username, client_ip=ip)
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn: with get_db() as conn:
@@ -393,7 +392,6 @@ async def register(body: RegisterRequest, request: Request):
).fetchone() ).fetchone()
if existing: if existing:
logger.warning("register failed: username taken", username=body.username, client_ip=ip)
raise HTTPException(409, "Пользователь с таким именем уже существует") raise HTTPException(409, "Пользователь с таким именем уже существует")
uuid = generate_uuid() uuid = generate_uuid()
@@ -407,7 +405,6 @@ async def register(body: RegisterRequest, request: Request):
) )
user_id = cursor.lastrowid user_id = cursor.lastrowid
logger.info("register ok", username=body.username, user_id=user_id, client_ip=ip)
# Создаем сессию # Создаем сессию
session_token = secrets.token_urlsafe(32) session_token = secrets.token_urlsafe(32)
@@ -459,7 +456,6 @@ async def login(body: LoginRequest, request: Request):
allowed, wait = check_rate_limit(ip) allowed, wait = check_rate_limit(ip)
if not allowed: if not allowed:
logger.warning("login rate limited", username=body.username, client_ip=ip)
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn: with get_db() as conn:
@@ -469,20 +465,15 @@ async def login(body: LoginRequest, request: Request):
).fetchone() ).fetchone()
if not user or not verify_password(body.password, user["password_hash"]): if not user or not verify_password(body.password, user["password_hash"]):
logger.warning("login failed: bad credentials", username=body.username, client_ip=ip)
record_login_attempt(ip, False) record_login_attempt(ip, False)
raise HTTPException(401, "Неверное имя пользователя или пароль") raise HTTPException(401, "Неверное имя пользователя или пароль")
if not user["is_active"]: if not user["is_active"]:
logger.warning("login failed: account deactivated", username=body.username, client_ip=ip)
raise HTTPException(403, "Аккаунт деактивирован") raise HTTPException(403, "Аккаунт деактивирован")
if user["banned_until"] and user["banned_until"] > time.time(): if user["banned_until"] and user["banned_until"] > time.time():
logger.warning("login failed: account banned", username=body.username, client_ip=ip)
raise HTTPException(403, "Аккаунт забанен") raise HTTPException(403, "Аккаунт забанен")
logger.info("login ok", username=user["username"], user_id=user["id"], client_ip=ip)
record_login_attempt(ip, True) record_login_attempt(ip, True)
now = time.time() now = time.time()
@@ -779,15 +770,3 @@ async def activate_pass(
"message": f"Проходка активирована для {uname}", "message": f"Проходка активирована для {uname}",
"role": 1, "role": 1,
} }
@router.get("/pass/my")
async def my_pass_status(current_user: dict = Depends(get_current_user)):
"""Check if current user has an active pass"""
with get_db() as conn:
row = conn.execute("""
SELECT 1 FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (current_user["id"], time.time())).fetchone()
return {"has_active": row is not None}
+1 -1
View File
@@ -23,7 +23,7 @@ def parse_args():
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("--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_known_args()[0] return parser.parse_args()
async def run_test_mode(): async def run_test_mode():
"""Validate all packs and generate/update manifests""" """Validate all packs and generate/update manifests"""
-176
View File
@@ -1,176 +0,0 @@
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"}
+215 -835
View File
File diff suppressed because it is too large Load Diff
+12 -18
View File
@@ -154,30 +154,24 @@ class LoggingMiddleware(BaseHTTPMiddleware):
_stats["blocked"] += 1 _stats["blocked"] += 1
return Response(status_code=404, content="") return Response(status_code=404, content="")
path = request.url.path # Check rate limit
if not check_rate_limit(client_ip):
# Skip rate limiting for pack file downloads (direct high-speed serving) _stats["rate_limited"] += 1
if path.startswith("/pack/") and "/file/" in path: # Periodic stats logging instead of every warning
is_file_download = True if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
elif path.startswith(("/skin/", "/css/", "/js/", "/img/", "/highlights/")): logger.warning(f"Stats: {_stats}")
# SkinRestorer skins + landing site assets must not be rate-limited _stats_last_log = time.time()
is_file_download = True return Response(status_code=429, content="Too many requests")
else:
is_file_download = False
# Check rate limit
if not check_rate_limit(client_ip):
_stats["rate_limited"] += 1
if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
logger.warning(f"Stats: {_stats}")
_stats_last_log = time.time()
return Response(status_code=429, content="Too many requests")
# Check suspicious path (silent 404 for bots) # Check suspicious path (silent 404 for bots)
path = request.url.path
if is_suspicious_path(path): if is_suspicious_path(path):
# Return 404 without logging - confuse the bots # Return 404 without logging - confuse the bots
return Response(status_code=404, content="") 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 # Track total requests for stats
_stats["total"] += 1 _stats["total"] += 1
-10
View File
@@ -1,10 +0,0 @@
Pack Download Stability Fix
Update
v1.0.13.1
[{"bold":1,"text":"Fixed pack downloads getting stuck on Minecraft/Forge installation","click_action":"none"}]
- All Minecraft downloads now use smart proxy system (like the rest of the launcher)
- Added read timeouts to prevent hanging on slow/unstable connections
- Asset download no longer blocks forever if Mojang CDN stalls
- Forge installer process now has a 10-minute timeout
- Progress bar now updates during Minecraft installation phase
[{"clickable":1,"text":"Download v1.0.13.1","click_action":"open_url","url":"https://zernmc.ru/download"}]
+16 -116
View File
@@ -1,13 +1,10 @@
import hashlib import hashlib
import os import os
import zipfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import json import json
from typing import Optional, Dict, List from typing import Optional, Dict
import structlog import structlog
import asyncio
import aiofiles
from models import PackMeta, FileEntry from models import PackMeta, FileEntry
@@ -20,46 +17,6 @@ DATA_DIR.mkdir(exist_ok=True)
# Cache for loaded manifests # Cache for loaded manifests
_manifest_cache: Dict[str, PackMeta] = {} _manifest_cache: Dict[str, PackMeta] = {}
# Pack config cache
_packs_config: Optional[Dict] = None
def load_packs_config() -> dict:
global _packs_config
if _packs_config is not None:
return _packs_config
config_path = DATA_DIR / "packs_config.json"
if config_path.exists():
try:
_packs_config = json.loads(config_path.read_text())
except Exception:
_packs_config = {}
else:
_packs_config = {}
return _packs_config
def save_packs_config(config: dict):
global _packs_config
_packs_config = config
config_path = DATA_DIR / "packs_config.json"
config_path.write_text(json.dumps(config, indent=2))
def is_pack_disabled(pack_name: str) -> bool:
config = load_packs_config()
return config.get(pack_name, {}).get("disabled", False)
def toggle_pack_disabled(pack_name: str) -> bool:
config = load_packs_config()
entry = config.setdefault(pack_name, {})
entry["disabled"] = not entry.get("disabled", False)
save_packs_config(config)
return entry["disabled"]
def get_pack_presets(pack_name: str) -> List[str]:
presets_dir = PACKS_DIR / pack_name / "presets"
if not presets_dir.exists() or not presets_dir.is_dir():
return []
return sorted([d.name for d in presets_dir.iterdir() if d.is_dir()])
def get_cached_manifest(pack_name: str) -> Optional[PackMeta]: def get_cached_manifest(pack_name: str) -> Optional[PackMeta]:
"""Get manifest from memory cache if available""" """Get manifest from memory cache if available"""
return _manifest_cache.get(pack_name) return _manifest_cache.get(pack_name)
@@ -76,9 +33,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)""" """Calculate SHA256 hash of a file (async wrapper)"""
loop = asyncio.get_running_loop() # Используем синхронную версию для простоты
return await loop.run_in_executor(None, calculate_sha256_sync, file_path) return 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"""
@@ -94,11 +51,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:
async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f: with open(meta_path, 'r', encoding='utf-8') as f:
data = json.loads(await f.read()) data = json.load(f)
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}")
@@ -107,11 +64,10 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
new_files: Dict[str, FileEntry] = {} new_files: Dict[str, FileEntry] = {}
changed = False changed = False
# Get ignored directories (always use current list, not cached from meta) # Get ignored directories
ignored_dirs = [ ignored_dirs = current_meta.ignored_dirs if current_meta else [
"resourcepacks", "shaderpacks", "saves", "logs", "resourcepacks", "shaderpacks", "saves", "logs",
"crash-reports", "screenshots", "journeymap", "crash-reports", "screenshots", "journeymap", "config"
"presets"
] ]
# Walk through pack directory # Walk through pack directory
@@ -123,19 +79,10 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
file_path = Path(root) / file file_path = Path(root) / file
rel_path = file_path.relative_to(pack_path).as_posix() rel_path = file_path.relative_to(pack_path).as_posix()
# Skip metadata files that should not be part of the pack
if file in ("description.txt", "instance.json"):
continue
# Skip if in ignored directory # Skip if in ignored directory
if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs): if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs):
continue continue
# Validate .jar files — skip corrupted ones
if file.endswith(".jar") and not _is_valid_zip(file_path):
logger.warning(f"Skipping corrupt jar in pack {pack_name}: {rel_path}")
continue
stat = file_path.stat() stat = file_path.stat()
file_hash = await calculate_sha256(file_path) file_hash = await calculate_sha256(file_path)
@@ -167,8 +114,9 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
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: # Синхронное чтение конфига
config = json.loads(await f.read()) with open(pack_config_path, 'r', encoding='utf-8') as f:
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")
@@ -189,8 +137,9 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
asset_index=asset_index asset_index=asset_index
) )
async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f: # Save to disk (синхронно)
await f.write(new_meta.model_dump_json(indent=2)) with open(meta_path, 'w', encoding='utf-8') as f:
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
@@ -205,52 +154,3 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
# Should not happen # Should not happen
raise Exception(f"Failed to scan pack {pack_name}") raise Exception(f"Failed to scan pack {pack_name}")
def _is_valid_zip(path: Path) -> bool:
"""Check if a file is a valid ZIP archive"""
try:
with zipfile.ZipFile(path, 'r') as zf:
return zf.testzip() is None
except zipfile.BadZipFile:
return False
except Exception:
return False
def get_merged_manifest(pack_name: str, preset: str) -> PackMeta:
"""Get manifest merged with preset files. Preset files override base files."""
base_meta = get_cached_manifest(pack_name)
if not base_meta:
raise FileNotFoundError(f"Pack {pack_name} not found")
preset_dir = PACKS_DIR / pack_name / "presets" / preset
if not preset_dir.exists() or not preset_dir.is_dir():
raise FileNotFoundError(f"Preset {preset} not found for pack {pack_name}")
merged_files = dict(base_meta.files)
for root, dirs, files in os.walk(preset_dir):
for file in files:
file_path = Path(root) / file
rel_path = file_path.relative_to(preset_dir).as_posix()
stat = file_path.stat()
file_hash = calculate_sha256_sync(file_path)
merged_files[rel_path] = FileEntry(
path=rel_path,
hash=file_hash,
size=stat.st_size,
added_at=datetime.utcfromtimestamp(stat.st_ctime),
modified_at=datetime.utcfromtimestamp(stat.st_mtime)
)
return PackMeta(
pack_name=base_meta.pack_name,
version=base_meta.version,
files=merged_files,
updated_at=base_meta.updated_at,
ignored_dirs=base_meta.ignored_dirs,
minecraft_version=base_meta.minecraft_version,
loader_type=base_meta.loader_type,
loader_version=base_meta.loader_version,
asset_index=base_meta.asset_index
)
-80
View File
@@ -1,80 +0,0 @@
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["id"], req.pack_name, req.minutes)
)
logger.info("Playtime synced", user=current_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["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["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
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

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

Before

Width:  |  Height:  |  Size: 423 B

-582
View File
@@ -1,582 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zern — Awaken from the cold</title>
<meta name="description" content="Zern — a custom Minecraft launcher. Forge, NeoForge, Fabric. Download and play on ZernMC.">
<link rel="icon" type="image/svg+xml" href="/img/zernmc.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav" id="nav">
<div class="nav-inner">
<a class="nav-brand" href="#top">
<img src="/img/zernmc.svg" alt="Zern" width="34" height="34">
<span>Zern</span>
</a>
<ul class="nav-links">
<li><a href="#server" data-i18n="nav.server">Сервер</a></li>
<li><a href="#highlights" data-i18n="nav.highlights">Хайлайты</a></li>
<li><a href="#theme" data-i18n="nav.season">Сезон</a></li>
<li><a href="#features" data-i18n="nav.features">Возможности</a></li>
<li><a href="#showcase" data-i18n="nav.showcase">Лаунчер</a></li>
<li><a href="#download" data-i18n="nav.download">Скачать</a></li>
<li><a href="#news" data-i18n="nav.news">Новости</a></li>
</ul>
<div class="nav-right">
<button class="lang-toggle" id="langToggle" type="button" aria-label="Switch language">
<span class="lang-toggle-label" id="langToggleLabel">EN</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</button>
<button class="btn btn-ghost" id="auth-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.login">Войти</button>
<div id="user-menu" class="hidden" style="display:flex;align-items:center;gap:8px">
<span id="user-name" style="font-weight:600;font-size:13px;color:var(--text)"></span>
<button class="btn btn-ghost" id="cabinet-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.cabinet">Кабинет</button>
<button class="btn btn-ghost" id="logout-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.logout">Выйти</button>
</div>
<a class="nav-cta" href="#download" data-i18n="nav.play">Играть сейчас</a>
</div>
</div>
</nav>
<header class="hero" id="top">
<div class="hero-grid" aria-hidden="true"></div>
<div class="hero-glow" aria-hidden="true"></div>
<div class="hero-diamond hero-diamond--1" aria-hidden="true"></div>
<div class="hero-diamond hero-diamond--2" aria-hidden="true"></div>
<div class="hero-diamond hero-diamond--3" aria-hidden="true"></div>
<div class="hero-inner" data-parallax="0.15">
<p class="hero-kicker" data-i18n="hero.kicker">КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР</p>
<h1 class="hero-title" data-i18n-html="hero.title">Очнись от<br><span class="hero-title-accent">холода.</span></h1>
<p class="hero-sub" data-i18n="hero.sub">Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#download">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<span data-i18n="hero.download">Скачать лаунчер</span>
</a>
<a class="btn btn-ghost" href="#features" data-i18n="hero.explore">Обзор</a>
</div>
<p class="hero-meta">v<span id="hero-version"></span> · <span id="hero-size"></span> · Windows</p>
</div>
<div class="scroll-hint" data-parallax="0.3">
<span data-i18n="hero.scroll">Листай вниз</span>
<div class="scroll-hint-line"><i></i></div>
</div>
</header>
<main>
<section class="section server" id="server">
<div class="section-head">
<span class="section-index">01 / 07</span>
<h2 class="section-title" data-i18n-html="server.title">Сервер <span class="accent">жив.</span></h2>
<p class="section-sub" data-i18n="server.sub">ZernMC — это кастомный Minecraft-сервер со своим лором, самописными плагинами и живым комьюнити. Вот что происходит прямо сейчас.</p>
</div>
<div class="server-stats">
<div class="server-card">
<div class="server-card-label" data-i18n="server.status">Статус</div>
<div class="server-card-value" id="srv-status"><span class="status-dot status-dot--loading"></span> <span data-i18n="server.checking">Проверяем…</span></div>
<div class="server-card-sub" id="srv-version">1.20.1 / ОБТ</div>
</div>
<div class="server-card">
<div class="server-card-label" data-i18n="server.players">Игроков онлайн</div>
<div class="server-card-value" id="srv-players">— / —</div>
<div class="server-card-sub">mc.zernmc.ru</div>
</div>
<div class="server-card">
<div class="server-card-label" data-i18n="server.mode">Режим</div>
<div class="server-card-value">Survival <span class="server-season">· <span data-i18n="server.season">Сезон Prologue</span></span></div>
<div class="server-card-sub">1.21.1 NeoForge · Create: Aeronautics</div>
</div>
</div>
</section>
<section class="section highlights" id="highlights">
<div class="section-head">
<span class="section-index">02 / 07</span>
<h2 class="section-title" data-i18n-html="highlights.title">Хайлайты <span class="accent">сервера.</span></h2>
<p class="section-sub" data-i18n="highlights.sub">Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.</p>
</div>
<div class="carousel">
<div class="carousel-track" id="carousel"></div>
<button class="carousel-btn carousel-btn--prev" id="carouselPrev" data-i18n-aria="carousel.prev"></button>
<button class="carousel-btn carousel-btn--next" id="carouselNext" data-i18n-aria="carousel.next"></button>
<div class="carousel-dots" id="carouselDots"></div>
</div>
</section>
<section class="section features" id="features">
<div class="section-head">
<span class="section-index">03 / 07</span>
<h2 class="section-title" data-i18n-html="features.title">Всё, что <span class="accent">тебе нужно.</span></h2>
<p class="section-sub" data-i18n="features.sub">Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.</p>
</div>
<div class="features-grid">
<article class="feature-card">
<span class="feature-num">01</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</div>
<h3 data-i18n="features.forge.title">Forge, NeoForge, Fabric</h3>
<p data-i18n="features.forge.sub">Все основные модлоадеры подхватываются автоматически. Библиотеки предзагружаются и проверяются, так что установка работает — даже на медленном интернете.</p>
</article>
<article class="feature-card">
<span class="feature-num">02</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
</div>
<h3 data-i18n="features.ui.title">Тёмный, быстрый нативный UI</h3>
<p data-i18n="features.ui.sub">Лёгкий интерфейс на JavaFX с чистым тёмным оформлением. Быстро запускается, мгновенно реагирует и не мешает.</p>
</article>
<article class="feature-card">
<span class="feature-num">03</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 18H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M9 22v-4"/><path d="M15 22v-4"/></svg>
</div>
<h3 data-i18n="features.proxy.title">Умные зеркала и прокси</h3>
<p data-i18n="features.proxy.sub">Загрузки идут через быстрые зеркала с автоматическим фолбэком. Заблокировали или зарезали скорость? Прокси подхватит — ты больше не зависнешь на 80%.</p>
</article>
<article class="feature-card">
<span class="feature-num">04</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 0 0-9 14l2 5 5-2A10 10 0 1 0 12 2z"/><path d="M8.5 12h.01"/><path d="M12 12h.01"/><path d="M15.5 12h.01"/></svg>
</div>
<h3 data-i18n="features.news.title">Новости и статус сервера</h3>
<p data-i18n="features.news.sub">Чейнджлоги, ивенты и обновления приходят прямо в лаунчер. Ты всегда знаешь, что нового, ещё до нажатия «Играть».</p>
</article>
<article class="feature-card">
<span class="feature-num">05</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<h3 data-i18n="features.pack.title">Установка сборки в один клик</h3>
<p data-i18n="features.pack.sub">Выбери сборку и жми «Установить». Манифесты версий, ванильные ассеты и библиотеки подтягиваются и проверяются автоматически. Просто.</p>
</article>
</div>
</section>
<section class="section showcase" id="showcase">
<div class="section-head section-head--center">
<span class="section-index">04 / 07</span>
<h2 class="section-title" data-i18n-html="showcase.title">Показываем <span class="accent">лаунчер</span></h2>
<p class="section-sub" data-i18n="showcase.sub">Никаких статичных скриншотов — живой тур по интерфейсу. Нужно просто наблюдать: лаунчер сам открывает свои экраны.</p>
</div>
<div class="launcher-preview" id="launcherPreview" role="img" aria-label="ZernMC Launcher preview, auto-rotating screens" data-i18n-aria="preview.aria">
<div class="launcher-window">
<div class="launcher-titlebar">
<div class="win-dots" aria-hidden="true"><span></span><span></span><span></span></div>
<span class="launcher-titlebar-name">
<svg class="win-icon" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
ZernMC Launcher <span id="preview-version"></span>
</span>
</div>
<div class="launcher-preview-body" id="launcherPreviewBody">
<div class="preview-scene scene-login is-active" data-key="login">
<div class="scene-login-inner">
<div class="pv-brand">
<svg width="40" height="40" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
</div>
<h3 class="pv-title" data-i18n="preview.signIn">Вход</h3>
<div class="pv-field"><label data-i18n="preview.username">Логин</label><div class="pv-input"></div></div>
<div class="pv-field"><label data-i18n="preview.password">Пароль</label><div class="pv-input"></div></div>
<div class="pv-btn" data-i18n="preview.signIn">Вход</div>
</div>
</div>
<div class="preview-scene scene-packs" data-key="packs">
<div class="pv-sidebar">
<div class="pv-sb-brand">
<svg width="24" height="24" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
<span>ZernMC<em>v<span class="pv-sb-ver">2.3.1</span></em></span>
</div>
<div class="pv-sb-nav">
<span class="pv-nav-item is-active" title="Packs">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
<span data-i18n="preview.nav.packs">Сборки</span>
</span>
<span class="pv-nav-item" title="News">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
<span data-i18n="preview.nav.news">Новости</span>
</span>
<span class="pv-nav-item" title="Friends">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="preview.nav.friends">Друзья</span>
</span>
</div>
<div class="pv-sb-section">
<span class="pv-sb-section-title" data-i18n="preview.serverPacks">Серверные сборки</span>
<div class="pv-pack-item">
<span class="pv-pack-name">Zern-OBT</span>
<span class="pv-pack-meta">1.20.1 · forge</span>
</div>
<div class="pv-pack-item is-active">
<span class="pv-pack-name">Zern-Prologue</span>
<span class="pv-pack-meta">1.21.1 · neoforge</span>
</div>
</div>
<div class="pv-sb-section">
<span class="pv-sb-section-title" data-i18n="preview.localPacks">Локальные сборки</span>
<div class="pv-pack-item">
<span class="pv-pack-name">Vanilla</span>
<span class="pv-pack-meta">1.20.1</span>
</div>
</div>
<div class="pv-sb-user">
<span class="pv-avatar">Z</span>
<span class="pv-user-meta"><em>zernplayer</em><i class="pv-badge">FREE</i></span>
<svg class="pv-sb-action" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<svg class="pv-sb-action" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</div>
</div>
<div class="pv-main">
<div class="pv-main-head">
<div>
<h3>Zern-Prologue</h3>
<div class="pv-tags"><span class="pv-tag pv-tag-mc">1.21.1</span><span class="pv-tag">neoforge</span></div>
</div>
<div class="pv-stats">
<span><b>47.4</b><i data-i18n="preview.stat.loader">Загрузчик</i></span>
<span><b>130</b><i data-i18n="preview.stat.files">Файлы</i></span>
<span><b>1.8 ГБ</b><i data-i18n="preview.stat.size">Размер</i></span>
</div>
</div>
<div class="pv-gallery" aria-hidden="true">
<span class="pv-gallery-item pv-gallery-item--1"></span>
<span class="pv-gallery-item pv-gallery-item--2"></span>
<span class="pv-gallery-item pv-gallery-item--3"></span>
</div>
<div class="pv-playbar">
<span>Zern-Prologue · <em data-i18n="preview.ready">Готов к запуску</em></span>
<span class="pv-play">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<i data-i18n="preview.play">Играть</i>
</span>
</div>
</div>
</div>
<div class="preview-scene scene-news" data-key="news">
<div class="pv-panel">
<h3 class="pv-view-title" data-i18n="preview.nav.news">Новости</h3>
<div class="pv-news-item">
<span class="pv-news-tag pv-tag-update" data-i18n="preview.news.update">Обновление</span>
<b data-i18n="preview.news1.title">Сезон Prologue запущен</b>
<p data-i18n="preview.news1.body">Небо принадлежит дирижаблям. Добро пожаловать в Prologue — 1.21.1 NeoForge, Create: Aeronautics.</p>
</div>
<div class="pv-news-item">
<span class="pv-news-tag pv-tag-event" data-i18n="preview.news.event">Событие</span>
<b data-i18n="preview.news2.title">Война в Заре</b>
<p data-i18n="preview.news2.body">«Феникс» атакует, «Ласточка» защищает. Жители выбирают сторону — или бунт.</p>
</div>
</div>
</div>
<div class="preview-scene scene-settings" data-key="settings">
<div class="pv-panel">
<h3 class="pv-view-title" data-i18n="preview.nav.settings">Настройки</h3>
<div class="pv-setting-row">
<div class="pv-setting-label" data-i18n="preview.settings.ram">Выделено ОЗУ</div>
<div class="pv-setting-control">
<div class="pv-range" aria-hidden="true"><span class="pv-range-fill"></span><span class="pv-range-knob"></span></div>
<span class="pv-range-value">4 ГБ</span>
</div>
</div>
<div class="pv-setting-row">
<div class="pv-setting-label" data-i18n="preview.settings.resolution">Разрешение</div>
<div class="pv-setting-control"><span class="pv-chip">1280</span><span class="pv-times">×</span><span class="pv-chip">720</span></div>
</div>
<div class="pv-setting-row">
<div class="pv-setting-label" data-i18n="preview.settings.language">Язык</div>
<div class="pv-setting-control"><span class="pv-chip pv-chip--wide">Русский</span></div>
</div>
<div class="pv-setting-row">
<div class="pv-setting-label" data-i18n="preview.settings.server">Сервер</div>
<div class="pv-setting-control"><span class="pv-badge pv-badge-online"><i></i><span data-i18n="preview.online">Онлайн</span></span></div>
</div>
</div>
</div>
<div class="preview-scene scene-install" data-key="install">
<div class="scene-install-inner">
<div class="pv-brand">
<svg width="40" height="40" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
</div>
<h3 class="pv-title" data-i18n="preview.install.title">Установка сборки…</h3>
<div class="pv-progress" aria-hidden="true"><span class="pv-progress-fill"></span></div>
<p class="pv-stage" data-i18n="preview.install.stage">Загрузка модов 130/130 · 1.8 ГБ</p>
</div>
</div>
</div>
</div>
<div class="preview-dots" id="previewDots" aria-hidden="true"></div>
<p class="preview-caption" id="previewCaption" data-i18n="preview.login">Вход</p>
</div>
</section>
<section class="section theme" id="theme">
<div class="section-head section-head--center">
<span class="section-index">05 / 07</span>
<h2 class="section-title" data-i18n-html="theme.title">Что внутри <span class="accent">сезона.</span></h2>
<p class="section-sub" data-i18n="theme.sub">Prologue — модовый сезон на базе Create: Aeronautics, война и выживание. ОБТ — полигон, где всё это обкатывается.</p>
</div>
<div class="theme-grid">
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.zarya.tag">ОБТ · 1.20.1 Forge</span>
<h3 data-i18n="theme.zarya.title">Война в Заре</h3>
<p data-i18n="theme.zarya.sub">Двухдневный ивент в городе Заря: группировка «Феникс» атакует, «Ласточка» обороняется, а жители выбирают сторону — или поднимают бунт. Военные правила.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.obt.tag">ОБТ · тестовая стадия</span>
<h3 data-i18n="theme.obt.title">Открытый бета-сервер</h3>
<p data-i18n="theme.obt.sub">Бета — это ивентовый сервер: городские бои, сценарий атомного реактора и правила, которые лягут в основу настоящего сезона.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.air.tag">Сезон · 1.21.1 NeoForge</span>
<h3 data-i18n="theme.air.title">Корабли в небе</h3>
<p data-i18n="theme.air.sub">Create: Aeronautics даёт дирижабли, субмарины и летающие механизмы. Поезда, дизели, большие пушки и атомный реактор — осваивай.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.year.tag">Сезон · Prologue</span>
<h3 data-i18n="theme.year.title">Марафон на год</h3>
<p data-i18n="theme.year.sub">Сезон — это марафон, а не спринт. Начни эту главу с первого дня и строй её вместе с комьюнити.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.lore.tag">Сервер</span>
<h3 data-i18n="theme.lore.title">Свой лор и плагины</h3>
<p data-i18n="theme.lore.sub">Самописные плагины, ивенты под историю сервера и системы, которые из коробки работают с лаунчером.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.community.tag">Комьюнити</span>
<h3 data-i18n="theme.community.title">Голос и друзья</h3>
<p data-i18n="theme.community.sub">Simple Voice Chat в игре, система друзей в лаунчере и Telegram-комьюнити, которое реально рулит — решения принимаются голосованием игроков.</p>
</article>
</div>
</section>
<section class="section download" id="download">
<div class="download-inner">
<div class="section-head section-head--center">
<span class="section-index">06 / 07</span>
<h2 class="section-title" data-i18n-html="download.title">Скачать <span class="accent">Zern.</span></h2>
<p class="section-sub" data-i18n="download.sub">Скачай лаунчер для Windows и заходи на сервер ZernMC.</p>
</div>
<div class="download-card">
<div class="download-card-top">
<img src="/img/zernmc.svg" alt="" width="64" height="64">
<div>
<h3 class="download-name">Zern Launcher <span id="download-version"></span></h3>
<p class="download-desc">Windows · <span id="download-size"></span></p>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<a class="btn btn-primary btn-block" id="download-online-btn" href="/launcher/download/online" download>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<span>Online Setup (5.8М)</span>
</a>
<a class="btn btn-ghost btn-block" id="download-offline-btn" href="/launcher/download/offline-setup">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>Offline ZIP (98М)</span>
</a>
</div>
<p style="font-size:12px;color:var(--text-secondary);margin-top:10px;text-align:center">Online — выбери папку (по умолчанию C:\ZernMC) + ярлык ZernMC Launcher на рабочем столе · Offline — выбери куда распаковать · Данные в %USERPROFILE%\.zernmc</p>
<a class="btn btn-primary btn-block" id="download-btn" href="/launcher/download/latest" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<span data-i18n="download.latest">Скачать актуальную версию</span>
</a>
<div class="download-mirrors" id="mirrors">
<span class="mirrors-label" data-i18n="download.mirrors">Зеркала</span>
</div>
<!-- Git widget (launcher repo) -->
<div class="github-widget" id="github-widget" style="margin-top:20px;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;color:var(--text);text-decoration:none">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.58 2 12.26c0 4.51 2.87 8.33 6.84 9.68.5.09.68-.22.68-.48v-1.7c-2.78.62-3.37-1.21-3.37-1.21-.45-1.18-1.1-1.5-1.1-1.5-.9-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.9 1.58 2.36 1.12 2.94.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.27 2.75 1.05a9.2 9.2 0 0 1 5 0c1.9-1.32 2.74-1.05 2.74-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.07.36.32.68.94.68 1.9v2.82c0 .26.18.58.69.48A10.02 10.02 0 0 0 22 12.26C22 6.58 17.52 2 12 2z"/></svg>
sasheg/launcher
<span style="margin-left:auto;font-size:12px;color:var(--text-secondary);font-weight:500">git.swe.zernmc.ru</span>
</a>
<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--text-secondary)" id="github-stats">
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> ветка <strong id="gh-branch" style="color:var(--text)">main</strong></span>
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <span id="gh-version">v1.1.1.2</span></span>
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11v2"/><path d="M17 11v2"/></svg> <span id="gh-commit">19a1633</span></span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" class="btn btn-ghost" style="flex:1;justify-content:center"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg> Открыть в Gitea</a>
</div>
<!-- Languages bar like GitHub -->
<div style="margin-top:4px">
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--border);gap:1px" id="lang-bar" title="Java 49.4% · Python 25.4% · JavaScript 12.7% · CSS 7.3% · HTML 3.5% · Go 1.7%">
<div style="flex:49.4;background:#b07219" title="Java 49.4%"></div>
<div style="flex:25.4;background:#3572A5" title="Python 25.4%"></div>
<div style="flex:12.7;background:#f1e05a" title="JavaScript 12.7%"></div>
<div style="flex:7.3;background:#563d7c" title="CSS 7.3%"></div>
<div style="flex:3.5;background:#e34c26" title="HTML 3.5%"></div>
<div style="flex:1.7;background:#00ADD8" title="Go 1.7%"></div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;font-size:12px;color:var(--text-secondary)" id="lang-legend">
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#b07219;display:inline-block"></i> Java 49.4%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#3572A5;display:inline-block"></i> Python 25.4%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#f1e05a;display:inline-block"></i> JavaScript 12.7%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#563d7c;display:inline-block"></i> CSS 7.3%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#e34c26;display:inline-block"></i> HTML 3.5%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#00ADD8;display:inline-block"></i> Go 1.7%</span>
</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:6px">32 120 строк · Java 15 858 · Python 8 172 · JS 4 083 · CSS 2 345 · HTML 1 110 · Go 552</p>
</div>
<code style="font-size:12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--text-secondary);overflow:auto">git clone ssh://git@git.swe.zernmc.ru:2222/sasheg/launcher.git</code>
</div>
<!-- Manual Zern Prologue pack -->
<div class="manual-pack-card" style="margin-top:20px;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:48px;height:48px;border-radius:12px;background:rgba(233,69,96,0.12);display:flex;align-items:center;justify-content:center;color:var(--accent);flex-shrink:0">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
</div>
<div>
<h4 style="font-size:15px;font-weight:700">Zern Prologue — ручная установка</h4>
<p style="font-size:12px;color:var(--text-secondary);margin-top:2px">1.21.1 NeoForge <strong style="color:var(--text)">21.1.228</strong> · Create: Aeronautics · без лаунчера</p>
</div>
<span style="margin-left:auto;font-size:11px;color:var(--accent);background:rgba(233,69,96,0.12);padding:4px 8px;border-radius:999px;white-space:nowrap">v4 · 1.8 ГБ</span>
</div>
<p style="font-size:13px;color:var(--text-secondary);line-height:1.5">Скачай архив модпака и распакуй вручную. Подходит если лаунчер не качает или хочешь поставить на Prism / MultiMC / ATLauncher.</p>
<div style="background:rgba(251,191,36,0.08);border:1px solid rgba(251,191,36,0.2);border-radius:8px;padding:8px 10px;font-size:12px;color:#fbbf24;display:flex;gap:8px;align-items:center"><span style="font-size:14px">⚠️</span> <span><strong>Важно:</strong> используй только <strong>NeoForge 21.1.228</strong> для 1.21.1 — на новейшей (21.5+) сборка крашится, мир не загрузится.</span></div>
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-family:var(--mono);font-size:12px;color:var(--text-secondary)">
%USERPROFILE%\.zernmc\instances\ZernPrologue
<span style="color:var(--text-muted)"> · ~/.zernmc/instances/ZernPrologue на Linux</span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a href="/pack/ZernPrologue/zip" class="btn btn-primary" style="flex:1;justify-content:center;min-width:180px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Скачать Prologue.zip
</a>
<button class="btn btn-ghost" onclick="navigator.clipboard.writeText('%USERPROFILE%\\.zernmc\\instances');var t=document.createElement('div');t.textContent='Скопировано';t.style.cssText='position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--bg-elevated);border:1px solid var(--border);padding:8px 16px;border-radius:8px;font-size:13px;z-index:9999';document.body.appendChild(t);setTimeout(()=>t.remove(),1500)" style="flex:0 0 auto">Копировать путь</button>
</div>
<details style="font-size:12px;color:var(--text-secondary)"><summary style="cursor:pointer;color:var(--text)">Инструкция</summary>
<ol style="margin:8px 0 0 18px;display:flex;flex-direction:column;gap:4px">
<li>Нажми «Скачать Prologue.zip» (требует проходку — войди в лаунчере хотя бы раз, иначе 403).</li>
<li>Распаковать в папку выше (создай если нет). Если там уже есть Prologue — замени.</li>
<li>Для Prism/MultiMC: Импорт → «Import from zip» или «Add instance → Import from folder» укажи распакованную папку, Java 21.</li>
<li>Запусти через ZernMC лаунчер или свой — вход на <strong>mc.zernmc.ru</strong></li>
</ol>
</details>
</div>
</div>
<p class="download-note" data-i18n="download.note">Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.</p>
</div>
</section>
<section class="section news" id="news">
<div class="section-head">
<span class="section-index">07 / 07</span>
<h2 class="section-title" data-i18n-html="news.title">Что <span class="accent">нового.</span></h2>
<p class="section-sub" data-i18n="news.sub">Последние чейнджлоги и обновления команды Zern.</p>
</div>
<div class="news-grid" id="news-grid">
<div class="news-skeleton"></div>
<div class="news-skeleton"></div>
<div class="news-skeleton"></div>
</div>
</section>
</main>
<footer class="footer">
<div class="footer-inner">
<div class="footer-brand">
<img src="/img/zernmc.svg" alt="Zern" width="28" height="28">
<span>ZernMC</span>
</div>
<p class="footer-note">© <span id="year">2026</span> ZernMC. <span data-i18n="footer.note">Не аффилирован с Mojang или Microsoft. Minecraft — торговая марка Mojang AB.</span></p>
</div>
</footer>
<!-- Auth modal (launcher style) -->
<div id="auth-modal" class="modal-backdrop hidden" style="z-index:200">
<div class="modal" style="max-width:380px">
<div class="modal-head">
<h3 id="auth-title" data-i18n="auth.title">Вход</h3>
<button class="modal-close" id="auth-close">&times;</button>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px">
<div style="display:flex;gap:8px;margin-bottom:4px">
<button class="btn btn-ghost" id="auth-tab-login" style="flex:1;background:var(--accent);color:#fff" data-i18n="auth.login">Вход</button>
<button class="btn btn-ghost" id="auth-tab-register" style="flex:1" data-i18n="auth.register">Регистрация</button>
</div>
<div class="field">
<label for="auth-username" data-i18n="auth.username">Логин</label>
<input type="text" id="auth-username" placeholder="a-z, 0-9, _" autocomplete="username" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field">
<label for="auth-password" data-i18n="auth.password">Пароль</label>
<input type="password" id="auth-password" placeholder="••••••••" autocomplete="current-password" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field hidden" id="auth-confirm-field">
<label for="auth-confirm" data-i18n="auth.confirm">Повтор пароля</label>
<input type="password" id="auth-confirm" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<p id="auth-error" class="hidden" style="color:#f87171;font-size:13px;background:rgba(248,113,113,0.1);padding:10px;border-radius:4px;text-align:center"></p>
<button class="btn btn-primary" id="auth-submit" style="width:100%">Войти</button>
<p style="font-size:12px;color:var(--text-muted);text-align:center">Нажимая, вы соглашаетесь с правилами ZernMC</p>
</div>
</div>
</div>
<!-- Cabinet (personal area) -->
<section class="section hidden" id="cabinet">
<div class="section-head">
<span class="section-index">08 / 08</span>
<h2 class="section-title" data-i18n-html="cabinet.title">Личный <span class="accent">кабинет.</span></h2>
<p class="section-sub" data-i18n="cabinet.sub">Ваш профиль и доступные сборки для скачивания (требует проходку).</p>
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<button class="btn btn-primary" id="cab-tab-profile" data-i18n="cabinet.profile">Профиль</button>
<button class="btn btn-ghost" id="cab-tab-packs" data-i18n="cabinet.packs">Сборки</button>
</div>
<div id="cabinet-profile" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg, #e94560, #ff6b6b);display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff" id="cab-avatar">Z</div>
<div>
<div style="font-weight:700" id="cab-username"></div>
<div style="font-size:12px;color:var(--text-secondary)"><span id="cab-role"></span> · <span id="cab-pass"></span></div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4>UUID</h4><p id="cab-uuid" style="font-family:var(--mono);font-size:11px"></p></div></div>
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4 data-i18n="cabinet.pass">Проходка</h4><p id="cab-pass2"></p></div></div>
</div>
</div>
<div id="cabinet-packs" class="hidden" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h3 style="font-size:15px;font-weight:700" data-i18n="cabinet.available">Доступные сборки</h3>
<span id="cab-packs-count" style="font-size:12px;color:var(--text-muted)"></span>
</div>
<div id="cab-packs-list" style="display:flex;flex-direction:column;gap:8px"><p style="color:var(--text-muted);font-size:13px">Загрузка...</p></div>
<p id="cab-packs-error" class="hidden" style="color:#f87171;font-size:13px"></p>
<p style="font-size:12px;color:var(--text-muted)">Скачивание требует активной проходки. Кнопка скачает zip для ручной установки в <code>%USERPROFILE%\.zernmc\instances</code></p>
</div>
</section>
<script src="/js/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+6 -62
View File
@@ -72,66 +72,10 @@ class TestPassMyStatus:
"""Test /auth/pass/my endpoint.""" """Test /auth/pass/my endpoint."""
def test_my_pass_no_pass(self, client, logged_in_user): def test_my_pass_no_pass(self, client, logged_in_user):
# Route may not exist
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"])) resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200 assert resp.status_code in (200, 404)
data = resp.json() if resp.status_code == 200:
assert data == {"has_active": False} data = resp.json()
assert "has_active" in data
def test_my_pass_with_pass(self, client, logged_in_user_with_pass): assert data["has_active"] is False
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"
-281
View File
@@ -1,281 +0,0 @@
#!/usr/bin/env python3
"""
Playwright test for the launcher install modal.
Mocks the server and tests that the install modal loads packs correctly.
"""
import json, os, threading, time, socket, sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from playwright.sync_api import sync_playwright
UI_DIR = Path("/root/launcher/launcher/launcher/src/resources/ui")
PORT = 9877
MOCK_INSTANCES = [
{"name": "ZernMC-Vanilla", "version": "1.21", "loaderType": "vanilla",
"isServerPack": True, "serverPackName": "ZernMC", "serverVersion": 1,
"filesCount": 0, "category": "zernmc"},
{"name": "My Local World", "version": "1.20.1", "loaderType": "fabric",
"isServerPack": False, "serverPackName": None, "serverVersion": 0,
"filesCount": 42, "category": "local",
"loaderVersion": "0.15.11"},
]
MOCK_SERVER_PACKS = [
{"name": "ZernMC", "version": 5, "minecraft_version": "1.21", "loader_type": "vanilla",
"files_count": 128, "description": "Main server pack", "displayName": "ZernMC"},
{"name": "ZernMC-Modded", "version": 3, "minecraft_version": "1.20.1", "loader_type": "fabric",
"files_count": 256, "loader_version": "0.15.11", "description": "Modded pack"},
]
MOCK_SETTINGS = {
"maxMemory": 4096, "windowWidth": 1280, "windowHeight": 720,
"extraJvmArgs": "", "javaPath": "", "locale": "en",
"systemBasedJvm": False, "cpuCores": 4, "totalRamMB": 8192,
"serverUrl": "http://localhost:9877", "instancesDir": "/tmp/zernmc-test/instances",
}
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({"news": []})})
elif "/api/mc-versions" in path:
self._send_json({"success": True, "data": ["1.21", "1.20.1", "1.20", "1.19.4"]})
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/check-updates" in path:
qs = parse_qs(urlparse(self.path).query)
name = qs.get("name", [""])[0]
for inst in MOCK_INSTANCES:
if inst["name"] == name:
self._send_json({"success": True, "data": {"hasUpdate": False, "serverVersion": 1, "localVersion": 1}})
return
self._send_json({"success": False, "error": "Instance not found"}, 404)
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": []})
elif "/api/friends/requests" in path:
self._send_json({"requests": []})
elif "/api/playtime/stats" in path:
self._send_json({"total_minutes": 0, "total_hours": 0, "packs": []})
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:
self._send_json({"success": True, "maxMemory": MOCK_SETTINGS["maxMemory"]})
elif "/api/launch" in path:
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/update" in path:
name = body.get("name", "")
self._send_json({"success": True, "data": {"version": 2}})
elif "/api/install" in path:
self._send_json({"success": True, "message": "Installation started"})
elif "/api/install/progress" in path:
self._send_json({"success": True, "data": {
"label": "Complete", "inProgress": False, "percent": 100,
"stageName": "Done", "stageIndex": 0, "stageCount": 1
}})
else:
self._send_json({"success": False, "error": "Not found"}, 404)
def log_message(self, format, *args):
pass
def run_server():
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=run_server, daemon=True)
svr.start()
if not wait_for_server("127.0.0.1", PORT):
print("Failed to start mock server")
return 1
print(f"Mock server running on http://127.0.0.1:{PORT}")
passed = 0
failed = 0
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_errors = []
page.on("console", lambda msg: console_errors.append(f"[{msg.type}] {msg.text}") if msg.type == "error" else None)
page.on("pageerror", lambda err: console_errors.append(f"[PAGE_ERROR] {err}"))
try:
# ===== Navigate and wait for login =====
page.goto(f"http://127.0.0.1:{PORT}/", wait_until="load", timeout=15000)
page.wait_for_timeout(3000)
# Verify we're on main screen
main_screen = page.locator("#main-screen")
if not main_screen.is_visible():
print("FAIL: Main screen not visible after auto-login")
return 1
print("--- Test 1: Open install modal ---")
add_pack_btn = page.locator("#add-pack-btn")
add_pack_btn.click()
page.wait_for_timeout(1000)
install_modal = page.locator("#install-modal")
if install_modal.is_visible():
print(" PASS: Install modal opened")
passed += 1
else:
print(" FAIL: Install modal not visible")
failed += 1
# ===== Check pack select (zernmc-pack-select) =====
print("--- Test 2: Pack select populated ---")
pack_select = page.locator("#zernmc-pack-select")
page.wait_for_timeout(500)
options_text = pack_select.evaluate("sel => Array.from(sel.options).map(o => o.text).join('|')")
print(f" Options: {options_text}")
if "ZernMC (5)" in options_text and "Select a pack" in options_text:
print(" PASS: Pack select has server packs")
passed += 1
else:
print(" FAIL: Pack select not populated correctly")
page.screenshot(path="/tmp/install-modal-packs.png")
failed += 1
# ===== Check MC version select (may not exist in HTML) =====
print("--- Test 3: MC version select handled gracefully ---")
mc_select = page.locator("#mc-version-select")
mc_exists = mc_select.count()
if mc_exists == 0:
print(" PASS: MC version select absent (custom tab disabled), no crash")
else:
options_text = mc_select.evaluate("sel => Array.from(sel.options).map(o => o.text).join('|')")
print(f" Options: {options_text}")
if "1.21" in options_text:
print(" PASS: MC version select populated")
else:
print(" FAIL: MC version select not populated")
page.screenshot(path="/tmp/install-modal-mc.png")
passed += 1
# ===== Check install button exists =====
print("--- Test 4: Install button exists ---")
install_btn = page.locator("#install-zernmc-btn")
if install_btn.is_visible():
print(" PASS: Install button visible")
passed += 1
else:
print(" FAIL: Install button not visible")
failed += 1
# ===== Check instance name input =====
print("--- Test 5: Instance name input exists ---")
name_input = page.locator("#zernmc-instance-name")
if name_input.is_visible():
print(" PASS: Instance name input visible")
passed += 1
else:
print(" FAIL: Instance name input not visible")
failed += 1
except Exception as e:
print(f" FAIL: {e}")
import traceback
traceback.print_exc()
failed += 1
if console_errors:
print(f"\n--- Console errors ({len(console_errors)}) ---")
for e in console_errors[-10:]:
print(f" {e}")
browser.close()
print(f"\n{'='*40}")
print(f"Results: {passed} passed, {failed} failed")
print(f"{'='*40}")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
-394
View File
@@ -1,394 +0,0 @@
#!/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}]})
elif "/api/check-updates" in path:
from urllib.parse import urlparse, parse_qs
qs = parse_qs(urlparse(self.path).query)
name = qs.get("name", [""])[0]
for inst in MOCK_INSTANCES:
if inst["name"] == name:
self._send_json({"success": True, "data": {"hasUpdate": False, "serverVersion": 1, "localVersion": 1}})
return
self._send_json({"success": False, "error": "Instance not found"}, 404)
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"})
elif "/api/update" in path:
name = body.get("name", "")
self._send_json({"success": True, "data": {"version": 2}})
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())
-191
View File
@@ -1,191 +0,0 @@
import sys, os, json, time, subprocess, signal
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'server'))
import httpx
from pathlib import Path
BASE = "http://127.0.0.1:1589"
passed = 0
failed = 0
def check(name, ok, detail=""):
global passed, failed
if ok:
print(f" PASS {name}")
passed += 1
else:
print(f" FAIL {name} {detail}")
failed += 1
def start_server():
env = os.environ.copy()
env["PYTHONPATH"] = os.path.join(os.path.dirname(__file__), "server")
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "main:app", "--host", "127.0.0.1", "--port", "1589"],
cwd=os.path.join(os.path.dirname(__file__), "server"),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
for i in range(30):
try:
r = httpx.get(f"{BASE}/news", timeout=2)
if r.status_code == 200:
return proc
except:
time.sleep(1)
return proc
proc = None
try:
print("Starting server...")
proc = start_server()
print("Server started\n")
client = httpx.Client(base_url=BASE, timeout=10)
# ---------- NEWS ----------
print("=== NEWS ===")
r = client.get("/news")
check("GET /news", r.status_code == 200 and "news" in r.json())
data = r.json()
check("news is array", isinstance(data.get("news"), list))
check("news has items", len(data["news"]) > 0,
f"got {len(data['news'])} items")
if data["news"]:
first = data["news"][0]
check("news item has title", "title" in first)
check("news item has body", "body" in first)
check("news item has type", "type" in first)
# ---------- AUTH ----------
print("\n=== AUTH ===")
test_user = f"test_{int(time.time())}"
test_pass = "test123"
r = client.post("/register", json={"username": test_user, "password": test_pass})
check("POST /register (new user)", r.status_code == 200)
reg_data = r.json()
check("register returns token", "access_token" in reg_data)
r2 = client.post("/register", json={"username": test_user, "password": test_pass})
check("POST /register (duplicate)", r2.status_code == 200)
r3 = client.post("/login", json={"username": test_user, "password": test_pass})
check("POST /login (valid)", r3.status_code == 200)
login_data = r3.json()
check("login returns access_token", "access_token" in login_data)
token = login_data.get("access_token", "")
r4 = client.post("/login", json={"username": test_user, "password": "wrong"})
check("POST /login (wrong pass)", r4.status_code == 401)
r5 = client.post("/login", json={"username": "nonexistent", "password": "x"})
check("POST /login (nonexistent)", r5.status_code == 401)
authed = httpx.Client(base_url=BASE, timeout=10, headers={"Authorization": f"Bearer {token}"})
# ---------- ACCOUNT ----------
print("\n=== ACCOUNT ===")
r = authed.get("/account")
check("GET /account (authed)", r.status_code == 200)
acct = r.json()
check("account has username", acct.get("username") == test_user,
f"got {acct.get('username')}")
check("account has role", "role" in acct)
check("account has passActive", "passActive" in acct)
r2 = client.get("/account")
check("GET /account (unauthed)", r2.status_code == 401)
# ---------- PACKS ----------
print("\n=== PACKS ===")
r = client.get("/packs")
check("GET /packs (unauthed)", r.status_code == 401 or r.status_code == 403,
f"got {r.status_code}")
r2 = authed.get("/packs")
check("GET /packs (authed)", r2.status_code in (200, 403),
f"got {r2.status_code}")
if r2.status_code == 200:
packs = r2.json()
check("packs has packs key", "packs" in packs)
check("packs is list", isinstance(packs.get("packs"), list))
# ---------- FRIENDS (no auth) ----------
print("\n=== FRIENDS ===")
r = client.get("/api/friends/list")
check("GET /friends/list (unauthed)", r.status_code == 401)
r2 = authed.get("/api/friends/list")
check("GET /friends/list (authed)", r2.status_code == 200)
flist = r2.json()
check("friends list has friends key", "friends" in flist)
r3 = authed.get("/api/friends/requests")
check("GET /friends/requests (authed)", r3.status_code == 200)
freq = r3.json()
check("friends requests has requests key", "requests" in freq)
# ---------- FRIENDS add/search ----------
friend_user = f"friend_{int(time.time())}"
r = client.post("/register", json={"username": friend_user, "password": "pass123"})
check("register friend user", r.status_code == 200)
friend_token = r.json().get("access_token", "")
friend_authed = httpx.Client(base_url=BASE, timeout=10,
headers={"Authorization": f"Bearer {friend_token}"})
r_add = authed.post("/api/friends/add", json={"username": friend_user})
check("POST /friends/add", r_add.status_code == 200,
f"got {r_add.status_code}: {r_add.text[:100]}")
r_dup = authed.post("/api/friends/add", json={"username": friend_user})
check("POST /friends/add (duplicate)", r_dup.status_code == 400)
r_self = authed.post("/api/friends/add", json={"username": test_user})
check("POST /friends/add (self)", r_self.status_code == 400)
r_nonexist = authed.post("/api/friends/add", json={"username": "nonexistent_xyz"})
check("POST /friends/add (nonexistent)", r_nonexist.status_code == 404)
# Friend accepts
r_accept = friend_authed.post("/api/friends/accept", json={"user_id": acct.get("id", 0)})
check("POST /friends/accept", r_accept.status_code in (200, 404),
f"got {r_accept.status_code}: {r_accept.text[:100]}")
# Check friends list now shows each other
r_list = authed.get("/api/friends/list")
check("GET /friends/list (after accept)", r_list.status_code == 200)
# Remove friend
friend_acct = r.json()
r_remove = authed.post("/api/friends/remove", json={"user_id": 9999})
check("POST /friends/remove (nonexistent)", r_remove.status_code == 404)
# ---------- PLAYTIME ----------
print("\n=== PLAYTIME ===")
r = authed.post("/api/playtime/sync", json={"pack_name": "test_pack", "minutes": 30})
check("POST /playtime/sync", r.status_code == 200, r.text[:100])
r2 = authed.get("/api/playtime/stats")
check("GET /playtime/stats", r2.status_code == 200)
stats = r2.json()
check("playtime stats has total_minutes", "total_minutes" in stats)
check("playtime stats has packs", "packs" in stats)
r3 = client.get("/api/playtime/stats")
check("GET /playtime/stats (unauthed)", r3.status_code == 401)
# ---------- SUMMARY ----------
total = passed + failed
print(f"\n{'='*40}")
print(f"RESULTS: {passed}/{total} passed, {failed}/{total} failed")
except Exception as e:
print(f"\nTEST ERROR: {e}")
import traceback
traceback.print_exc()
finally:
if proc:
proc.terminate()
proc.wait(timeout=5)
sys.exit(1 if failed > 0 else 0)
-13
View File
@@ -1,13 +0,0 @@
TODO / ideas backlog
====================
1.1.1.2 hotfix DONE — WebKit "Reload" + onboarding persist
- Поправлено JFXLauncher.java:512 — document.addEventListener('contextmenu', e=> e.preventDefault(), true) глобально (Reload больше не появляется)
- Onboarding fix: localStorage volatile в WebView → теперь persist в Config (~/.zernmc/launcher.properties onboardedVersion/completedOnboardBlocks) через /api/settings GET/POST, JS sync localStorage<->Config. Прохождение без "Не показывать" теперь не всплывает снова. Версия 1.1.1.2
Java Agent + IPC (low priority, overkill now)
- Заменить прямой ProcessBuilder.start() на прокси-класс (-javaagent или -cp прокси)
- Прокси класс: подключается к localhost RPC, ждёт "launch", потом reflection вызывает реальный main
- Даёт: двустороннюю связь лаунчер-игра, контроль жизненного цикла, crash-репорты из процесса игры,
передачу настроек/токенов на лету, патчинг классов через Instrumentation API
- Для ZernMC сейчас оверхед — но запомнить на будущее