43 Commits

Author SHA1 Message Date
SashegDev 454d3389b6 fix: добавляем shell скрипт launcher.sh для запуска с javafx module-path 2026-05-05 11:36:36 +00:00
SashegDev 012d1635dc fix: исключаем javafx из shade, исправляем antrun 2026-05-05 11:35:33 +00:00
SashegDev 70c4815032 feat: добавляем win/linux профили для сборки с разными javafx 2026-05-05 11:33:30 +00:00
SashegDev a46ef3e834 fix: исправляем синтаксис antrun copy 2026-05-05 11:29:01 +00:00
SashegDev cba8259e59 fix: копируем JavaFX JAR в builds и исключаем из shade 2026-05-05 11:25:44 +00:00
SashegDev 08417efe2f fix: добавляем JavaFX module-path и настройки для GUI запуска 2026-05-05 11:23:59 +00:00
SashegDev 991252130d fix: добавляем Windows classifier для JavaFX и исключаем Linux jar из shade
- добавили <classifier>win</classifier> в javafx-controls и javafx-web
- добавили фильтр в shade plugin для исключения *-linux.jar
2026-05-05 09:42:49 +00:00
SashegDev 6fa97b7fda fix: убираем дублирующую проверку isHeadless из UIWindow
раньше UIWindow сам проверял isHeadless и выбрасывал исключение
теперь доверяем проверке в Main с учётом переменной DISPLAY
2026-05-05 09:37:01 +00:00
SashegDev 83abc600f3 fix: добавляем проверку DISPLAY для запуска UI на сервере
раньше isHeadless() определял что нет дисплея даже когда он есть
теперь дополнительно проверяем переменную DISPLAY - если она есть,
значит графическая среда доступна и можно запускать UI
2026-05-05 09:36:01 +00:00
SashegDev 300ce4b60b feat(ui): добавляем анимированный прогресс-бар при обновлении сборки
- обновили updateInstance() с поддержкой SSE для реального прогресса
- добавили showAnimatedProgress() и updateAnimatedProgress() методы
- добавили CSS анимацию shimmer для прогресс-бара
- теперь показывает: файл X из Y и процент текущего файла
2026-05-05 08:57:48 +00:00
SashegDev 82391e10ea feat(ui): добавляем прогресс-коллбэк и автообновление лаунчера
- добавили ProgressCallback в PackDownloader для отслеживания прогресса
- SSE эндпоинт /instances/{name}/install/stream для стриминга прогресса
- добавили checkLauncherUpdate() в WebServer для обновления самого лаунчера
- теперь при запуске UI проверяются обновления лаунчера автоматически
2026-05-05 08:53:01 +00:00
SashegDev 1934199ba8 fix: исправляем баг с версией сборки
раньше latestVersion вычислялся как currentVersion + 1
теперь честно получаем версию с сервера через PackDownloader.checkForUpdates()

так же обновили места в LaunchMenu и UpdateMenu где используется этот метод
2026-05-05 08:42:51 +00:00
SashegDev 526a24a16a feat(ui): добавляем верификацию файлов и кнопку ОБНОВИТЬ
- спиздили апи файлы из alpha (ApiResponse, AuthService, InstanceService, LaunchService)
- добавили в InstanceInfo поля isServerPack и serverPackName
- CSS: добавили оранжевую .btn-update кнопку
- JS: при загрузке инстанса проверяем целостность (verify) и обновления (updates)
- Кнопка ИГРАТЬ теперь меняется на ОБНОВИТЬ если есть косяки
- ОБНОВИТЬ докачивает/обновляет файлы через повторный install

всё как ты хотел, красава
2026-05-05 08:21:10 +00:00
sasheg dev 96baeeea68 Merge pull request #5 from SashegDev/ui
feat(ui): add Web UI with JavaFX, install service, and new tests
2026-05-05 09:54:09 +03:00
SashegDev 28995adce8 feat(ui): add Web UI with JavaFX, install service, and new tests
- Add JavaFX WebView for native window UI (fallback to TUI on headless)
- Create WebServer with Javalin HTTP server
- Add webapp with dark theme and grid animation
- Create InstallService for ZernMC pack installation
- Integrate CLI installation logic via PackDownloader
- Add verifyHashes() using /pack/{name}/diff endpoint
- Add API endpoints: /instances/zernmc/install, /instances/{name}/updates, /instances/{name}/verify, /instances/{name}/playtime
- Add 14 new tests (WebServerTest, HeadlessDetectionTest, InstanceServiceTest)
- Total 44 tests now passing
2026-05-05 06:52:13 +00:00
SashegDev c9ed825686 feat(ui): add Web UI with JavaFX, install service, and new tests
- Add JavaFX WebView for native window UI (fallback to TUI on headless)
- Create WebServer with Javalin HTTP server
- Add webapp with dark theme and grid animation
- Create InstallService for ZernMC pack installation
- Integrate CLI installation logic via PackDownloader
- Add verifyHashes() using /pack/{name}/diff endpoint
- Add API endpoints: /instances/zernmc/install, /instances/{name}/updates, /instances/{name}/verify, /instances/{name}/playtime
- Add 14 new tests (WebServerTest, HeadlessDetectionTest, InstanceServiceTest)
- Total 44 tests now passing
2026-05-05 06:48:27 +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
sasheg dev 3f2cb6662a Немного подправил инфу 2026-05-05 02:16:06 +03:00
sasheg dev 11513fbf13 Merge pull request #4 from SashegDev/alpha
Ептить медж из альфы! СПУСТЯ СТОЛЬКО ВРЕМЕНИ ЕБААААТ
2026-05-05 02:12:31 +03: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
60 changed files with 9351 additions and 1323 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+2
View File
@@ -9,3 +9,5 @@ jre
.vscode .vscode
dependency-reduced-pom.xml dependency-reduced-pom.xml
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
telegram-bot/
.env
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 SashegDev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17 -6
View File
@@ -17,21 +17,34 @@
## Чего пока нет в лаунчере ## Чего пока нет в лаунчере
- Графического интерфейса (GUI) — только TUI
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти) - Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
- Поддержки **Forge** (в разработке) - Поддержки **Forge** (в разработке) (технически уже есть вместе с NeoForge (science PR№4))
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров - Поддержки Quilt, LabyMod, NeoForge и других лоадеров
- Раздела новостей об обновлениях Minecraft и лаунчера - Раздела новостей об обновлениях Minecraft и лаунчера
- Выбора готовых пресетов оптимизации JVM - Выбора готовых пресетов оптимизации JVM
- Кастомных модов (UI, спавнеры, DPI, карточки)
- Сайта для лаунчера и сервера
- Трекинга наигранного времени
## Что планируется доработать в ближайшее время ## Что планируется доработать в ближайшее время
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
- **GUI мод** — привести в единый стиль с главным меню
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
- **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
- Генерацию команды запуска Minecraft - Генерацию команды запуска Minecraft
- Стабильную работу автообновления лаунчера - Стабильную работу автообновления лаунчера
- Полноценные настройки - Полноценные настройки
- Стабильность и производительность серверной части - **Улучшенный античит / ClientChecker** — проверка подлинности клиента при подключении к серверу, без нужного клиента не пустит; поставляется вместе с лаунчером, не общедоступный. Хеш-проверка всех папок и файлов сборки при каждом запуске — при несовпадении одного хеша все моды переустанавливаются. Игнорируются только: логи, ресурспаки, шейдеры, сейвы, личные файлы. Защита от подмены libs и лоадеров (Meteor и аналоги), проверка целостности модов через хеши. В перспективе — защита от Mixin-атак (перехват логики других модов), сбор отчёта о текущей сборке и сравнение с базовой
- **Баг-фиксы сервера:** подключить `admin_router` в `main.py`, исправить импорты ролей (`ROLE_USER` и др. не существуют в `roles.py`), добавить эндпоинт `/auth/pass/activate`, убрать дубли импортов (`TTLCache`, `Response`)
- Улучшение прокси-режима - Улучшение прокси-режима
- Стабильность и производительность серверной части
- Общую надёжность загрузки файлов с сервера - Общую надёжность загрузки файлов с сервера
- аккаунты, проходки
## Важная информация перед использованием ## Важная информация перед использованием
@@ -39,12 +52,10 @@
Лаунчер использует **текстовый интерфейс (TUI)**: Лаунчер использует **текстовый интерфейс (TUI)**:
- `W` / `S` (или `Ц` / `Ы`) — перемещение по меню - `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
- `ENTER` — выбор пункта - `ENTER` — выбор пункта
- `ESC` или пункт «Назад» — возврат назад - `ESC` или пункт «Назад» — возврат назад
> **Важно:** Стрелки ↑/↓ могут вызывать баги и краши. Используйте только `W`/`S`.
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре. Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
### Расположение сборок ### Расположение сборок
+127 -7
View File
@@ -3,9 +3,13 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId> <groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId> <artifactId>ZernMCLauncher</artifactId>
<version>1.0.7</version> <version>1.0.8</version>
<build> <build>
<plugins> <plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
<plugin> <plugin>
<artifactId>maven-shade-plugin</artifactId> <artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version> <version>3.5.0</version>
@@ -24,11 +28,54 @@
<Implementation-Version>${project.version}</Implementation-Version> <Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title> <Implementation-Title>ZernMC Launcher</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor> <Implementation-Vendor>SashegDev</Implementation-Vendor>
<Implementation-Description>Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном)</Implementation-Description> <Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL> <Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
</manifestEntries> </manifestEntries>
</transformer> </transformer>
</transformers> </transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
<filter>
<artifact>org.openjfx:*</artifact>
<excludes>
<exclude>**/*</exclude>
</excludes>
</filter>
</filters>
<dependencySet>
<outputDirectory>/</outputDirectory>
<useProjectArtifact>false</useProjectArtifact>
<unpack>true</unpack>
<scope>runtime</scope>
<excludes>
<exclude>org.openjfx:*</exclude>
</excludes>
</dependencySet>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>copy-javafx</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib-javafx</outputDirectory>
<includeScope>runtime</includeScope>
<includeGroupIds>org.openjfx</includeGroupIds>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>
@@ -45,28 +92,36 @@
<goal>launch4j</goal> <goal>launch4j</goal>
</goals> </goals>
<configuration> <configuration>
<outfile>../server/builds/ZernMCLauncher.exe</outfile> <outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
<jar>../server/builds/ZernMCLauncher.jar</jar> <jar>../server/builds/ZernMCLauncher.jar</jar>
<headerType>console</headerType> <headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar> <dontWrapJar>false</dontWrapJar>
<jre> <jre>
<path>jre21</path> <path>jre21</path>
<minVersion>21</minVersion> <minVersion>21</minVersion>
<opts>
<opt>--module-path=lib-javafx</opt>
<opt>--add-modules=javafx.controls,javafx.web</opt>
<opt>--add-reads=javafx.graphics=ALL-UNNAMED</opt>
</opts>
</jre> </jre>
<versionInfo> <versionInfo>
<fileVersion>${project.version}.0</fileVersion> <fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion> <txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription> <fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription>
<productVersion>${project.version}.0</productVersion> <productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion> <txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC Launcher</productName> <productName>ZernMC Launcher</productName>
<companyName>ZernMC(SashegDev)</companyName> <companyName>ZernMC(SashegDev)</companyName>
<internalName>ZernMCLauncher</internalName> <internalName>ZernMCLauncher</internalName>
<originalFilename>ZernMCLauncher.exe</originalFilename> <originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
</versionInfo> </versionInfo>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>
<configuration>
<skip>${skip.launch4j}</skip>
</configuration>
</plugin> </plugin>
<plugin> <plugin>
<artifactId>maven-antrun-plugin</artifactId> <artifactId>maven-antrun-plugin</artifactId>
@@ -83,6 +138,9 @@
<copy> <copy>
<fileset /> <fileset />
</copy> </copy>
<copy>
<fileset />
</copy>
<zip /> <zip />
</target> </target>
</configuration> </configuration>
@@ -91,10 +149,72 @@
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
<profiles>
<profile>
<id>global</id>
<properties>
<launcher.title>ZernMC Launcher</launcher.title>
<build.profile>global</build.profile>
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
<profile>
<id>zernmc</id>
<properties>
<launcher.title>ZernMC Private Launcher</launcher.title>
<build.profile>zernmc</build.profile>
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
<profile>
<id>win</id>
<properties>
<os.suffix>win</os.suffix>
<javafx.classifier>win</javafx.classifier>
<skip.launch4j>false</skip.launch4j>
</properties>
</profile>
<profile>
<id>linux</id>
<properties>
<os.suffix>linux</os.suffix>
<javafx.classifier>linux</javafx.classifier>
<skip.launch4j>true</skip.launch4j>
</properties>
</profile>
</profiles>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
<exclusions>
<exclusion>
<artifactId>junit-jupiter-api</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-params</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-engine</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<properties> <properties>
<maven.compiler.target>21</maven.compiler.target> <project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass> <mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<project.organization.name>ZernMC</project.organization.name>
<javafx.classifier>win</javafx.classifier>
<skip.launch4j>false</skip.launch4j>
<maven.compiler.target>21</maven.compiler.target>
<os.suffix>win</os.suffix>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.inceptionYear>2026</project.inceptionYear>
</properties> </properties>
</project> </project>
+191 -11
View File
@@ -6,14 +6,20 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId> <groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId> <artifactId>ZernMCLauncher</artifactId>
<version>1.0.7</version> <version>1.0.8</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.organization.name>ZernMC</project.organization.name>
<project.inceptionYear>2026</project.inceptionYear>
<project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass> <mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<javafx.classifier>win</javafx.classifier>
<os.suffix>win</os.suffix>
<skip.launch4j>false</skip.launch4j>
</properties> </properties>
<dependencies> <dependencies>
@@ -42,6 +48,11 @@
<artifactId>jansi</artifactId> <artifactId>jansi</artifactId>
<version>2.4.1</version> <version>2.4.1</version>
</dependency> </dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
<version>3.24.1</version>
</dependency>
<dependency> <dependency>
<groupId>me.tongfei</groupId> <groupId>me.tongfei</groupId>
<artifactId>progressbar</artifactId> <artifactId>progressbar</artifactId>
@@ -52,10 +63,58 @@
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>
<version>2.15.1</version> <version>2.15.1</version>
</dependency> </dependency>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<version>6.1.3</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.11</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>21.0.2</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21.0.2</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>21.0.2</version>
<classifier>linux</classifier>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21.0.2</version>
<classifier>linux</classifier>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
<plugins> <plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
<!-- Shade Plugin --> <!-- Shade Plugin -->
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
@@ -76,21 +135,71 @@
<Implementation-Version>${project.version}</Implementation-Version> <Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title> <Implementation-Title>ZernMC Launcher</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor> <Implementation-Vendor>SashegDev</Implementation-Vendor>
<Implementation-Description>Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном)</Implementation-Description> <Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL> <Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
</manifestEntries> </manifestEntries>
</transformer> </transformer>
</transformers> </transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
<!-- Исключаем JavaFX из shade полностью (он будет в lib-javafx) -->
<filter>
<artifact>org.openjfx:*</artifact>
<excludes>
<exclude>**/*</exclude>
</excludes>
</filter>
</filters>
<dependencySet>
<outputDirectory>/</outputDirectory>
<useProjectArtifact>false</useProjectArtifact>
<unpack>true</unpack>
<scope>runtime</scope>
<excludes>
<exclude>org.openjfx:*</exclude>
</excludes>
</dependencySet>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
<!-- Launch4j для создания .exe --> <!-- Copy JavaFX dependencies -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>copy-javafx</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib-javafx</outputDirectory>
<includeScope>runtime</includeScope>
<includeGroupIds>org.openjfx</includeGroupIds>
</configuration>
</execution>
</executions>
</plugin>
<!-- Launch4j для создания .exe (только для Windows) -->
<plugin> <plugin>
<groupId>com.akathist.maven.plugins.launch4j</groupId> <groupId>com.akathist.maven.plugins.launch4j</groupId>
<artifactId>launch4j-maven-plugin</artifactId> <artifactId>launch4j-maven-plugin</artifactId>
<version>2.5.0</version> <version>2.5.0</version>
<configuration>
<skip>${skip.launch4j}</skip>
</configuration>
<executions> <executions>
<execution> <execution>
<id>l4j</id> <id>l4j</id>
@@ -99,24 +208,29 @@
<goal>launch4j</goal> <goal>launch4j</goal>
</goals> </goals>
<configuration> <configuration>
<outfile>../server/builds/ZernMCLauncher.exe</outfile> <outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
<jar>../server/builds/ZernMCLauncher.jar</jar> <jar>../server/builds/ZernMCLauncher.jar</jar>
<headerType>console</headerType> <headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar> <dontWrapJar>false</dontWrapJar>
<jre> <jre>
<path>jre21</path> <path>jre21</path>
<minVersion>21</minVersion> <minVersion>21</minVersion>
<opts>
<opt>--module-path=lib-javafx</opt>
<opt>--add-modules=javafx.controls,javafx.web</opt>
<opt>--add-reads=javafx.graphics=ALL-UNNAMED</opt>
</opts>
</jre> </jre>
<versionInfo> <versionInfo>
<fileVersion>${project.version}.0</fileVersion> <fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion> <txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription> <fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription>
<productVersion>${project.version}.0</productVersion> <productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion> <txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC Launcher</productName> <productName>ZernMC Launcher</productName>
<companyName>ZernMC(SashegDev)</companyName> <companyName>ZernMC(SashegDev)</companyName>
<internalName>ZernMCLauncher</internalName> <internalName>ZernMCLauncher</internalName>
<originalFilename>ZernMCLauncher.exe</originalFilename> <originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
</versionInfo> </versionInfo>
</configuration> </configuration>
</execution> </execution>
@@ -141,11 +255,22 @@
<fileset dir="${user.home}/launcher/jre/jre21"/> <fileset dir="${user.home}/launcher/jre/jre21"/>
</copy> </copy>
<!-- Создаём zip только с .exe и jre21 (без .jar и build.version) --> <!-- Копируем JavaFX JAR в builds -->
<zip destfile="../server/builds/ZernMCLauncher-${project.version}.zip" <copy todir="../server/builds/lib-javafx" overwrite="true">
<fileset dir="${project.build.directory}/lib-javafx"/>
</copy>
<!-- Копируем shell script для Linux -->
<copy file="${project.basedir}/src/main/resources/launcher.sh"
todir="../server/builds"
overwrite="true"/>
<chmod file="../server/builds/launcher.sh" perm="+x"/>
<!-- Создаём zip с .exe, jre21, lib-javafx и launcher.sh (без .jar и build.version) -->
<zip destfile="../server/builds/ZernMCLauncher-${project.version}-${os.suffix}.zip"
basedir="../server/builds" basedir="../server/builds"
includes="ZernMCLauncher.exe,jre21/**" includes="ZernMCLauncher.exe,ZernMCLauncher.jar,jre21/**,lib-javafx/**,launcher.sh"
excludes="*.jar,build.version"/> excludes="build.version"/>
</target> </target>
</configuration> </configuration>
</execution> </execution>
@@ -153,4 +278,59 @@
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
<profiles>
<!-- ==================== GLOBAL ==================== -->
<profile>
<id>global</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<build.profile>global</build.profile>
<launcher.title>ZernMC Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url>
<!-- Можно добавить флаги для отключения некоторых фич -->
</properties>
</profile>
<!-- ==================== ZERNMC ==================== -->
<profile>
<id>zernmc</id>
<properties>
<build.profile>zernmc</build.profile>
<launcher.title>ZernMC Private Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
<!-- ==================== WINDOWS BUILD ==================== -->
<profile>
<id>win</id>
<activation>
<os>
<family>windows</family>
</os>
</activation>
<properties>
<javafx.classifier>win</javafx.classifier>
<os.suffix>win</os.suffix>
<skip.launch4j>false</skip.launch4j>
</properties>
</profile>
<!-- ==================== LINUX BUILD ==================== -->
<profile>
<id>linux</id>
<activation>
<os>
<family>unix</family>
</os>
</activation>
<properties>
<javafx.classifier>linux</javafx.classifier>
<os.suffix>linux</os.suffix>
<skip.launch4j>true</skip.launch4j>
</properties>
</profile>
</profiles>
</project> </project>
@@ -1,10 +1,14 @@
package me.sashegdev.zernmc.launcher; package me.sashegdev.zernmc.launcher;
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.menu.*; import me.sashegdev.zernmc.launcher.menu.*;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu; import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.*; import me.sashegdev.zernmc.launcher.utils.*;
import me.sashegdev.zernmc.launcher.web.UIWindow;
import me.sashegdev.zernmc.launcher.web.WebServer;
import java.awt.GraphicsEnvironment;
import java.io.IOException; import java.io.IOException;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
@@ -12,33 +16,100 @@ 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.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.List; import java.util.List;
public class Main { public class Main {
private static final String CURRENT_VERSION = Version.getCurrentVersion(); private static final String CURRENT_VERSION = Version.getCurrentVersion();
private static final LauncherAPI api = new LauncherAPI();
public static void main(String[] args) throws IOException { public static void main(String[] args) throws Exception {
boolean cliMode = Arrays.asList(args).contains("--cli") || Arrays.asList(args).contains("-c");
if (cliMode) {
runTUI(args);
} else {
try {
startWebUI(args);
} catch (Exception e) {
System.err.println(ZAnsi.red("UI не запустился: " + e.getMessage()));
e.printStackTrace();
System.out.println(ZAnsi.yellow("Переключаюсь на режим TUI..."));
runTUI(args);
}
}
}
private static void startWebUI(String[] args) throws Exception {
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
System.setProperty("file.encoding", "UTF-8");
int startPort = 8080;
for (int i = 0; i < args.length - 1; i++) {
if (args[i].equals("--port") || args[i].equals("-p")) {
startPort = Integer.parseInt(args[i + 1]);
}
}
System.out.println(ZAnsi.brightGreen("Запуск Web UI..."));
System.out.println(ZAnsi.cyan("Поиск свободного порта..."));
int port = WebServer.findFreePort(startPort);
// Запускаем WebServer в отдельном потоке
Thread serverThread = new Thread(() -> {
try {
WebServer.start(port);
} catch (Exception e) {
System.err.println("WebServer error: " + e.getMessage());
}
});
serverThread.setDaemon(true);
serverThread.start();
// Даем серверу время запуститься
Thread.sleep(1000);
// Проверяем headless перед запуском JavaFX (только для не-Windows систем)
if (!System.getProperty("os.name").toLowerCase().contains("win")) {
boolean isHeadless = java.awt.GraphicsEnvironment.isHeadless();
String display = System.getenv("DISPLAY");
if (isHeadless && (display == null || display.isEmpty())) {
System.out.println(ZAnsi.yellow("Дисплей недоступен, переключаюсь на TUI..."));
WebServer.stop();
runTUI(args);
return;
}
}
// Проверка обновлений лаунчера
checkAndAutoUpdateLauncher();
// Запускаем JavaFX окно
UIWindow.start(port);
}
private static void runTUI(String[] args) throws IOException {
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true"); System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
System.setProperty("file.encoding", "UTF-8"); System.setProperty("file.encoding", "UTF-8");
System.setProperty("sun.err.encoding", "UTF-8"); System.setProperty("sun.err.encoding", "UTF-8");
System.setProperty("sun.stdout.encoding", "UTF-8"); System.setProperty("sun.stdout.encoding", "UTF-8");
java.nio.charset.Charset.defaultCharset();
ZAnsi.install(); ZAnsi.install();
System.out.print("\033[H\033[2J"); System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION)); System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION) + ZAnsi.cyan(" [CLI mode]"));
//проверка всех сервисов при старте // Проверка всех сервисов при старте
ZHttpClient.checkAllServicesOnStartup(); ZHttpClient.checkAllServicesOnStartup();
checkAndAutoUpdateLauncher(); checkAndAutoUpdateLauncher();
// === АВТОРИЗАЦИЯ === // === АВТОРИЗАЦИЯ (используем новый API) ===
System.out.println(ZAnsi.cyan("Проверка авторизации...")); System.out.println(ZAnsi.cyan("Проверка авторизации..."));
boolean sessionRestored = AuthManager.loadSavedSession(); var sessionResponse = api.checkSession();
if (!sessionRestored) { if (!sessionResponse.isSuccess()) {
LoginMenu loginMenu = new LoginMenu(); LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show(); boolean loggedIn = loginMenu.show();
if (!loggedIn) { if (!loggedIn) {
@@ -47,10 +118,11 @@ public class Main {
System.exit(0); System.exit(0);
} }
} else { } else {
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!")); var sessionInfo = sessionResponse.getData();
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.getUsername() + "!"));
} }
// === КОНЕЦ АВТОРИЗАЦИИ ===
// === ГЛАВНЫЙ ЦИКЛ ===
try { try {
mainLoop(); mainLoop();
} catch (Exception e) { } catch (Exception e) {
@@ -63,7 +135,6 @@ public class Main {
private static void checkAndAutoUpdateLauncher() { private static void checkAndAutoUpdateLauncher() {
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера...")); System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
try { try {
String json = ZHttpClient.getLauncherVersionInfo(); String json = ZHttpClient.getLauncherVersionInfo();
String serverVersion = extractVersion(json); String serverVersion = extractVersion(json);
@@ -74,13 +145,11 @@ public class Main {
if (Version.isNewer(CURRENT_VERSION, serverVersion)) { if (Version.isNewer(CURRENT_VERSION, serverVersion)) {
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")")); System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n")); System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
performAutoUpdate(serverVersion); performAutoUpdate(serverVersion);
restartLauncher(); restartLauncher();
} else { } else {
System.out.println(ZAnsi.brightGreen("Лаунчер актуален.")); System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера.")); System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
@@ -109,9 +178,7 @@ public class Main {
long size = Files.size(tempJar); long size = Files.size(tempJar);
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)")); System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
// Заменяем текущий jar
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING); Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!")); System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
} }
@@ -119,12 +186,20 @@ public class Main {
try { try {
String javaPath = System.getProperty("java.home") + "/bin/java"; String javaPath = System.getProperty("java.home") + "/bin/java";
String jarPath = getCurrentJarPath().toAbsolutePath().toString(); String jarPath = getCurrentJarPath().toAbsolutePath().toString();
String launcherDir = jarPath.substring(0, jarPath.lastIndexOf(java.io.File.separator));
String javafxPath = launcherDir + java.io.File.separator + "lib-javafx";
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией...")); System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
new ProcessBuilder(javaPath, "-jar", jarPath) ProcessBuilder pb = new ProcessBuilder(
.inheritIO() javaPath,
.start(); "--module-path=" + javafxPath,
"--add-modules=javafx.controls,javafx.web",
"--add-reads=javafx.graphics=ALL-UNNAMED",
"-jar", jarPath
);
pb.inheritIO();
pb.start();
System.exit(0); System.exit(0);
} catch (Exception e) { } catch (Exception e) {
@@ -152,27 +227,73 @@ public class Main {
} }
} }
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
private static void mainLoop() throws Exception { private static void mainLoop() throws Exception {
if (Config.isZernMCBuild()) {
zernMCFlow();
} else {
globalFlow();
}
}
// ====================== ZERNMC FLOW ======================
private static void zernMCFlow() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
// 1. Проверка подключения к серверу
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
try {
String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("✓ Сервер доступен"));
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу"));
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage()));
ConsoleUtils.pause();
System.exit(1);
}
// 2. Авторизация
boolean sessionRestored = AuthManager.loadSavedSession();
if (!sessionRestored) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.exit(0);
}
} else {
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
}
// 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow)
LaunchMenu launchMenu = new LaunchMenu();
launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри
}
// ====================== GLOBAL FLOW ======================
private static void globalFlow() throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
List<String> options = List.of( List<String> options = List.of(
"Запустить игру", "Запустить игру",
"Проверка обновлений", "Проверка обновлений",
"Настройки", "Настройки",
"Проверка подключения к серверам Zern", "Проверка подключения к серверам",
"Выход" "Выход"
); );
ArrowMenu menu = new ArrowMenu("Главное меню", options); ArrowMenu menu = new ArrowMenu("Главное меню", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == 4) { if (choice == -1 || choice == 4) {
System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.yellow("До свидания!")); System.out.println(ZAnsi.yellow("До свидания!"));
break; break;
} }
switch (choice) { switch (choice) {
case 0 -> new LaunchMenu().show(); case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
case 1 -> new UpdateMenu().show(); case 1 -> new UpdateMenu().show();
case 2 -> new SettingsMenu().show(); case 2 -> new SettingsMenu().show();
case 3 -> new ServerCheckMenu().show(); case 3 -> new ServerCheckMenu().show();
@@ -0,0 +1,33 @@
package me.sashegdev.zernmc.launcher.api;
public class ApiResponse<T> {
private boolean success;
private T data;
private String error;
public ApiResponse(boolean success, T data, String error) {
this.success = success;
this.data = data;
this.error = error;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, null);
}
public static <T> ApiResponse<T> error(String error) {
return new ApiResponse<>(false, null, error);
}
public boolean isSuccess() {
return success;
}
public T getData() {
return data;
}
public String getError() {
return error;
}
}
@@ -0,0 +1,81 @@
package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.install.InstallService;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import java.util.List;
/**
* Центральный фасад для внутреннего API лаунчера.
* Используется как единая точка входа для UI и других компонентов.
*/
public class LauncherAPI {
private final AuthService authService;
private final InstanceService instanceService;
private final LaunchService launchService;
private final InstallService installService;
public LauncherAPI() {
this.authService = new AuthService();
this.instanceService = new InstanceService();
this.launchService = new LaunchService();
this.installService = new InstallService();
}
public AuthService auth() {
return authService;
}
public InstanceService instances() {
return instanceService;
}
public LaunchService launch() {
return launchService;
}
public InstallService install() {
return installService;
}
// ====================== Удобные методы ======================
public boolean isLoggedIn() {
return authService.isLoggedIn();
}
public String getCurrentUsername() {
return authService.getCurrentUsername();
}
public ApiResponse<AuthService.SessionInfo> checkSession() {
return authService.checkSession();
}
public ApiResponse<AuthService.LoginResult> login(String username, String password) {
return authService.login(username, password);
}
public ApiResponse<Boolean> logout() {
return authService.logout();
}
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
return instanceService.getAllInstances();
}
public ApiResponse<LaunchService.InstanceInfo> getLaunchInfo(String instanceName) {
return launchService.getLaunchInfo(instanceName);
}
public ApiResponse<LaunchService.LaunchInfo> prepareLaunch(String instanceName) {
return launchService.prepareLaunch(instanceName);
}
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
return launchService.launch(instanceName);
}
}
@@ -0,0 +1,134 @@
package me.sashegdev.zernmc.launcher.api.auth;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException;
public class AuthService {
public ApiResponse<LoginResult> login(String username, String password) {
try {
AuthManager.AuthResult result = AuthManager.login(username, password);
if (result.success) {
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
return ApiResponse.success(loginResult);
}
return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
} catch (Exception e) {
return ApiResponse.error("Ошибка авторизации: " + e.getMessage());
}
}
public ApiResponse<Boolean> logout() {
try {
AuthManager.logout();
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Ошибка при выходе: " + e.getMessage());
}
}
public ApiResponse<SessionInfo> checkSession() {
try {
boolean restored = AuthManager.loadSavedSession();
if (restored) {
SessionInfo info = new SessionInfo(
AuthManager.getUsername(),
AuthManager.getAccessToken(),
AuthManager.hasActivePass()
);
return ApiResponse.success(info);
}
return ApiResponse.error("Сессия не найдена");
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки сессии: " + e.getMessage());
}
}
public ApiResponse<Boolean> activatePass(String passCode) {
try {
String response = post("/auth/pass/activate",
"{\"code\":\"" + passCode + "\"}");
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Ошибка активации проходки: " + e.getMessage());
}
}
private String post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
java.net.URL url = new java.net.URL(fullUrl);
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
if (AuthManager.getAccessToken() != null && !AuthManager.getAccessToken().equals("0")) {
conn.setRequestProperty("Authorization", "Bearer " + AuthManager.getAccessToken());
}
conn.setDoOutput(true);
try (var os = conn.getOutputStream()) {
byte[] input = jsonBody.getBytes(java.nio.charset.StandardCharsets.UTF_8);
os.write(input);
}
int statusCode = conn.getResponseCode();
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
String responseBody;
try (var scanner = new java.util.Scanner(is, java.nio.charset.StandardCharsets.UTF_8.name())) {
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
}
conn.disconnect();
if (statusCode != 200) {
throw new IOException("HTTP " + statusCode + ": " + responseBody);
}
return responseBody;
}
public boolean isLoggedIn() {
return AuthManager.isLoggedIn();
}
public String getCurrentUsername() {
return AuthManager.getUsername();
}
public static class LoginResult {
private String username;
private String token;
public LoginResult(String username, String token) {
this.username = username;
this.token = token;
}
public String getUsername() { return username; }
public String getToken() { return token; }
}
public static class SessionInfo {
private String username;
private String token;
private boolean passActive;
public SessionInfo(String username, String token, boolean passActive) {
this.username = username;
this.token = token;
this.passActive = passActive;
}
public String getUsername() { return username; }
public String getToken() { return token; }
public boolean isPassActive() { return passActive; }
}
}
@@ -0,0 +1,226 @@
package me.sashegdev.zernmc.launcher.api.install;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class InstallService {
private PackDownloader.ProgressCallback progressCallback;
public void setProgressCallback(PackDownloader.ProgressCallback callback) {
this.progressCallback = callback;
}
public ApiResponse<InstallResult> installZernMCPack(String packName, String instanceName) {
try {
boolean created = InstanceManager.createInstanceFolder(instanceName);
if (!created) {
return ApiResponse.error("Сборка с таким именем уже существует: " + instanceName);
}
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Не удалось создать директорию сборки");
}
PackDownloader downloader = new PackDownloader(instance);
if (progressCallback != null) {
downloader.setProgressCallback(progressCallback);
}
// Получаем список доступных сборок
List<ServerPack> availablePacks = downloader.getAvailablePacks();
// Находим нужную сборку
ServerPack selectedPack = availablePacks.stream()
.filter(p -> p.getName().equals(packName))
.findFirst()
.orElse(null);
if (selectedPack == null) {
return ApiResponse.error("Сборка не найдена: " + packName);
}
boolean success = downloader.installOrUpdatePack(packName, selectedPack);
if (success) {
return ApiResponse.success(new InstallResult(
instanceName,
selectedPack.getMinecraftVersion(),
selectedPack.getLoaderType(),
selectedPack.getVersion()
));
} else {
return ApiResponse.error("Не удалось установить сборку");
}
} catch (Exception e) {
return ApiResponse.error("Ошибка установки: " + e.getMessage());
}
}
public ApiResponse<UpdateCheckResult> checkForUpdates(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null || !instance.isServerPack()) {
return ApiResponse.success(new UpdateCheckResult(false, false, 0, 0));
}
PackDownloader downloader = new PackDownloader(instance);
int serverVersion = downloader.checkForUpdates(instance.getServerPackName());
boolean hasUpdate = serverVersion > 0;
return ApiResponse.success(new UpdateCheckResult(
hasUpdate,
true,
instance.getServerVersion(),
serverVersion
));
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки обновлений: " + e.getMessage());
}
}
public ApiResponse<HashCheckResult> verifyHashes(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
if (!instance.isServerPack() || instance.getServerPackName() == null) {
return ApiResponse.success(new HashCheckResult(false, List.of()));
}
PackDownloader downloader = new PackDownloader(instance);
Map<String, String> localFiles = downloader.scanLocalFiles();
// Отправляем хеши на сервер через diff
var diff = downloader.getDiff(instance.getServerPackName(), localFiles);
List<String> mismatched = new ArrayList<>();
for (var f : diff.getToDownload()) {
mismatched.add(f.getPath());
}
mismatched.addAll(diff.getToUpdate());
mismatched.addAll(diff.getToDelete());
boolean hasMismatches = !mismatched.isEmpty();
return ApiResponse.success(new HashCheckResult(hasMismatches, mismatched));
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки хешей: " + e.getMessage());
}
}
public ApiResponse<PlayTimeInfo> getPlayTime(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
if (instance.isServerPack()) {
// TODO: Для ZernMC получаем время с сервера
// String response = ZHttpClient.get("/users/me/playtime?pack=" + instance.getServerPackName());
// Пока возвращаем 0 - в будущем интегрировать с сервером
return ApiResponse.success(new PlayTimeInfo(0, true));
}
// Для локальных сборок возвращаем 0
return ApiResponse.success(new PlayTimeInfo(0, false));
} catch (Exception e) {
return ApiResponse.error("Ошибка получения времени: " + e.getMessage());
}
}
private int extractPlayTime(String json) {
try {
// Простой парсинг JSON
String minutes = json.replaceAll(".*\"minutes\"\\s*:\\s*(\\d+).*", "$1");
return Integer.parseInt(minutes);
} catch (Exception e) {
return 0;
}
}
public static class InstallResult {
private String name;
private String mcVersion;
private String loaderType;
private int serverVersion;
public InstallResult(String name, String mcVersion, String loaderType, int serverVersion) {
this.name = name;
this.mcVersion = mcVersion;
this.loaderType = loaderType;
this.serverVersion = serverVersion;
}
public String getName() { return name; }
public String getMcVersion() { return mcVersion; }
public String getLoaderType() { return loaderType; }
public int getServerVersion() { return serverVersion; }
}
public static class UpdateCheckResult {
private boolean hasUpdate;
private boolean isServerPack;
private int currentVersion;
private int latestVersion;
public UpdateCheckResult(boolean hasUpdate, boolean isServerPack, int currentVersion, int latestVersion) {
this.hasUpdate = hasUpdate;
this.isServerPack = isServerPack;
this.currentVersion = currentVersion;
this.latestVersion = latestVersion;
}
public boolean isHasUpdate() { return hasUpdate; }
public boolean isServerPack() { return isServerPack; }
public int getCurrentVersion() { return currentVersion; }
public int getLatestVersion() { return latestVersion; }
}
public static class HashCheckResult {
private boolean hasMismatches;
private List<String> mismatchedFiles;
public HashCheckResult(boolean hasMismatches, List<String> mismatchedFiles) {
this.hasMismatches = hasMismatches;
this.mismatchedFiles = mismatchedFiles;
}
public boolean hasMismatches() { return hasMismatches; }
public List<String> getMismatchedFiles() { return mismatchedFiles; }
}
public static class PlayTimeInfo {
private int totalMinutes;
private boolean fromServer;
public PlayTimeInfo(int totalMinutes, boolean fromServer) {
this.totalMinutes = totalMinutes;
this.fromServer = fromServer;
}
public int getTotalMinutes() { return totalMinutes; }
public boolean isFromServer() { return fromServer; }
public String getFormattedTime() {
int hours = totalMinutes / 60;
int minutes = totalMinutes % 60;
if (hours > 0) {
return hours + "ч " + minutes + "м";
}
return minutes + "м";
}
}
}
@@ -0,0 +1,106 @@
package me.sashegdev.zernmc.launcher.api.instance;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
public class InstanceService {
public ApiResponse<List<InstanceInfo>> getAllInstances() {
try {
List<Instance> instances = InstanceManager.getAllInstances();
List<InstanceInfo> infoList = instances.stream()
.map(this::toInstanceInfo)
.collect(Collectors.toList());
return ApiResponse.success(infoList);
} catch (IOException e) {
return ApiResponse.error("Ошибка получения списка сборок: " + e.getMessage());
}
}
public ApiResponse<InstanceInfo> getInstance(String name) {
try {
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + name);
}
return ApiResponse.success(toInstanceInfo(instance));
} catch (Exception e) {
return ApiResponse.error("Ошибка получения сборки: " + e.getMessage());
}
}
public ApiResponse<InstanceInfo> createInstance(String name) {
try {
boolean created = InstanceManager.createInstanceFolder(name);
if (!created) {
return ApiResponse.error("Сборка с таким именем уже существует: " + name);
}
Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(toInstanceInfo(instance));
} catch (IOException e) {
return ApiResponse.error("Ошибка создания сборки: " + e.getMessage());
}
}
public ApiResponse<Boolean> deleteInstance(String name) {
try {
boolean deleted = InstanceManager.deleteInstance(name);
if (!deleted) {
return ApiResponse.error("Не удалось удалить сборку: " + name);
}
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Ошибка удаления сборки: " + e.getMessage());
}
}
public ApiResponse<Boolean> isInstanceExists(String name) {
try {
Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(instance != null);
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки сборки: " + e.getMessage());
}
}
private InstanceInfo toInstanceInfo(Instance instance) {
return new InstanceInfo(
instance.getName(),
instance.getPath().toString(),
instance.getMinecraftVersion(),
instance.getLoaderType(),
instance.isServerPack(),
instance.getServerPackName()
);
}
public static class InstanceInfo {
private String name;
private String path;
private String version;
private String loaderType;
private boolean isServerPack;
private String serverPackName;
public InstanceInfo(String name, String path, String version, String loaderType, boolean isServerPack, String serverPackName) {
this.name = name;
this.path = path;
this.version = version;
this.loaderType = loaderType;
this.isServerPack = isServerPack;
this.serverPackName = serverPackName;
}
public String getName() { return name; }
public String getPath() { return path; }
public String getVersion() { return version; }
public String getLoaderType() { return loaderType; }
public boolean isServerPack() { return isServerPack; }
public String getServerPackName() { return serverPackName; }
}
}
@@ -0,0 +1,157 @@
package me.sashegdev.zernmc.launcher.api.launch;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
public class LaunchService {
public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions();
List<String> command = builder.build(options);
LaunchInfo info = new LaunchInfo(
instanceName,
command,
instance.getPath().toString()
);
return ApiResponse.success(info);
} catch (Exception e) {
return ApiResponse.error("Ошибка подготовки запуска: " + e.getMessage());
}
}
public ApiResponse<ProcessInfo> launch(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions();
List<String> command = builder.build(options);
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile());
processBuilder.inheritIO();
Process process = processBuilder.start();
ProcessInfo info = new ProcessInfo(
instanceName,
process.pid(),
"RUNNING"
);
return ApiResponse.success(info);
} catch (Exception e) {
return ApiResponse.error("Ошибка запуска: " + e.getMessage());
}
}
public ApiResponse<Boolean> isReady(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
Path versionJson = instance.getPath().resolve("version.json");
boolean hasVersionJson = versionJson.toFile().exists();
return ApiResponse.success(hasVersionJson);
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки готовности: " + e.getMessage());
}
}
public ApiResponse<InstanceInfo> getLaunchInfo(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
InstanceInfo info = new InstanceInfo(
instance.getName(),
instance.getMinecraftVersion(),
instance.getLoaderType(),
instance.getLoaderVersion(),
instance.getAssetIndex()
);
return ApiResponse.success(info);
} catch (Exception e) {
return ApiResponse.error("Ошибка получения информации: " + e.getMessage());
}
}
public static class LaunchInfo {
private String instanceName;
private List<String> command;
private String workingDirectory;
public LaunchInfo(String instanceName, List<String> command, String workingDirectory) {
this.instanceName = instanceName;
this.command = command;
this.workingDirectory = workingDirectory;
}
public String getInstanceName() { return instanceName; }
public List<String> getCommand() { return command; }
public String getWorkingDirectory() { return workingDirectory; }
}
public static class ProcessInfo {
private String instanceName;
private long pid;
private String status;
public ProcessInfo(String instanceName, long pid, String status) {
this.instanceName = instanceName;
this.pid = pid;
this.status = status;
}
public String getInstanceName() { return instanceName; }
public long getPid() { return pid; }
public String getStatus() { return status; }
}
public static class InstanceInfo {
private String name;
private String minecraftVersion;
private String loaderType;
private String loaderVersion;
private String assetIndex;
public InstanceInfo(String name, String minecraftVersion, String loaderType,
String loaderVersion, String assetIndex) {
this.name = name;
this.minecraftVersion = minecraftVersion;
this.loaderType = loaderType;
this.loaderVersion = loaderVersion;
this.assetIndex = assetIndex;
}
public String getName() { return name; }
public String getMinecraftVersion() { return minecraftVersion; }
public String getLoaderType() { return loaderType; }
public String getLoaderVersion() { return loaderVersion; }
public String getAssetIndex() { return assetIndex; }
}
}
@@ -10,9 +10,12 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException; import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
public class AuthManager { public class AuthManager {
@@ -20,6 +23,18 @@ public class AuthManager {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static volatile AuthSession session = null; private static volatile AuthSession session = null;
private static volatile UserInfo userInfo = null;
// === Роли ===
public static final int ROLE_USER = 0;
public static final int ROLE_PASS_HOLDER = 1;
public static final int ROLE_MODERATOR = 2;
public static final int ROLE_ELDER = 3;
public static final int ROLE_CREATOR = 4;
// === Права доступа ===
public static final String PERM_VIEW_PACKS = "view_packs";
public static final String PERM_DOWNLOAD_PACK = "download_pack";
public static boolean loadSavedSession() { public static boolean loadSavedSession() {
if (!Files.exists(AUTH_FILE)) return false; if (!Files.exists(AUTH_FILE)) return false;
@@ -29,6 +44,8 @@ public class AuthManager {
if (loaded == null || loaded.accessToken == null) return false; if (loaded == null || loaded.accessToken == null) return false;
session = loaded; session = loaded;
userInfo = fetchUserInfo();
if (isAccessTokenExpired()) { if (isAccessTokenExpired()) {
return tryRefresh(); return tryRefresh();
} }
@@ -38,6 +55,7 @@ public class AuthManager {
} }
} }
// ====================== АВТОРИЗАЦИЯ ======================
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);
} }
@@ -49,17 +67,13 @@ public class AuthManager {
private static AuthResult authRequest(String endpoint, String username, String password) { private static AuthResult authRequest(String endpoint, String username, String password) {
try { try {
String body = GSON.toJson(new LoginRequest(username, password)); String body = GSON.toJson(new LoginRequest(username, password));
//System.out.println(ZAnsi.cyan("[AUTH] Отправка запроса: " + endpoint));
SimpleHttpResponse resp = post(endpoint, body); SimpleHttpResponse resp = post(endpoint, body);
//System.out.println(ZAnsi.cyan("[AUTH] Ответ: HTTP " + resp.statusCode()));
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;
saveSession(); saveSession();
userInfo = fetchUserInfo();
return AuthResult.ok(); return AuthResult.ok();
} else if (resp.statusCode() == 422) { } else if (resp.statusCode() == 422) {
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body())); return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
@@ -67,7 +81,6 @@ public class AuthManager {
return AuthResult.fail(extractError(resp.body())); return AuthResult.fail(extractError(resp.body()));
} }
} catch (Exception e) { } catch (Exception e) {
//System.err.println(ZAnsi.red("[AUTH] Исключение: " + e.getMessage()));
e.printStackTrace(); e.printStackTrace();
return AuthResult.fail("Ошибка соединения: " + e.getMessage()); return AuthResult.fail("Ошибка соединения: " + e.getMessage());
} }
@@ -75,11 +88,12 @@ public class AuthManager {
public static void logout() { public static void logout() {
if (session != null && session.refreshToken != null) { if (session != null && session.refreshToken != null) {
try { try {
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}"); post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
session = null; session = null;
userInfo = null;
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
} }
@@ -113,16 +127,18 @@ public class AuthManager {
try { try {
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}"; String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
SimpleHttpResponse resp = post("/auth/refresh", body); SimpleHttpResponse resp = post("/auth/refresh", body);
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();
saveSession(); saveSession();
return true; return true;
} }
} catch (Exception ignored) {} } catch (Exception ignored) {}
session = null; session = null;
userInfo = null;
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
return false; return false;
} }
@@ -136,19 +152,82 @@ public class AuthManager {
} }
} }
// ==================== ПОЛУЧЕНИЕ ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ====================
private static UserInfo fetchUserInfo() {
if (!isLoggedIn() || session.accessToken == null) return null;
try {
// Используем существующий метод ZHttpClient.get() + вручную добавляем токен
java.net.HttpURLConnection conn = null;
try {
URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me");
conn = (java.net.HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
int responseCode = conn.getResponseCode();
if (responseCode != 200) return null;
StringBuilder response = new StringBuilder();
try (var reader = new java.io.BufferedReader(
new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
return GSON.fromJson(response.toString(), UserInfo.class);
} finally {
if (conn != null) conn.disconnect();
}
} catch (Exception e) {
System.err.println("Не удалось получить UserInfo: " + e.getMessage());
return null;
}
}
// ==================== ПРОВЕРКИ ПРАВ ====================
public static boolean hasPass() {
if (userInfo != null) return userInfo.has_pass;
return getRole() >= ROLE_PASS_HOLDER;
}
public static boolean canViewPacks() {
if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_VIEW_PACKS);
}
return hasPass(); // fallback для старых аккаунтов
}
public static boolean canDownloadPacks() {
if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
}
return hasPass(); // fallback
}
public static int getRole() {
return session != null ? session.role : ROLE_USER;
}
// ====================== POST ======================
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception { private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint; String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
HttpURLConnection conn = null;
java.net.HttpURLConnection conn = null;
try { try {
java.net.URL url = java.net.URI.create(fullUrl).toURL(); URL url = new URL(fullUrl);
conn = (java.net.HttpURLConnection) url.openConnection(); conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST"); conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
conn.setRequestProperty("Connection", "close");
// Добавляем токен авторизации, если есть сессия
if (session != null && session.accessToken != null) { if (session != null && session.accessToken != null) {
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken); conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
} }
@@ -157,19 +236,19 @@ public class AuthManager {
conn.setConnectTimeout(15000); conn.setConnectTimeout(15000);
conn.setReadTimeout(15000); conn.setReadTimeout(15000);
try (java.io.OutputStream os = conn.getOutputStream()) { byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); conn.setFixedLengthStreamingMode(input.length);
os.write(input, 0, input.length);
try (var os = conn.getOutputStream()) {
os.write(input);
os.flush();
} }
int statusCode = conn.getResponseCode(); int statusCode = conn.getResponseCode();
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
java.io.InputStream is = (statusCode >= 200 && statusCode < 300)
? conn.getInputStream()
: conn.getErrorStream();
String responseBody; String responseBody;
try (java.util.Scanner scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) { try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
} }
@@ -183,19 +262,13 @@ public class AuthManager {
private static String extractError(String body) { private static String extractError(String body) {
try { try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject(); JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("detail")) { if (json.has("detail")) {
if (json.get("detail").isJsonArray()) { if (json.get("detail").isJsonArray()) {
return json.getAsJsonArray("detail").get(0).getAsJsonObject() return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
.get("msg").getAsString();
} }
return json.get("detail").getAsString(); return json.get("detail").getAsString();
} }
if (json.has("error")) {
return json.get("error").getAsString();
}
} catch (Exception ignored) {} } catch (Exception ignored) {}
return body.length() > 200 ? body.substring(0, 200) + "..." : body; return body.length() > 200 ? body.substring(0, 200) + "..." : body;
} }
@@ -203,36 +276,27 @@ public class AuthManager {
if (!isLoggedIn()) return false; if (!isLoggedIn()) return false;
try { try {
String response = ZHttpClient.get("/auth/pass/my"); String response = ZHttpClient.get("/auth/pass/my");
return response.contains("\"is_active\":true"); JsonObject json = JsonParser.parseString(response).getAsJsonObject();
return json.has("has_active") && json.get("has_active").getAsBoolean();
} catch (Exception e) { } catch (Exception e) {
System.err.println("Не удалось проверить проходки: " + e.getMessage()); System.err.println(ZAnsi.red("Не удалось проверить проходки: ") + e.getMessage());
return false; return false;
} }
} }
public static String activatePass(String passCode) { public static String getPassStatus() {
if (!isLoggedIn()) return "Не авторизован";
try { try {
String json = "{\"pass_code\":\"" + passCode.toUpperCase() + "\"}"; String response = ZHttpClient.get("/auth/pass/my");
SimpleHttpResponse resp = post("/auth/pass/activate", json); JsonObject json = JsonParser.parseString(response).getAsJsonObject();
boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean();
System.out.println(ZAnsi.cyan("[AUTH] Активация проходки: HTTP " + resp.statusCode())); return hasActive ? "Есть активная проходка" : "Проходка отсутствует";
if (resp.statusCode() == 200) {
return "Проходка успешно активирована!";
} else if (resp.statusCode() == 401) {
return "Ошибка: Требуется авторизация. Перезайдите в аккаунт.";
} else {
String error = extractError(resp.body());
return "Ошибка: " + error;
}
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); return "Ошибка проверки";
return "Ошибка соединения: " + e.getMessage();
} }
} }
// ====================== ВНУТРЕННИЕ КЛАССЫ ====================== // ====================== ВНУТРЕННИЕ КЛАССЫ ======================
public static class AuthSession { public static class AuthSession {
@SerializedName("access_token") public String accessToken; @SerializedName("access_token") public String accessToken;
@SerializedName("refresh_token") public String refreshToken; @SerializedName("refresh_token") public String refreshToken;
@@ -240,12 +304,30 @@ public class AuthManager {
public transient long expiresAt; public transient long expiresAt;
public String username; public String username;
public String uuid; public String uuid;
public int role;
}
public static class UserInfo {
public int id;
public String username;
public String uuid;
public int role;
public String role_name;
public boolean has_pass;
public List<String> permissions;
public boolean hasPermission(String perm) {
return permissions != null && permissions.contains(perm);
}
} }
private static class LoginRequest { private static class LoginRequest {
final String username; final String username;
final String password; final String password;
LoginRequest(String u, String p) { this.username = u; this.password = p; } LoginRequest(String u, String p) {
this.username = u;
this.password = p;
}
} }
public static class AuthResult { public static class AuthResult {
@@ -261,12 +343,12 @@ public class AuthManager {
class SimpleHttpResponse { class SimpleHttpResponse {
final int statusCode; final int statusCode;
final String body; final String body;
SimpleHttpResponse(int statusCode, String body) { SimpleHttpResponse(int statusCode, String body) {
this.statusCode = statusCode; this.statusCode = statusCode;
this.body = body; this.body = body;
} }
int statusCode() { return statusCode; } int statusCode() { return statusCode; }
String body() { return body; } String body() { return body; }
} }
File diff suppressed because it is too large Load Diff
@@ -148,14 +148,54 @@ public class LoginMenu {
* Читаем пароль — стараемся скрыть вывод через Console, * Читаем пароль — стараемся скрыть вывод через Console,
* если недоступно (IDE/терминал без TTY) — читаем обычным способом. * если недоступно (IDE/терминал без TTY) — читаем обычным способом.
*/ */
private String readPassword(String prompt) { private String readPassword(String prompt) throws IOException {
java.io.Console console = System.console(); org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder()
if (console != null) { .system(true)
char[] chars = console.readPassword(prompt); .jna(true)
return chars != null ? new String(chars) : ""; .build();
passTerminal.enterRawMode();
passTerminal.writer().print(prompt);
passTerminal.writer().flush();
StringBuilder password = new StringBuilder();
try {
while (true) {
int key = passTerminal.reader().read();
if (key == 27) {
// Escape sequence — consume remaining bytes (arrow keys, etc.)
int next = passTerminal.reader().read(50);
if (next == 91) { // '[' — arrow key sequence
passTerminal.reader().read(50); // consume 'A'/'B'/'C'/'D'
}
continue;
}
if (key == 13 || key == 10) { // Enter
passTerminal.writer().println();
break;
} else if (key == 127 || key == 8) { // Backspace
if (password.length() > 0) {
password.setLength(password.length() - 1);
passTerminal.writer().print("\b \b");
passTerminal.writer().flush();
}
} else if (key == 3) { // Ctrl+C
passTerminal.writer().println();
System.exit(0);
} else if (key >= 32 && key < 127) { // Printable characters
password.append((char) key);
passTerminal.writer().print('*');
passTerminal.writer().flush();
}
}
} finally {
passTerminal.close();
} }
// Fallback: в IDE пароль будет виден
return Input.readLine(prompt); return password.toString();
} }
private void printBanner() { private void printBanner() {
@@ -16,71 +16,83 @@ import java.util.List;
public class ServerCheckMenu { public class ServerCheckMenu {
public void show() throws IOException { public void show() throws IOException {
List<String> options = List.of( while (true) {
"Проверить подключение к ZernMC серверу", ConsoleUtils.clearScreen();
"Проверить доступ к Mojang (Minecraft)", System.out.println(ZAnsi.header("Диагностика подключения"));
"Проверить доступ к Fabric Meta",
"Назад в главное меню"
);
ArrowMenu menu = new ArrowMenu("Диагностика подключения", options); List<String> options = List.of(
int choice = menu.show(); "Проверить подключение к ZernMC серверу",
"Проверить доступ к Mojang (Minecraft)",
"Проверить доступ к Fabric Meta",
"Проверить доступ к Forge Maven",
"Назад в главное меню"
);
if (choice == -1 || choice == 4) return; ArrowMenu menu = new ArrowMenu("Выберите проверку", options);
int choice = menu.show();
ConsoleUtils.clearScreen(); if (choice == -1 || choice == 4) {
return;
}
switch (choice) { ConsoleUtils.clearScreen();
case 0 -> checkZernServer();
case 1 -> checkMojang(); switch (choice) {
case 2 -> checkFabric(); case 0 -> checkZernServer();
case 1 -> checkMojang();
case 2 -> checkFabric();
case 3 -> checkForge();
}
ConsoleUtils.pause();
} }
ConsoleUtils.pause();
} }
private void checkZernServer() { private void checkZernServer() {
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу...")); System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
try { try {
String response = ZHttpClient.get("/health"); String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("Сервер успешно подключён!")); System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!"));
System.out.println("Ответ: " + response); System.out.println(ZAnsi.white("Ответ сервера: ") + response);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Не удалось подключиться к ZernMC серверу")); System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу"));
System.out.println("Ошибка: " + e.getMessage()); System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
} }
} }
private void checkMojang() { private void checkMojang() {
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang...")); System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
try { try {
HttpClient client = HttpClient.newBuilder() HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8)) .connectTimeout(Duration.ofSeconds(10))
.build(); .build();
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json")) .uri(URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"))
.GET() .GET()
.build(); .build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("Mojang доступен")); System.out.println(ZAnsi.brightGreen("[OK] Mojang доступен"));
} else { } else {
System.out.println(ZAnsi.brightRed("Mojang вернул код " + response.statusCode())); System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode()));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Нет доступа к Mojang")); System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang"));
System.out.println("Ошибка: " + e.getMessage()); System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
} }
} }
private void checkFabric() { private void checkFabric() {
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta...")); System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
try { try {
HttpClient client = HttpClient.newBuilder() HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8)) .connectTimeout(Duration.ofSeconds(10))
.build(); .build();
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
@@ -91,13 +103,39 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("Fabric Meta доступен")); System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta доступен"));
} else { } else {
System.out.println(ZAnsi.brightRed("Fabric Meta вернул код " + response.statusCode())); System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta вернул код " + response.statusCode()));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Нет доступа к Fabric Meta")); System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta"));
System.out.println("Ошибка: " + e.getMessage()); System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
}
}
private void checkForge() {
System.out.println(ZAnsi.cyan("Проверка доступа к Forge Maven..."));
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven доступен"));
} else {
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven вернул код " + response.statusCode()));
}
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Forge Maven"));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
} }
} }
} }
@@ -64,9 +64,10 @@ public class UpdateMenu {
for (Instance instance : serverInstances) { for (Instance instance : serverInstances) {
PackDownloader downloader = new PackDownloader(instance); PackDownloader downloader = new PackDownloader(instance);
try { try {
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); int serverVersion = downloader.checkForUpdates(instance.getServerPackName());
boolean hasUpdate = serverVersion > 0;
if (hasUpdate) { if (hasUpdate) {
System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!")); System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!"));
updatableInstances.add(instance); updatableInstances.add(instance);
@@ -14,7 +14,7 @@ public class Instance {
private final Path path; private final Path path;
private String minecraftVersion; private String minecraftVersion;
private String loaderType; // vanilla, fabric, forge private String loaderType; // vanilla, fabric, forge, neoforge
private String loaderVersion; private String loaderVersion;
private String assetIndex; private String assetIndex;
private boolean isServerPack; // флаг, что это сборка с сервера private boolean isServerPack; // флаг, что это сборка с сервера
@@ -2,6 +2,7 @@ package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
@@ -41,6 +42,11 @@ public class MinecraftLib {
return installer.install(minecraftVersion, forgeVersion); return installer.install(minecraftVersion, forgeVersion);
} }
public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception {
NeoForgeInstaller installer = new NeoForgeInstaller(instance);
return installer.install(minecraftVersion, neoforgeVersion);
}
public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception { public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception {
FabricInstaller installer = new FabricInstaller(instance); FabricInstaller installer = new FabricInstaller(instance);
boolean success = installer.install(minecraftVersion, loaderVersion); boolean success = installer.install(minecraftVersion, loaderVersion);
@@ -76,8 +82,17 @@ public class MinecraftLib {
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(loaderType)) { } else if ("forge".equalsIgnoreCase(loaderType)) {
System.out.println(ZAnsi.yellow("Forge пока не поддерживается")); boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
return false; if (!forgeInstalled) {
System.out.println(ZAnsi.brightRed("Не удалось установить Forge"));
return false;
}
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
if (!neoforgeInstalled) {
System.out.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
return false;
}
} }
// 3. В будущем здесь будет diff и скачивание модов // 3. В будущем здесь будет diff и скачивание модов
@@ -129,7 +144,8 @@ public class MinecraftLib {
try (var stream = Files.walk(versionsDir)) { try (var stream = Files.walk(versionsDir)) {
stream.filter(Files::isDirectory) stream.filter(Files::isDirectory)
.filter(dir -> dir.getFileName().toString().contains("fabric-loader") || .filter(dir -> dir.getFileName().toString().contains("fabric-loader") ||
dir.getFileName().toString().contains("forge")) dir.getFileName().toString().contains("forge") ||
dir.getFileName().toString().contains("neoforge"))
.filter(dir -> !dir.getFileName().toString().contains(keepVersion)) .filter(dir -> !dir.getFileName().toString().contains(keepVersion))
.forEach(this::safeDeleteDirectory); .forEach(this::safeDeleteDirectory);
} }
@@ -163,6 +179,8 @@ public class MinecraftLib {
deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer); deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer);
} else if ("forge".equals(loaderType)) { } else if ("forge".equals(loaderType)) {
deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer); deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer);
} else if ("neoforge".equals(loaderType)) {
deleteAllExcept(libraries.resolve("net/neoforged/neoforge"), currentLoaderVer);
} }
// Также чистим versions/ от старых fabric/forge версий // Также чистим versions/ от старых fabric/forge версий
@@ -6,6 +6,8 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.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;
@@ -27,83 +29,142 @@ public class PackDownloader {
private final Instance instance; private final Instance instance;
private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
private final HttpClient httpClient = HttpClient.newHttpClient(); private final HttpClient httpClient = HttpClient.newHttpClient();
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; private ProgressCallback progressCallback;
public interface ProgressCallback {
void onProgress(ProgressInfo info);
}
public static class ProgressInfo {
private String phase;
private int totalFiles;
private int downloadedFiles;
private String currentFile;
private long fileSize;
private long downloadedBytes;
private int filePercent;
private int totalPercent;
private String eta;
public ProgressInfo(String phase, int totalFiles, int downloadedFiles, String currentFile,
long fileSize, long downloadedBytes, int filePercent, int totalPercent, String eta) {
this.phase = phase;
this.totalFiles = totalFiles;
this.downloadedFiles = downloadedFiles;
this.currentFile = currentFile;
this.fileSize = fileSize;
this.downloadedBytes = downloadedBytes;
this.filePercent = filePercent;
this.totalPercent = totalPercent;
this.eta = eta;
}
public String getPhase() { return phase; }
public int getTotalFiles() { return totalFiles; }
public int getDownloadedFiles() { return downloadedFiles; }
public String getCurrentFile() { return currentFile; }
public long getFileSize() { return fileSize; }
public long getDownloadedBytes() { return downloadedBytes; }
public int getFilePercent() { return filePercent; }
public int getTotalPercent() { return totalPercent; }
public String getEta() { return eta; }
}
public PackDownloader(Instance instance) { public PackDownloader(Instance instance) {
this.instance = instance; this.instance = instance;
} }
public void setProgressCallback(ProgressCallback callback) {
this.progressCallback = callback;
}
/** /**
* Получить список доступных паков с сервера * Получить список доступных паков с сервера
*/ */
public List<ServerPack> getAvailablePacks() throws Exception { public List<ServerPack> getAvailablePacks() throws Exception {
String response = ZHttpClient.get("/packs"); String accessToken = AuthManager.getAccessToken();
if (accessToken == null) {
// Для отладки - выведем ответ сервера throw new IOException("Не авторизован. Требуется проходка для просмотра сборок.");
System.out.println(ZAnsi.cyan("Ответ сервера: " + response));
JsonObject root = JsonParser.parseString(response).getAsJsonObject();
// Проверяем, есть ли поле "packs"
if (!root.has("packs")) {
System.out.println(ZAnsi.yellow("Сервер вернул неожиданный формат ответа"));
return new ArrayList<>();
} }
if (!AuthManager.canViewPacks()) {
throw new IOException("Для просмотра сборок требуется активная проходка");
}
// Используем HttpURLConnection для GET с авторизацией
java.net.HttpURLConnection connection = null;
try {
java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs");
connection = (java.net.HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
connection.setConnectTimeout(15000);
connection.setReadTimeout(15000);
int responseCode = connection.getResponseCode();
if (responseCode == 403) {
throw new IOException("Для просмотра сборок требуется активная проходка");
}
StringBuilder response = new StringBuilder();
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
if (responseCode != 200) {
throw new IOException("HTTP " + responseCode);
}
return parsePacksResponse(response.toString());
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private List<ServerPack> parsePacksResponse(String responseBody) {
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray packsArray = root.getAsJsonArray("packs"); JsonArray packsArray = root.getAsJsonArray("packs");
List<ServerPack> result = new ArrayList<>(); List<ServerPack> result = new ArrayList<>();
for (JsonElement elem : packsArray) { for (JsonElement elem : packsArray) {
JsonObject pack = elem.getAsJsonObject(); JsonObject pack = elem.getAsJsonObject();
// Пропускаем паки с ошибками if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
if (pack.has("error")) {
System.out.println(ZAnsi.yellow("Пак имеет ошибку: " + pack.get("error").getAsString()));
continue; continue;
} }
// Пропускаем паки со статусом not_scanned
if (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString())) {
System.out.println(ZAnsi.yellow("Пак " + pack.get("name").getAsString() + " не отсканирован на сервере"));
continue;
}
try { try {
// Пробуем получить name или pack_name (разные форматы) String name = pack.get("name").getAsString();
String name = null;
if (pack.has("name")) {
name = pack.get("name").getAsString();
} else if (pack.has("pack_name")) {
name = pack.get("pack_name").getAsString();
} else {
continue; // Пропускаем если нет имени
}
int version = pack.has("version") ? pack.get("version").getAsInt() : 0; int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
// Получаем остальные поля (могут отсутствовать)
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown"; String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla"; String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() ? pack.get("loader_version").getAsString() : ""; String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull()
? pack.get("loader_version").getAsString() : "";
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0; int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
// Парсим дату, если есть
LocalDateTime updatedAt = null; LocalDateTime updatedAt = null;
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) { if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
try { try {
updatedAt = parseDateTime(pack.get("updated_at").getAsString()); updatedAt = LocalDateTime.parse(pack.get("updated_at").getAsString(),
} catch (Exception e) { DateTimeFormatter.ISO_DATE_TIME);
// Игнорируем ошибки парсинга даты } catch (Exception ignored) {}
}
} }
result.add(new ServerPack(name, version, minecraftVersion, result.add(new ServerPack(name, version, minecraftVersion, loaderType,
loaderType, loaderVersion, updatedAt, filesCount)); loaderVersion, updatedAt, filesCount));
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.yellow("Ошибка парсинга пака: " + e.getMessage())); System.err.println("Ошибка парсинга пака: " + e.getMessage());
} }
} }
return result; return result;
} }
@@ -139,6 +200,12 @@ public class PackDownloader {
System.err.println(ZAnsi.brightRed("Не удалось установить Fabric")); System.err.println(ZAnsi.brightRed("Не удалось установить Fabric"));
return false; return false;
} }
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
return false;
}
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) { if (!success) {
@@ -201,15 +268,16 @@ public class PackDownloader {
/** /**
* Проверить наличие обновлений для серверной сборки * Проверить наличие обновлений для серверной сборки
* @return версия на сервере, или 0 если нет обновлений
*/ */
public boolean checkForUpdates(String packName) throws Exception { public int checkForUpdates(String packName) throws Exception {
if (!instance.isServerPack()) return false; if (!instance.isServerPack()) return 0;
PackManifest manifest = getPackManifest(packName); PackManifest manifest = getPackManifest(packName);
int serverVersion = manifest.getVersion(); int serverVersion = manifest.getVersion();
int localVersion = instance.getServerVersion(); int localVersion = instance.getServerVersion();
return serverVersion > localVersion; return serverVersion > localVersion ? serverVersion : 0;
} }
/** /**
@@ -248,7 +316,7 @@ public class PackDownloader {
/** /**
* Сканирование локальных файлов и вычисление хешей * Сканирование локальных файлов и вычисление хешей
*/ */
private Map<String, String> scanLocalFiles() throws IOException { public Map<String, String> scanLocalFiles() throws IOException {
Map<String, String> files = new HashMap<>(); Map<String, String> files = new HashMap<>();
Path instancePath = instance.getPath(); Path instancePath = instance.getPath();
@@ -288,26 +356,23 @@ public class PackDownloader {
} }
/** /**
* Отправить diff запрос на сервер * Отправить diff запрос на сервер (получить список файлов для обновления)
*/ */
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception { public DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
String json = gson.toJson(localFiles); String json = gson.toJson(localFiles);
System.out.println(ZAnsi.cyan("Отправка diff запроса для " + packName)); // Получаем токен авторизации
System.out.println(ZAnsi.cyan("JSON размер: " + json.length() + " байт")); String accessToken = AuthManager.getAccessToken();
System.out.println(ZAnsi.cyan("JSON тело: " + json)); if (accessToken == null) {
throw new IOException("Не авторизован. Требуется проходка для скачивания сборок.");
String baseUrl = ZHttpClient.getBaseUrl();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
} }
String url = baseUrl + "/pack/" + packName + "/diff"; if (!AuthManager.canDownloadPacks()) {
throw new IOException("Для скачивания сборок требуется активная проходка");
System.out.println(ZAnsi.cyan("URL: " + url)); }
// ПРОБЛЕМА: стандартный HttpClient может отправлять chunked encoding String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
// РЕШЕНИЕ: используем HttpURLConnection вместо HttpClient
// Используем HttpURLConnection для полного контроля
java.net.HttpURLConnection connection = null; java.net.HttpURLConnection connection = null;
try { try {
java.net.URL urlObj = new java.net.URL(url); java.net.URL urlObj = new java.net.URL(url);
@@ -315,21 +380,21 @@ public class PackDownloader {
connection.setRequestMethod("POST"); connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "application/json"); connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length)); connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length));
connection.setDoOutput(true); connection.setDoOutput(true);
connection.setConnectTimeout(30000); connection.setConnectTimeout(30000);
connection.setReadTimeout(30000); connection.setReadTimeout(30000);
// Отправляем JSON // Отправляем JSON
try (java.io.OutputStream os = connection.getOutputStream()) { try (java.io.OutputStream os = connection.getOutputStream()) {
byte[] input = json.getBytes("UTF-8"); byte[] input = json.getBytes("UTF-8");
os.write(input, 0, input.length); os.write(input, 0, input.length);
os.flush(); os.flush();
} }
int responseCode = connection.getResponseCode(); int responseCode = connection.getResponseCode();
System.out.println(ZAnsi.cyan("Diff ответ: HTTP " + responseCode));
// Читаем ответ // Читаем ответ
StringBuilder response = new StringBuilder(); StringBuilder response = new StringBuilder();
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
@@ -339,16 +404,19 @@ public class PackDownloader {
response.append(line); response.append(line);
} }
} }
String responseBody = response.toString(); String responseBody = response.toString();
System.out.println(ZAnsi.cyan("Тело ответа: " + responseBody));
if (responseCode == 403) {
if (responseCode != 200) { throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору.");
throw new IOException("HTTP " + responseCode + ": " + responseBody);
} }
if (responseCode != 200) {
throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody));
}
return gson.fromJson(responseBody, DiffResponse.class); return gson.fromJson(responseBody, DiffResponse.class);
} finally { } finally {
if (connection != null) { if (connection != null) {
connection.disconnect(); connection.disconnect();
@@ -356,6 +424,16 @@ public class PackDownloader {
} }
} }
private String extractErrorFromResponse(String body) {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("detail")) {
return json.get("detail").getAsString();
}
} catch (Exception ignored) {}
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
}
/** /**
* Применить diff (скачать новые файлы, удалить старые) * Применить diff (скачать новые файлы, удалить старые)
*/ */
@@ -363,16 +441,18 @@ public class PackDownloader {
System.out.println(ZAnsi.cyan("\nПрименение изменений:")); System.out.println(ZAnsi.cyan("\nПрименение изменений:"));
System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов"); System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов");
System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов"); System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов");
// Создаем директории если нужно if (progressCallback != null) {
progressCallback.onProgress(new ProgressInfo("starting", diff.getToDownload().size(), 0, "", 0, 0, 0, 0, ""));
}
try { try {
Files.createDirectories(instance.getPath()); Files.createDirectories(instance.getPath());
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage())); System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage()));
return false; return false;
} }
// Удаляем файлы
for (String filePath : diff.getToDelete()) { for (String filePath : diff.getToDelete()) {
Path fullPath = instance.getPath().resolve(filePath); Path fullPath = instance.getPath().resolve(filePath);
try { try {
@@ -383,85 +463,103 @@ public class PackDownloader {
System.err.println(ZAnsi.red(" Ошибка удаления " + filePath + ": " + e.getMessage())); System.err.println(ZAnsi.red(" Ошибка удаления " + filePath + ": " + e.getMessage()));
} }
} }
// Скачиваем файлы
AtomicInteger downloaded = new AtomicInteger(0); AtomicInteger downloaded = new AtomicInteger(0);
int total = diff.getToDownload().size(); int total = diff.getToDownload().size();
for (FileInfo file : diff.getToDownload()) { for (FileInfo file : diff.getToDownload()) {
String path = file.getPath(); String path = file.getPath();
Path fullPath = instance.getPath().resolve(path); Path fullPath = instance.getPath().resolve(path);
try { try {
// Создаем директории
Files.createDirectories(fullPath.getParent()); Files.createDirectories(fullPath.getParent());
// Скачиваем файл downloadFile(file, fullPath, progressCallback, downloaded.get(), total);
downloadFile(file, fullPath);
// Проверяем хеш
String actualHash = calculateHash(fullPath); String actualHash = calculateHash(fullPath);
if (!actualHash.equals(file.getHash())) { if (!actualHash.equals(file.getHash())) {
throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() + throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() +
", получен: " + actualHash); ", получен: " + actualHash);
} }
downloaded.incrementAndGet(); downloaded.incrementAndGet();
if (total > 0) { if (total > 0) {
ProgressBar.show("Скачивание", downloaded.get(), total, "файлов"); ProgressBar.show("Скачивание", downloaded.get(), total, "файлов");
} }
if (progressCallback != null) {
progressCallback.onProgress(new ProgressInfo("downloading", total, downloaded.get(), path,
file.getSize(), file.getSize(), 100, (downloaded.get() * 100) / total, ""));
}
} catch (Exception e) { } catch (Exception e) {
System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage())); System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage()));
return false; return false;
} }
} }
if (total > 0) { if (total > 0) {
ProgressBar.finish("Скачивание"); ProgressBar.finish("Скачивание");
} }
if (progressCallback != null) {
progressCallback.onProgress(new ProgressInfo("complete", total, total, "", 0, 0, 100, 100, ""));
}
return true; return true;
} }
/** /**
* Скачать один файл с сервера * Скачать один файл с сервера
*/ */
private void downloadFile(FileInfo file, Path destination) throws Exception { private void downloadFile(FileInfo file, Path destination) throws Exception {
downloadFile(file, destination, null, 0, 0);
}
private void downloadFile(FileInfo file, Path destination, ProgressCallback callback, int downloadedFiles, int totalFiles) throws Exception {
String url = ZHttpClient.getBaseUrl() + file.getUrl(); String url = ZHttpClient.getBaseUrl() + file.getUrl();
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(java.net.URI.create(url)) .uri(java.net.URI.create(url))
.GET() .GET()
.build(); .build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse<InputStream> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofInputStream()); HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode()); throw new IOException("HTTP " + response.statusCode());
} }
// Скачиваем с прогрессом
try (InputStream in = response.body(); try (InputStream in = response.body();
FileOutputStream out = new FileOutputStream(destination.toFile())) { FileOutputStream out = new FileOutputStream(destination.toFile())) {
byte[] buffer = new byte[8192]; byte[] buffer = new byte[8192];
int bytesRead; int bytesRead;
long totalRead = 0; long totalRead = 0;
long fileSize = file.getSize(); long fileSize = file.getSize();
long lastCallbackTime = 0;
while ((bytesRead = in.read(buffer)) != -1) { while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead); out.write(buffer, 0, bytesRead);
totalRead += bytesRead; totalRead += bytesRead;
if (fileSize > 0 && totalRead % 8192 == 0) { if (fileSize > 0) {
ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize); ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize);
long now = System.currentTimeMillis();
if (callback != null && now - lastCallbackTime > 200) {
int filePercent = (int) ((totalRead * 100) / fileSize);
int totalPercent = totalFiles > 0 ? ((downloadedFiles * 100 + filePercent) / totalFiles) : 0;
callback.onProgress(new ProgressInfo("downloading", totalFiles, downloadedFiles, file.getPath(),
fileSize, totalRead, filePercent, totalPercent, ""));
lastCallbackTime = now;
}
} }
} }
ProgressBar.clearLine();
} }
ProgressBar.clearLine();
} }
/** /**
@@ -486,17 +584,6 @@ public class PackDownloader {
return sb.toString(); return sb.toString();
} }
/**
* Парсинг даты из строки
*/
private LocalDateTime parseDateTime(String dateTimeStr) {
try {
return LocalDateTime.parse(dateTimeStr, DATE_FORMATTER);
} catch (Exception e) {
return null;
}
}
// ====================== Вложенные классы ====================== // ====================== Вложенные классы ======================
public static class PackManifest { public static class PackManifest {
@@ -0,0 +1,257 @@
package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.*;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashMap;
import java.util.Map;
public class NeoForgeInstaller {
private final Instance instance;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(30))
.build();
public NeoForgeInstaller(Instance instance) {
this.instance = instance;
}
public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Установка NeoForge " + neoForgeVersion + " для Minecraft " + mcVersion));
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
String assetIndex = vanillaInstaller.install(mcVersion);
if (assetIndex == null || assetIndex.isEmpty()) {
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
return false;
}
instance.setAssetIndex(assetIndex);
String mavenGroup = getMavenGroup(mcVersion);
String mavenArtifact = getMavenArtifact(mcVersion);
// Формируем путь к версии
String versionName = mcVersion + "-" + neoForgeVersion;
Path versionDir = instance.getPath().resolve("versions").resolve(versionName);
Files.createDirectories(versionDir);
// Скачиваем universal.jar (это основной JAR NeoForge)
String baseMavenUrl = "https://maven.neoforged.net/releases/"
+ mavenGroup.replace('.', '/') + "/"
+ mavenArtifact + "/"
+ neoForgeVersion + "/";
String universalJarUrl = baseMavenUrl + mavenArtifact + "-" + neoForgeVersion + "-universal.jar";
Path neoForgeJar = versionDir.resolve(versionName + ".jar");
System.out.println(ZAnsi.cyan("Скачивание NeoForge universal.jar..."));
downloadFileDirect(universalJarUrl, neoForgeJar);
// Создаем version.json вручную
System.out.println(ZAnsi.cyan("Создание version.json..."));
createVersionJson(versionDir.resolve(versionName + ".json"), mcVersion, neoForgeVersion, mavenArtifact);
// Скачиваем необходимые библиотеки
System.out.println(ZAnsi.cyan("Скачивание библиотек NeoForge..."));
downloadNeoForgeLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " успешно установлен!"));
instance.setMinecraftVersion(mcVersion);
instance.setLoaderType("neoforge");
instance.setLoaderVersion(neoForgeVersion);
return true;
}
private String getMavenGroup(String mcVersion) {
if (mcVersion.equals("1.20.1")) {
return "net.neoforged";
}
return "net.neoforged";
}
private String getMavenArtifact(String mcVersion) {
if (mcVersion.equals("1.20.1")) {
return "forge";
}
return "neoforge";
}
private void createLauncherProfile() throws IOException {
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
if (Files.exists(profilePath)) return;
String minimalProfile = """
{
"profiles": {},
"selectedProfile": "Default"
}
""";
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
System.out.println(ZAnsi.yellow("Создан launcher_profiles.json"));
}
private void downloadFileWithProgress(String url, Path target) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode());
}
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
try (InputStream in = response.body();
FileOutputStream out = new FileOutputStream(target.toFile())) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
int lastPercent = -1;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
totalRead += bytesRead;
if (contentLength > 0) {
int percent = (int) ((totalRead * 100) / contentLength);
if (percent != lastPercent) {
String downloaded = ProgressBar.formatBytes(totalRead);
String total = ProgressBar.formatBytes(contentLength);
ProgressBar.show("NeoForge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
lastPercent = percent;
}
} else {
char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4;
System.out.print("\rСкачивание NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
}
}
}
ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
}
private void downloadFileDirect(String url, Path target) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode() + " for " + url);
}
System.out.println(ZAnsi.green(" " + target.getFileName() + " завершено ✓"));
}
private void createVersionJson(Path jsonFile, String mcVersion, String neoForgeVersion, String mavenArtifact) throws IOException {
// Создаем минимальный version.json для NeoForge
String versionName = mcVersion + "-" + neoForgeVersion;
String json = """
{
"id": "%s",
"type": "release",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"inheritsFrom": "%s",
"arguments": {
"--tweakClass": "cpw.mods.fml.relauncher.CoreModManager"
},
"libraries": [
{"name": "net.neoforged:neoforge:%s"},
{"name": "cpw.mods:bootstraplauncher:1.1.2"},
{"name": "net.minecraftforge:unsafe:0.2.0"},
{"name": "net.minecraftforge:srgutils:0.4.4"},
{"name": "net.minecraftforge:modlauncher:10.2.1"},
{"name": "net.minecraftforge:coremods:5.0.1"},
{"name": "net.minecraftforge:accesstransformers:8.8"},
{"name": "net.minecraftforge:eventbus:6.0.5"},
{"name": "net.minecraftforge:forgemin:0.1.1"},
{"name": "net.minecraftforge:scanner:1.2.2"},
{"name": "com.google.code.gson:gson:2.10.1"},
{"name": "com.google.guava:guava:32.1.3-jre"},
{"name": "org.apache.commons:commons-lang3:3.13.0"},
{"name": "org.jline:jline-reader:3.12.1"},
{"name": "org.jline:jline-terminal:3.12.1"}
]
}
""".formatted(versionName, mcVersion, neoForgeVersion);
Files.writeString(jsonFile, json);
System.out.println(ZAnsi.green(" version.json создан ✓"));
}
private void downloadNeoForgeLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
System.out.println(ZAnsi.cyan("Скачивание библиотек NeoForge..."));
String baseMavenUrl = "https://maven.neoforged.net/releases/"
+ mavenGroup.replace('.', '/') + "/";
Path librariesDir = instance.getPath().resolve("libraries");
// Список основных библиотек NeoForge
String[][] libs = {
{mavenGroup, mavenArtifact, neoForgeVersion},
{"cpw.mods", "bootstraplauncher", "1.1.2"},
{"net.minecraftforge", "unsafe", "0.2.0"},
{"net.minecraftforge", "srgutils", "0.4.4"},
{"net.minecraftforge", "modlauncher", "10.2.1"},
{"net.minecraftforge", "coremods", "5.0.1"},
{"net.minecraftforge", "accesstransformers", "8.8"},
{"net.minecraftforge", "eventbus", "6.0.5"},
{"net.minecraftforge", "forgemin", "0.1.1"},
{"net.minecraftforge", "scanner", "1.2.2"}
};
for (String[] lib : libs) {
String group = lib[0].replace('.', '/');
String artifact = lib[1];
String version = lib[2];
String jarName = artifact + "-" + version + ".jar";
String mavenPath = group + "/" + artifact + "/" + version + "/" + jarName;
Path target = librariesDir.resolve(mavenPath);
if (Files.exists(target)) {
System.out.println(ZAnsi.green(" " + jarName + " уже есть ✓"));
continue;
}
Files.createDirectories(target.getParent());
String url = baseMavenUrl + mavenPath;
try {
downloadFileDirect(url, target);
} catch (Exception e) {
// Пробуем Maven Central как fallback
try {
String centralUrl = "https://repo1.maven.org/maven2/" + mavenPath;
downloadFileDirect(centralUrl, target);
} catch (Exception e2) {
System.out.println(ZAnsi.yellow(" Предупреждение: не удалось скачать " + jarName));
}
}
}
System.out.println(ZAnsi.green("Библиотеки NeoForge обработаны ✓"));
}
}
@@ -3,11 +3,14 @@ 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.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import org.json.JSONObject;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
public class LaunchCommandBuilder { public class LaunchCommandBuilder {
@@ -22,105 +25,266 @@ public class LaunchCommandBuilder {
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
// 1. Путь к Java String javaPath = "java";
String javaPath = getJavaPath();
command.add(javaPath); command.add(javaPath);
// 2. JVM аргументы
command.addAll(getJvmArguments(options)); command.addAll(getJvmArguments(options));
// 3. Natives
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()); command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
String loaderType = instance.getLoaderType().toLowerCase(); VersionManifest manifest = resolveVersionManifest();
if (manifest != null) {
if ("forge".equals(loaderType)) {
command.addAll(getForgeJvmArguments());
command.add("-cp"); command.add("-cp");
command.add(buildForgeClasspath()); command.add(buildClasspathFromManifest(manifest));
command.add("cpw.mods.modlauncher.Launcher");
command.addAll(getForgeArguments(options)); String mainClass = resolveMainClass(manifest);
command.add(mainClass);
command.addAll(resolveGameArguments(manifest, options));
} else { } else {
command.add("-cp"); command.add("-cp");
command.add(buildClasspath()); command.add(buildVanillaClasspath());
command.add(getMainClass()); command.add(getVanillaMainClass());
command.addAll(getMinecraftArguments(options)); command.addAll(getVanillaGameArguments(options));
} }
return command; return command;
} }
private String getJavaPath() { private VersionManifest resolveVersionManifest() {
return "java"; try {
Path versionJson = findVersionJson();
if (versionJson != null && Files.exists(versionJson)) {
String content = Files.readString(versionJson);
JSONObject json = new JSONObject(content);
System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName()));
return new VersionManifest(json);
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage()));
}
return null;
} }
private List<String> getJvmArguments(LaunchOptions options) { private Path findVersionJson() {
List<String> jvmArgs = new ArrayList<>(); Path versionsDir = instance.getPath().resolve("versions");
String loaderType = instance.getLoaderType().toLowerCase();
String mcVersion = instance.getMinecraftVersion();
String loaderVersion = instance.getLoaderVersion();
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096; if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
jvmArgs.add("-Xmx" + ramMB + "M"); String[] candidates = {
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M"); getVersionId(),
mcVersion + "-" + loaderType + "-" + loaderVersion,
loaderType + "-" + loaderVersion,
mcVersion + "-" + loaderVersion,
mcVersion
};
for (String candidate : candidates) {
Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json");
if (Files.exists(jsonPath)) {
return jsonPath;
}
}
jvmArgs.add("-XX:+UseG1GC"); try {
jvmArgs.add("-XX:+UnlockExperimentalVMOptions"); if (Files.exists(versionsDir)) {
jvmArgs.add("-XX:G1NewSizePercent=20"); try (var stream = Files.list(versionsDir)) {
jvmArgs.add("-XX:G1ReservePercent=20"); return stream
jvmArgs.add("-XX:MaxGCPauseMillis=50"); .filter(Files::isDirectory)
jvmArgs.add("-XX:G1HeapRegionSize=32M"); .filter(dir -> dir.getFileName().toString().contains("forge") ||
dir.getFileName().toString().contains("neoforge"))
.filter(dir -> dir.getFileName().toString().contains(mcVersion))
.findFirst()
.map(dir -> dir.resolve(dir.getFileName().toString() + ".json"))
.filter(Files::exists)
.orElse(null);
}
}
} catch (Exception ignored) {}
}
Path fallback = versionsDir.resolve(mcVersion).resolve(mcVersion + ".json");
if (Files.exists(fallback)) {
return fallback;
}
return null;
}
private String getVersionId() {
String loaderType = instance.getLoaderType().toLowerCase();
String mcVersion = instance.getMinecraftVersion();
String loaderVer = instance.getLoaderVersion();
if ("vanilla".equals(loaderType)) {
return mcVersion;
}
else if ("fabric".equals(loaderType)) {
String fabricId = instance.getFabricVersionId();
if (fabricId != null && !fabricId.isEmpty()) {
return fabricId;
}
return "fabric-loader-" + loaderVer + "-" + mcVersion;
}
else if ("forge".equals(loaderType)) {
return mcVersion + "-forge-" + loaderVer;
}
else if ("neoforge".equals(loaderType)) {
if (mcVersion.equals("1.20.1")) {
return mcVersion + "-neoforge-" + loaderVer;
}
return "neoforge-" + loaderVer;
}
return mcVersion;
}
private String resolveMainClass(VersionManifest manifest) {
return manifest.getMainClass();
}
private String getVanillaMainClass() {
String loaderType = instance.getLoaderType().toLowerCase();
if ("fabric".equals(loaderType)) {
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
}
return "net.minecraft.client.main.Main";
}
private List<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) {
List<String> args = new ArrayList<>();
Map<String, String> vars = buildVariableMap(options);
for (String raw : manifest.getGameArguments()) {
args.add(resolveVariable(raw, vars));
}
if (options.getWidth() > 0) {
args.add("--width");
args.add(String.valueOf(options.getWidth()));
}
if (options.getHeight() > 0) {
args.add("--height");
args.add(String.valueOf(options.getHeight()));
}
return args;
}
private List<String> getVanillaGameArguments(LaunchOptions options) {
List<String> args = new ArrayList<>();
args.add("--version");
args.add(instance.getName());
args.add("--gameDir");
args.add(instance.getPath().toAbsolutePath().toString());
args.add("--assetsDir");
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
args.add("--assetIndex");
String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion();
System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex));
} else {
System.out.println(ZAnsi.green("Использую asset index: " + assetIndex));
}
args.add(assetIndex);
args.add("--username");
args.add(options.getUsername() != null ? options.getUsername() : "Player");
args.add("--accessToken");
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
args.add("--uuid");
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
args.add("--userType");
args.add("legacy");
return args;
}
private Map<String, String> buildVariableMap(LaunchOptions options) {
Map<String, String> vars = new HashMap<>();
Path gameDir = instance.getPath().toAbsolutePath();
Path assetsDir = gameDir.resolve("assets");
Path nativesDir = gameDir.resolve("natives");
Path librariesDir = gameDir.resolve("libraries");
vars.put("version_name", instance.getName());
vars.put("game_directory", gameDir.toString());
vars.put("assets_root", assetsDir.toString());
vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion());
vars.put("auth_player_name", options.getUsername() != null ? options.getUsername() : "Player");
vars.put("auth_access_token", options.getAccessToken() != null ? options.getAccessToken() : "0");
vars.put("auth_uuid", options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
vars.put("auth_xuid", "0");
vars.put("user_type", "legacy");
vars.put("version_type", "release");
vars.put("natives_directory", nativesDir.toString());
vars.put("library_directory", librariesDir.toString());
vars.put("launcher_name", "ZernMC");
vars.put("launcher_version", "1.0");
vars.put("classpath_separator", System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":");
vars.put("resolution_width", String.valueOf(options.getWidth() > 0 ? options.getWidth() : 1920));
vars.put("resolution_height", String.valueOf(options.getHeight() > 0 ? options.getHeight() : 1080));
vars.put("game_directory", gameDir.toString());
String loaderType = instance.getLoaderType().toLowerCase(); String loaderType = instance.getLoaderType().toLowerCase();
if ("forge".equals(loaderType)) {
if ("fabric".equals(loaderType)) { vars.put("forge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
jvmArgs.add("--add-modules=ALL-MODULE-PATH"); } else if ("neoforge".equals(loaderType)) {
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED"); vars.put("neoforge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); vars.put("fml.neoForgeVersion", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED"); vars.put("fml.neoForgeGroup", "net.neoforged");
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
} }
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) { return vars;
jvmArgs.addAll(options.getExtraJvmArgs()); }
private String resolveVariable(String raw, Map<String, String> vars) {
if (!raw.contains("${")) return raw;
String result = raw;
for (Map.Entry<String, String> entry : vars.entrySet()) {
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
} }
return result;
return jvmArgs;
} }
private List<String> getForgeJvmArguments() { private String buildClasspathFromManifest(VersionManifest manifest) throws Exception {
List<String> jvmArgs = new ArrayList<>();
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
jvmArgs.add("-Dforge.logging.console.level=debug");
jvmArgs.add("-Dforge.logging.mojang.level=info");
jvmArgs.add("-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-");
jvmArgs.add("-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar");
return jvmArgs;
}
private String buildClasspath() throws Exception {
List<String> paths = new ArrayList<>(); List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries");
for (VersionManifest.Library lib : manifest.getLibraries()) {
Path libPath = librariesDir.resolve(lib.artifactPath);
if (Files.exists(libPath)) {
paths.add(libPath.toAbsolutePath().toString());
} else {
String mavenPath = mavenToPath(lib.name);
Path fallbackPath = librariesDir.resolve(mavenPath);
if (Files.exists(fallbackPath)) {
paths.add(fallbackPath.toAbsolutePath().toString());
} else {
System.out.println(ZAnsi.yellow(" Библиотека не найдена: " + lib.name));
}
}
}
Path versionJar = findVersionJar();
if (versionJar != null) {
paths.add(0, versionJar.toAbsolutePath().toString());
}
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
return String.join(separator, paths);
}
private String buildVanillaClasspath() throws Exception {
List<String> paths = new ArrayList<>();
String versionId = getVersionId(); String versionId = getVersionId();
Path versionJar = instance.getPath() Path versionJar = instance.getPath()
.resolve("versions") .resolve("versions")
.resolve(versionId) .resolve(versionId)
@@ -152,222 +316,111 @@ public class LaunchCommandBuilder {
return String.join(separator, paths); return String.join(separator, paths);
} }
private String buildForgeClasspath() throws Exception { private Path findVersionJar() {
List<String> paths = new ArrayList<>();
String versionId = getVersionId(); String versionId = getVersionId();
String mcVersion = instance.getMinecraftVersion(); Path versionsDir = instance.getPath().resolve("versions");
String forgeVersion = instance.getLoaderVersion();
Path librariesDir = instance.getPath().resolve("libraries"); Path[] candidates = {
if (Files.exists(librariesDir)) { versionsDir.resolve(versionId).resolve(versionId + ".jar"),
try (var stream = Files.walk(librariesDir)) { versionsDir.resolve(instance.getMinecraftVersion()).resolve(instance.getMinecraftVersion() + ".jar")
stream.filter(p -> p.toString().endsWith(".jar")) };
.map(p -> p.toAbsolutePath().toString())
.forEach(paths::add); for (Path candidate : candidates) {
if (Files.exists(candidate)) {
return candidate;
} }
} }
Path versionJar = instance.getPath() try {
.resolve("versions") if (Files.exists(versionsDir)) {
.resolve(versionId) try (var stream = Files.list(versionsDir)) {
.resolve(versionId + ".jar"); return stream
if (Files.exists(versionJar)) { .filter(Files::isDirectory)
paths.add(0, versionJar.toAbsolutePath().toString()); .filter(dir -> dir.getFileName().toString().contains("forge") ||
} else { dir.getFileName().toString().contains("neoforge"))
Path vanillaJar = instance.getPath() .filter(dir -> dir.getFileName().toString().contains(instance.getMinecraftVersion()))
.resolve("versions") .findFirst()
.resolve(mcVersion) .map(dir -> dir.resolve(dir.getFileName().toString() + ".jar"))
.resolve(mcVersion + ".jar"); .filter(Files::exists)
if (Files.exists(vanillaJar)) { .orElse(null);
paths.add(0, vanillaJar.toAbsolutePath().toString()); }
} }
} } catch (Exception ignored) {}
Path forgeUniversal = instance.getPath() return null;
.resolve("libraries")
.resolve("net")
.resolve("minecraftforge")
.resolve("forge")
.resolve(mcVersion + "-" + forgeVersion)
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-universal.jar");
if (Files.exists(forgeUniversal)) {
paths.add(forgeUniversal.toAbsolutePath().toString());
}
Path forgeClient = instance.getPath()
.resolve("libraries")
.resolve("net")
.resolve("minecraftforge")
.resolve("forge")
.resolve(mcVersion + "-" + forgeVersion)
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-client.jar");
if (Files.exists(forgeClient)) {
paths.add(forgeClient.toAbsolutePath().toString());
}
String[] forgeModules = {"fmlcore", "javafmllanguage", "lowcodelanguage", "mclanguage"};
for (String module : forgeModules) {
Path modulePath = instance.getPath()
.resolve("libraries")
.resolve("net")
.resolve("minecraftforge")
.resolve(module)
.resolve(mcVersion + "-" + forgeVersion)
.resolve(module + "-" + mcVersion + "-" + forgeVersion + ".jar");
if (Files.exists(modulePath)) {
paths.add(modulePath.toAbsolutePath().toString());
}
}
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
return String.join(separator, paths);
} }
private String getMainClass() { private String mavenToPath(String mavenName) {
String[] parts = mavenName.split(":");
if (parts.length < 3) return mavenName;
String group = parts[0].replace('.', '/');
String artifact = parts[1];
String version = parts[2];
if (parts.length == 4) {
String classifier = parts[3];
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + "-" + classifier + ".jar";
}
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + ".jar";
}
private List<String> getJvmArguments(LaunchOptions options) {
List<String> jvmArgs = new ArrayList<>();
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
jvmArgs.add("-Xmx" + ramMB + "M");
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
jvmArgs.add("-XX:+UseG1GC");
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
jvmArgs.add("-XX:G1NewSizePercent=20");
jvmArgs.add("-XX:G1ReservePercent=20");
jvmArgs.add("-XX:MaxGCPauseMillis=50");
jvmArgs.add("-XX:G1HeapRegionSize=32M");
String loaderType = instance.getLoaderType().toLowerCase(); String loaderType = instance.getLoaderType().toLowerCase();
if ("fabric".equals(loaderType)) { if ("fabric".equals(loaderType)) {
return "net.fabricmc.loader.impl.launch.knot.KnotClient"; jvmArgs.add("--add-modules=ALL-MODULE-PATH");
} jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
else if ("forge".equals(loaderType)) { jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
return "cpw.mods.modlauncher.Launcher"; jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
} jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
else { jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
return "net.minecraft.client.main.Main"; jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
} else if ("forge".equals(loaderType)) {
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
} else if ("neoforge".equals(loaderType)) {
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
} }
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
jvmArgs.addAll(options.getExtraJvmArgs());
}
return jvmArgs;
} }
}
/**
* ИСПРАВЛЕНО: используем instance.getAssetIndex() вместо minecraftVersion
*/
private List<String> getMinecraftArguments(LaunchOptions options) {
List<String> args = new ArrayList<>();
args.add("--version");
args.add(instance.getName());
args.add("--gameDir");
args.add(instance.getPath().toAbsolutePath().toString());
args.add("--assetsDir");
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
// FIXED: Используем правильный assetIndex
args.add("--assetIndex");
String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion();
System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex));
} else {
System.out.println(ZAnsi.green("Использую asset index: " + assetIndex));
}
args.add(assetIndex);
args.add("--username");
args.add(options.getUsername() != null ? options.getUsername() : "Player");
args.add("--accessToken");
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
args.add("--uuid");
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
args.add("--userType");
args.add("legacy");
if (options.getWidth() > 0) {
args.add("--width");
args.add(String.valueOf(options.getWidth()));
}
if (options.getHeight() > 0) {
args.add("--height");
args.add(String.valueOf(options.getHeight()));
}
return args;
}
/**
* ИСПРАВЛЕНО: для Forge тоже используем правильный assetIndex
*/
private List<String> getForgeArguments(LaunchOptions options) {
List<String> args = new ArrayList<>();
args.add("--launchTarget");
args.add("forgeclient");
args.add("--fml.forgeVersion");
args.add(instance.getLoaderVersion());
args.add("--fml.mcVersion");
args.add(instance.getMinecraftVersion());
args.add("--fml.forgeGroup");
args.add("net.minecraftforge");
args.add("--gameDir");
args.add(instance.getPath().toAbsolutePath().toString());
args.add("--assetsDir");
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
// FIXED: Используем правильный assetIndex для Forge
args.add("--assetIndex");
String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion();
}
args.add(assetIndex);
args.add("--username");
args.add(options.getUsername() != null ? options.getUsername() : "Player");
args.add("--accessToken");
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
args.add("--uuid");
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
args.add("--userType");
args.add("legacy");
if (options.getWidth() > 0) {
args.add("--width");
args.add(String.valueOf(options.getWidth()));
}
if (options.getHeight() > 0) {
args.add("--height");
args.add(String.valueOf(options.getHeight()));
}
return args;
}
/**
* ИСПРАВЛЕНО: для Fabric используем сохраненный fabricVersionId
*/
private String getVersionId() {
String loaderType = instance.getLoaderType().toLowerCase();
String mcVersion = instance.getMinecraftVersion();
String loaderVer = instance.getLoaderVersion();
if ("vanilla".equals(loaderType)) {
return mcVersion;
}
else if ("fabric".equals(loaderType)) {
// Используем сохраненный fabricVersionId если есть
String fabricId = instance.getFabricVersionId();
if (fabricId != null && !fabricId.isEmpty()) {
return fabricId;
}
// fallback
return "fabric-loader-" + loaderVer + "-" + mcVersion;
}
else if ("forge".equals(loaderType)) {
return mcVersion + "-forge-" + loaderVer;
}
return mcVersion;
}
}
@@ -0,0 +1,165 @@
package me.sashegdev.zernmc.launcher.minecraft.launch;
import org.json.JSONArray;
import org.json.JSONObject;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class VersionManifest {
private final String id;
private final String mainClass;
private final String assetIndexId;
private final List<String> jvmArguments;
private final List<String> gameArguments;
private final List<Library> libraries;
public VersionManifest(JSONObject json) {
this.id = json.getString("id");
this.mainClass = json.getString("mainClass");
if (json.has("assetIndex")) {
JSONObject ai = json.getJSONObject("assetIndex");
this.assetIndexId = ai.has("id") ? ai.getString("id") : "unknown";
} else {
this.assetIndexId = "unknown";
}
this.jvmArguments = parseArguments(json, "jvm");
this.gameArguments = parseArguments(json, "game");
this.libraries = parseLibraries(json);
}
public String getId() { return id; }
public String getMainClass() { return mainClass; }
public String getAssetIndexId() { return assetIndexId; }
public List<String> getJvmArguments() { return jvmArguments; }
public List<String> getGameArguments() { return gameArguments; }
public List<Library> getLibraries() { return libraries; }
private List<String> parseArguments(JSONObject json, String type) {
List<String> args = new ArrayList<>();
if (!json.has("arguments")) return args;
JSONObject arguments = json.getJSONObject("arguments");
if (!arguments.has(type)) return args;
JSONArray arr = arguments.getJSONArray(type);
for (int i = 0; i < arr.length(); i++) {
Object item = arr.get(i);
if (item instanceof String) {
args.add((String) item);
} else if (item instanceof JSONObject) {
JSONObject ruleObj = (JSONObject) item;
if (ruleMatches(ruleObj)) {
Object value = ruleObj.get("value");
if (value instanceof String) {
args.add((String) value);
} else if (value instanceof JSONArray) {
JSONArray valArr = (JSONArray) value;
for (int j = 0; j < valArr.length(); j++) {
args.add(valArr.getString(j));
}
}
}
}
}
return args;
}
private boolean ruleMatches(JSONObject ruleObj) {
JSONArray rules = ruleObj.getJSONArray("rules");
boolean result = false;
for (int i = 0; i < rules.length(); i++) {
JSONObject rule = rules.getJSONObject(i);
String action = rule.getString("action");
boolean matches = true;
if (rule.has("os")) {
JSONObject os = rule.getJSONObject("os");
String osName = System.getProperty("os.name").toLowerCase();
if (os.has("name")) {
String reqName = os.getString("name").toLowerCase();
if (reqName.equals("windows") && !osName.contains("win")) matches = false;
else if (reqName.equals("linux") && !osName.contains("linux") && !osName.contains("nix")) matches = false;
else if (reqName.equals("osx") && !osName.contains("mac")) matches = false;
}
if (os.has("arch")) {
String reqArch = os.getString("arch");
String osArch = System.getProperty("os.arch");
if (!reqArch.equals(osArch)) matches = false;
}
}
if (rule.has("features")) {
JSONObject features = rule.getJSONObject("features");
for (String key : features.keySet()) {
if (key.startsWith("is_demo_user") || key.startsWith("has_custom_resolution")) continue;
matches = false;
}
}
if ("allow".equals(action) && matches) {
result = true;
} else if ("disallow".equals(action) && matches) {
return false;
}
}
return result;
}
private List<Library> parseLibraries(JSONObject json) {
List<Library> libs = new ArrayList<>();
if (!json.has("libraries")) return libs;
JSONArray arr = json.getJSONArray("libraries");
for (int i = 0; i < arr.length(); i++) {
JSONObject libJson = arr.getJSONObject(i);
if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) {
String name = libJson.getString("name");
String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path");
Library lib = new Library(name, artifactPath);
if (libJson.has("natives")) {
JSONObject natives = libJson.getJSONObject("natives");
for (String key : natives.keySet()) {
String osKey = key.toLowerCase();
lib.natives.put(osKey, natives.getString(key));
}
}
if (libJson.has("rules")) {
JSONObject dummyObj = new JSONObject();
dummyObj.put("rules", libJson.getJSONArray("rules"));
dummyObj.put("value", "");
if (ruleMatches(dummyObj)) {
libs.add(lib);
}
} else {
libs.add(lib);
}
}
}
return libs;
}
public static class Library {
public final String name;
public final String artifactPath;
public final Map<String, String> natives = new HashMap<>();
public Library(String name, String artifactPath) {
this.name = name;
this.artifactPath = artifactPath;
}
public String getSimpleName() {
return name.substring(name.indexOf(':') + 1);
}
}
}
@@ -36,17 +36,30 @@ public class ArrowMenu {
printPagedMenu(); printPagedMenu();
int key = terminal.reader().read(); int key = terminal.reader().read();
if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц') { // Up if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц'
|| key == 'k' || key == 'K' || key == 'л' || key == 'Л') { // Up / Arrow Up
selected = (selected - 1 + options.size()) % options.size(); selected = (selected - 1 + options.size()) % options.size();
} }
else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы') { // Down else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы'
|| key == 'j' || key == 'J' || key == 'о' || key == 'О') { // Down / Arrow Down
selected = (selected + 1) % options.size(); selected = (selected + 1) % options.size();
} }
else if (key == 13 || key == 10) { // Enter else if (key == 13 || key == 10) { // Enter
return selected; return selected;
} }
else if (key == 27) { // Esc else if (key == 27) { // Esc or arrow escape seq
return -1; int next = terminal.reader().read(50);
if (next == 91) { // '[' — start of arrow escape sequence
int arrow = terminal.reader().read(50);
if (arrow == 65) { // 'A' — Up arrow
selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 66) { // 'B' — Down arrow
selected = (selected + 1) % options.size();
}
// else — unknown escape seq, ignore
} else {
return -1; // genuine Esc
}
} }
} }
} finally { } finally {
@@ -83,7 +96,7 @@ public class ArrowMenu {
// Подсказка внизу (фиксированная) // Подсказка внизу (фиксированная)
sb.append("\n") sb.append("\n")
.append(ZAnsi.white("W/S (Ц/Ы) - перемещение | Enter - выбрать | Esc - назад")); .append(ZAnsi.white("W/S (Ц/Ы) или ↑/↓ - перемещение | Enter - выбрать | Esc - назад"));
System.out.print(sb); System.out.print(sb);
} }
@@ -10,6 +10,8 @@ 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();
// Настройки // Настройки
@@ -83,6 +85,14 @@ public class Config {
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;
@@ -19,6 +19,7 @@ public class Input {
} }
public static String readLine(String prompt) { public static String readLine(String prompt) {
flushInput(); // Очищаем буфер
System.out.print(prompt); System.out.print(prompt);
return scanner.nextLine().trim(); return scanner.nextLine().trim();
} }
@@ -79,4 +80,18 @@ public class Input {
public static void close() { public static void close() {
scanner.close(); scanner.close();
} }
/**
* Очищает буфер ввода от оставшихся символов
*/
public static void flushInput() {
try {
while (System.in.available() > 0) {
System.in.read();
}
} catch (IOException e) {
// Игнорируем
}
}
} }
@@ -27,6 +27,10 @@ public class ZAnsi {
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString(); return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
} }
public static String brightCyan(String text) {
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a(text).reset().toString();
}
public static String yellow(String text) { public static String yellow(String text) {
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString(); return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
} }
@@ -47,6 +51,27 @@ public class ZAnsi {
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString(); return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
} }
public static String brightBlue(String text) {
return Ansi.ansi().fgBright(Ansi.Color.BLUE).a(text).reset().toString();
}
public static String magenta(String text) {
return Ansi.ansi().fg(Ansi.Color.MAGENTA).a(text).reset().toString();
}
public static String brightMagenta(String text) {
return Ansi.ansi().fgBright(Ansi.Color.MAGENTA).a(text).reset().toString();
}
// Пурпурный как brightPurple (используем magenta)
public static String purple(String text) {
return brightMagenta(text);
}
public static String brightPurple(String text) {
return brightMagenta(text);
}
public static String white(String text) { public static String white(String text) {
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString(); return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
} }
@@ -55,7 +80,28 @@ public class ZAnsi {
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString(); return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
} }
// Стили public static String black(String text) {
return Ansi.ansi().fg(Ansi.Color.BLACK).a(text).reset().toString();
}
// === Фоновые цвета ===
public static String bgGreen(String text) {
return Ansi.ansi().bg(Ansi.Color.GREEN).a(text).reset().toString();
}
public static String bgRed(String text) {
return Ansi.ansi().bg(Ansi.Color.RED).a(text).reset().toString();
}
public static String bgYellow(String text) {
return Ansi.ansi().bg(Ansi.Color.YELLOW).a(text).reset().toString();
}
public static String bgBlue(String text) {
return Ansi.ansi().bg(Ansi.Color.BLUE).a(text).reset().toString();
}
// === Стили ===
public static String bold(String text) { public static String bold(String text) {
return Ansi.ansi().bold().a(text).reset().toString(); return Ansi.ansi().bold().a(text).reset().toString();
} }
@@ -64,17 +110,73 @@ public class ZAnsi {
return Ansi.ansi().reset().toString(); return Ansi.ansi().reset().toString();
} }
// Комбинированные удобные методы // === Комбинированные удобные методы ===
public static String header(String text) { public static String header(String text) {
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString(); return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
} }
public static String success(String text) {
return Ansi.ansi().fgBright(Ansi.Color.GREEN).bold().a("[✓] " + text).reset().toString();
}
public static String error(String text) {
return Ansi.ansi().fgBright(Ansi.Color.RED).bold().a("[✗] " + text).reset().toString();
}
public static String warning(String text) {
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).bold().a("[!] " + text).reset().toString();
}
public static String info(String text) {
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a("[i] " + text).reset().toString();
}
public static String selected(String text) { public static String selected(String text) {
return Ansi.ansi() return Ansi.ansi()
.bgBright(Ansi.Color.WHITE) .bgBright(Ansi.Color.WHITE)
.fgBlack() .fg(Ansi.Color.BLACK)
.bold()
.a(" > " + text + " ") .a(" > " + text + " ")
.reset() .reset()
.toString(); .toString();
} }
public static String dim(String text) {
return Ansi.ansi().fgBright(Ansi.Color.BLACK).a(text).reset().toString();
}
// === Цветной текст для ролей ===
public static String roleUser(String text) {
return white(text);
}
public static String rolePassHolder(String text) {
return brightGreen(text);
}
public static String roleModerator(String text) {
return brightBlue(text);
}
public static String roleElder(String text) {
return brightPurple(text);
}
public static String roleCreator(String text) {
return brightRed(text);
}
// === Очистка экрана ===
public static String clearScreen() {
return Ansi.ansi().eraseScreen().cursor(1, 1).toString();
}
// === Прогресс бар символы ===
public static String progressChar() {
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a("").reset().toString();
}
public static String progressEmpty() {
return Ansi.ansi().fg(Ansi.Color.BLACK).a("").reset().toString();
}
} }
@@ -3,6 +3,8 @@ package me.sashegdev.zernmc.launcher.utils;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
@@ -25,20 +27,33 @@ public class ZHttpClient {
.version(HttpClient.Version.HTTP_1_1) .version(HttpClient.Version.HTTP_1_1)
.build(); .build();
private static final String BASE_URL = "http://87.120.187.36:1582"; private static String BASE_URL = "http://87.120.187.36:1582";
// Глобальный прокси режим (для обратной совместимости) // Глобальный прокси режим (для обратной совместимости)
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
/**
* Переопределить URL сервера (для тестов).
* Внимание: не потокобезопасно, использовать только в тестах.
*/
public static void setBaseUrl(String url) {
BASE_URL = url;
}
public static String getBaseUrl() {
return BASE_URL;
}
// Умное проксирование по сервисам // Умное проксирование по сервисам
public enum ServiceType { public enum ServiceType {
ZERN_SERVER(BASE_URL, true), ZERN_SERVER("http://87.120.187.36:1582", true),
FABRIC_META("https://meta.fabricmc.net", false), FABRIC_META("https://meta.fabricmc.net", false),
FABRIC_MAVEN("https://maven.fabricmc.net", false), FABRIC_MAVEN("https://maven.fabricmc.net", false),
MOJANG_META("https://piston-meta.mojang.com", false), MOJANG_META("https://piston-meta.mojang.com", false),
MOJANG_RESOURCES("https://resources.download.minecraft.net", false), MOJANG_RESOURCES("https://resources.download.minecraft.net", false),
FORGE_MAVEN("https://maven.minecraftforge.net", false), FORGE_MAVEN("https://maven.minecraftforge.net", false),
NEOFORGE_MAVEN("https://maven.neoforged.net", false),
GOOGLE("https://google.com", false), GOOGLE("https://google.com", false),
CLOUDFLARE("https://cloudflare.com", false); CLOUDFLARE("https://cloudflare.com", false);
@@ -92,7 +107,8 @@ public class ZHttpClient {
ServiceType.FABRIC_MAVEN, ServiceType.FABRIC_MAVEN,
ServiceType.MOJANG_META, ServiceType.MOJANG_META,
ServiceType.MOJANG_RESOURCES, ServiceType.MOJANG_RESOURCES,
ServiceType.FORGE_MAVEN ServiceType.FORGE_MAVEN,
ServiceType.NEOFORGE_MAVEN
); );
for (ServiceType service : servicesToCheck) { for (ServiceType service : servicesToCheck) {
@@ -223,6 +239,7 @@ public class ZHttpClient {
return ServiceType.MOJANG_META; return ServiceType.MOJANG_META;
if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES; if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES;
if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN; if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN;
if (url.contains("maven.neoforged.net")) return ServiceType.NEOFORGE_MAVEN;
if (url.contains("google.com")) return ServiceType.GOOGLE; if (url.contains("google.com")) return ServiceType.GOOGLE;
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE; if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
return null; return null;
@@ -380,13 +397,19 @@ public class ZHttpClient {
} }
try { try {
HttpRequest request = HttpRequest.newBuilder() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + endpoint)) .uri(URI.create(BASE_URL + endpoint))
.timeout(Duration.ofSeconds(15)) .timeout(Duration.ofSeconds(15))
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET() .GET();
.build();
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
}
HttpRequest request = requestBuilder.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
@@ -401,19 +424,25 @@ public class ZHttpClient {
private static String proxyGet(String endpoint) throws IOException { private static String proxyGet(String endpoint) throws IOException {
try { try {
HttpRequest request = HttpRequest.newBuilder() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/proxy" + endpoint)) .uri(URI.create(BASE_URL + "/proxy" + endpoint))
.timeout(Duration.ofSeconds(30)) .timeout(Duration.ofSeconds(30))
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET() .GET();
.build();
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
}
HttpRequest request = requestBuilder.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode()); throw new IOException("HTTP " + response.statusCode());
} }
proxySuccessCount++; proxySuccessCount++;
return response.body(); return response.body();
} catch (Exception e) { } catch (Exception e) {
@@ -479,10 +508,6 @@ public class ZHttpClient {
// ====================== ВСПОМОГАТЕЛЬНЫЕ ====================== // ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
public static String getBaseUrl() {
return BASE_URL;
}
public static String getLauncherVersionInfo() throws IOException, InterruptedException { public static String getLauncherVersionInfo() throws IOException, InterruptedException {
return get("/launcher/version"); return get("/launcher/version");
} }
@@ -0,0 +1,63 @@
package me.sashegdev.zernmc.launcher.web;
import java.awt.GraphicsEnvironment;
import javafx.application.Application;
import javafx.concurrent.Worker;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
public class UIWindow extends Application {
private static String url;
private static int port;
public static void start(int port) {
UIWindow.port = port;
UIWindow.url = "http://localhost:" + port;
Application.launch(UIWindow.class);
}
@Override
public void start(Stage stage) {
stage.setTitle("ZernMC Launcher");
stage.initStyle(StageStyle.UNDECORATED);
WebView webView = new WebView();
WebEngine webEngine = webView.getEngine();
webEngine.load(url);
webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
if (newState == Worker.State.FAILED) {
System.err.println("Failed to load: " + url);
}
});
Scene scene = new Scene(webView);
stage.setScene(scene);
Rectangle2D screenBounds = Screen.getPrimary().getVisualBounds();
double screenWidth = screenBounds.getWidth();
double screenHeight = screenBounds.getHeight();
double windowWidth = Math.min(1200, screenWidth * 0.8);
double windowHeight = Math.min(800, screenHeight * 0.85);
stage.setWidth(windowWidth);
stage.setHeight(windowHeight);
stage.setX((screenWidth - windowWidth) / 2);
stage.setY((screenHeight - windowHeight) / 2);
stage.show();
stage.setOnCloseRequest(event -> {
WebServer.stop();
System.exit(0);
});
}
}
@@ -0,0 +1,463 @@
package me.sashegdev.zernmc.launcher.web;
import io.javalin.Javalin;
import io.javalin.http.staticfiles.Location;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.install.InstallService;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.awt.Desktop;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.ServerSocket;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
public class WebServer {
private static final LauncherAPI api = new LauncherAPI();
private static Javalin app;
private static int currentPort;
private static volatile boolean running = false;
public static int findFreePort(int startPort) throws IOException {
for (int port = startPort; port < startPort + 100; port++) {
if (isPortAvailable(port)) {
return port;
}
}
throw new IOException("Не удалось найти свободный порт в диапазоне " + startPort + "-" + (startPort + 99));
}
private static boolean isPortAvailable(int port) {
try (ServerSocket socket = new ServerSocket(port)) {
return true;
} catch (IOException e) {
return false;
}
}
public static void start(int port) throws Exception {
currentPort = port;
running = true;
// Отключаем логирование Javalin в консоль
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "error");
app = Javalin.create(config -> {
config.staticFiles.add("/webapp", Location.CLASSPATH);
config.staticFiles.add("/assets", Location.CLASSPATH);
}).start(port);
// API эндпоинты
setupApiRoutes();
System.out.println(ZAnsi.brightGreen("✓ Web UI готов на http://localhost:" + port));
// Блокируем главный поток (сервер работает)
while (running) {
Thread.sleep(1000);
}
}
private static void setupApiRoutes() {
// Auth
app.get("/api/auth/status", ctx -> {
if (AuthManager.loadSavedSession()) {
ctx.json(Map.of(
"success", true,
"loggedIn", true,
"username", AuthManager.getUsername()
));
} else {
ctx.json(Map.of(
"success", true,
"loggedIn", false
));
}
});
app.post("/api/auth/login", ctx -> {
Map<String, String> body = ctx.bodyAsClass(Map.class);
String username = body.get("username");
String password = body.get("password");
if (username == null || password == null) {
ctx.status(400).json(Map.of("success", false, "error", "Missing username or password"));
return;
}
var result = api.login(username, password);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "username", username));
} else {
ctx.status(401).json(Map.of("success", false, "error", result.getError()));
}
});
app.post("/api/auth/logout", ctx -> {
AuthManager.logout();
ctx.json(Map.of("success", true));
});
// Instances - локальные
app.get("/api/instances", ctx -> {
var result = api.getAllInstances();
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "data", result.getData()));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Instance детали
app.get("/api/instances/{name}", ctx -> {
String name = ctx.pathParam("name");
var result = api.instances().getInstance(name);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "data", result.getData()));
} else {
ctx.status(404).json(Map.of("success", false, "error", result.getError()));
}
});
// Launch
app.post("/api/instances/{name}/launch", ctx -> {
String name = ctx.pathParam("name");
var result = api.launch(name);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "message", "Launch started"));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Delete
app.post("/api/instances/{name}/delete", ctx -> {
String name = ctx.pathParam("name");
var result = api.instances().deleteInstance(name);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "message", "Instance deleted"));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// ZernMC серверные сборки
app.get("/api/instances/zernmc", ctx -> {
// TODO: получить реальные сборки с сервера
List<Map<String, Object>> packs = List.of(
Map.of("name", "ZernMC SkyBlock", "version", 1, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 150),
Map.of("name", "ZernMC RPG", "version", 3, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 200)
);
ctx.json(Map.of("success", true, "data", packs));
});
// Установка ZernMC сборки
app.post("/api/instances/zernmc/install", ctx -> {
Map<String, String> body = ctx.bodyAsClass(Map.class);
String packName = body.get("packName");
String instanceName = body.get("instanceName");
if (packName == null || instanceName == null) {
ctx.status(400).json(Map.of("success", false, "error", "Missing packName or instanceName"));
return;
}
var result = api.install().installZernMCPack(packName, instanceName);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"name", result.getData().getName(),
"mcVersion", result.getData().getMcVersion(),
"loaderType", result.getData().getLoaderType(),
"serverVersion", result.getData().getServerVersion()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// SSE прогресс установки
app.get("/api/instances/{name}/install/stream", ctx -> {
ctx.header("Content-Type", "text/event-stream");
ctx.header("Cache-Control", "no-cache");
ctx.header("Connection", "keep-alive");
String instanceName = ctx.pathParam("name");
var instanceInfo = api.instances().getInstance(instanceName);
if (!instanceInfo.isSuccess() || instanceInfo.getData() == null) {
ctx.result("data: {\"phase\":\"error\",\"message\":\"Instance not found\"}\n\n");
return;
}
var os = ctx.outputStream();
InstallService service = new InstallService();
service.setProgressCallback(info -> {
try {
String json = String.format(
"{\"phase\":\"%s\",\"totalFiles\":%d,\"downloadedFiles\":%d,\"currentFile\":\"%s\",\"fileSize\":%d,\"downloadedBytes\":%d,\"filePercent\":%d,\"totalPercent\":%d,\"eta\":\"%s\"}",
info.getPhase(), info.getTotalFiles(), info.getDownloadedFiles(),
info.getCurrentFile() != null ? info.getCurrentFile().replace("\"", "\\\"") : "",
info.getFileSize(), info.getDownloadedBytes(),
info.getFilePercent(), info.getTotalPercent(),
info.getEta() != null ? info.getEta() : ""
);
os.write(("data: " + json + "\n\n").getBytes());
os.flush();
} catch (Exception e) {}
});
var result = service.installZernMCPack(instanceInfo.getData().getServerPackName(), instanceName);
try {
if (!result.isSuccess()) {
os.write(("data: {\"phase\":\"error\",\"message\":\"" + result.getError().replace("\"", "\\\"") + "\"}\n\n").getBytes());
} else {
os.write("data: {\"phase\":\"complete\"}\n\n".getBytes());
}
os.flush();
} catch (Exception e) {}
});
// Проверка обновлений
app.get("/api/instances/{name}/updates", ctx -> {
String name = ctx.pathParam("name");
var result = api.install().checkForUpdates(name);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"hasUpdate", result.getData().isHasUpdate(),
"isServerPack", result.getData().isServerPack(),
"currentVersion", result.getData().getCurrentVersion(),
"latestVersion", result.getData().getLatestVersion()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Проверка хешей
app.get("/api/instances/{name}/verify", ctx -> {
String name = ctx.pathParam("name");
var result = api.install().verifyHashes(name);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"hasMismatches", result.getData().hasMismatches(),
"mismatchedFiles", result.getData().getMismatchedFiles()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Получение времени игры
app.get("/api/instances/{name}/playtime", ctx -> {
String name = ctx.pathParam("name");
var result = api.install().getPlayTime(name);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"totalMinutes", result.getData().getTotalMinutes(),
"fromServer", result.getData().isFromServer(),
"formatted", result.getData().getFormattedTime()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Minecraft версии
app.get("/api/versions", ctx -> {
List<String> versions = List.of(
"1.21.4", "1.21.3", "1.21.2", "1.21.1", "1.21",
"1.20.4", "1.20.3", "1.20.2", "1.20.1", "1.20",
"1.19.4", "1.19.3", "1.19.2", "1.19.1", "1.19",
"1.18.2", "1.18.1", "1.18",
"1.17.1", "1.17"
);
ctx.json(Map.of("success", true, "data",
versions.stream().map(v -> Map.of("id", v)).toList()
));
});
// Версии лоадеров для конкретной версии Minecraft
app.get("/api/versions/{version}/loaders/{loader}", ctx -> {
String version = ctx.pathParam("version");
String loader = ctx.pathParam("loader");
List<Map<String, String>> loaderVersions = switch (loader.toLowerCase()) {
case "fabric" -> List.of(
Map.of("version", "0.16.9"),
Map.of("version", "0.16.8"),
Map.of("version", "0.16.7"),
Map.of("version", "0.16.6"),
Map.of("version", "0.16.5"),
Map.of("version", "0.15.11"),
Map.of("version", "0.15.10"),
Map.of("version", "0.15.9")
);
case "forge" -> List.of(
Map.of("version", "1.21-51.0.0"),
Map.of("version", "1.20.4-49.0.0"),
Map.of("version", "1.20.1-47.1.0"),
Map.of("version", "1.19.2-43.2.0"),
Map.of("version", "1.18.2-40.2.0")
);
case "neoforge" -> List.of(
Map.of("version", "21.0.0-beta"),
Map.of("version", "1.21-21.0.0"),
Map.of("version", "1.20.4-21.0.0"),
Map.of("version", "1.20.1-21.0.0")
);
default -> List.of();
};
ctx.json(Map.of("success", true, "data", loaderVersions));
});
// Установка ванильной сборки
app.post("/api/instances/vanilla/install", ctx -> {
Map<String, String> body = ctx.bodyAsClass(Map.class);
String mcVersion = body.get("mcVersion");
String loader = body.get("loader");
String loaderVersion = body.get("loaderVersion");
String instanceName = body.get("instanceName");
if (mcVersion == null || instanceName == null) {
ctx.status(400).json(Map.of("success", false, "error", "Missing required parameters"));
return;
}
// TODO: реализовать установку ванильной сборки
String desc = loader != null ? mcVersion + " + " + loader + " " + loaderVersion : mcVersion + " Vanilla";
ctx.json(Map.of("success", true, "message", "Vanilla installation started: " + desc));
});
// Health check
app.get("/api/health", ctx -> {
ctx.json(Map.of("success", true, "status", "ok"));
});
}
private static void openBrowser(String url) {
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(new URI(url));
System.out.println(ZAnsi.cyan("Браузер открыт: " + url));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически. Откройте вручную: " + url));
}
}
public static void stop() {
running = false;
if (app != null) {
app.stop();
}
}
// ==================== LAUNCHER AUTO-UPDATE ====================
public static void checkLauncherUpdate() {
try {
String json = ZHttpClient.getLauncherVersionInfo();
String serverVersion = extractVersion(json);
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
performLauncherUpdate(serverVersion);
restartLauncher();
} else {
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
}
}
private static void performLauncherUpdate(String newVersion) throws Exception {
String downloadUrl = ZHttpClient.getBaseUrl() + "/launcher/download?type=jar";
Path currentJar = getCurrentJarPath();
Path tempJar = currentJar.getParent().resolve("zernmc-launcher-new.jar");
System.out.println(ZAnsi.cyan("Скачивание версии " + newVersion + "..."));
HttpClient client = HttpClient.newBuilder().build();
HttpRequest request = HttpRequest.newBuilder()
.uri(java.net.URI.create(downloadUrl))
.GET()
.build();
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(tempJar));
if (response.statusCode() != 200) {
throw new IOException("Сервер вернул код: " + response.statusCode());
}
long size = Files.size(tempJar);
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
}
private static void restartLauncher() {
try {
String javaPath = System.getProperty("java.home") + "/bin/java";
String jarPath = getCurrentJarPath().toAbsolutePath().toString();
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
new ProcessBuilder(javaPath, "-jar", jarPath)
.inheritIO()
.start();
System.exit(0);
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Не удалось перезапустить лаунчер."));
System.exit(1);
}
}
private static String extractVersion(String json) {
try {
return json.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
} catch (Exception e) {
return "unknown";
}
}
private static Path getCurrentJarPath() {
try {
return Path.of(me.sashegdev.zernmc.launcher.Main.class.getProtectionDomain()
.getCodeSource()
.getLocation()
.toURI());
} catch (Exception e) {
return Path.of("zernmc-launcher.jar");
}
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JAVA_HOME="$SCRIPT_DIR/jre21"
JAVA="$JAVA_HOME/bin/java"
JAVAFX_PATH="$SCRIPT_DIR/lib-javafx"
exec "$JAVA" \
--module-path="$JAVAFX_PATH" \
--add-modules=javafx.controls,javafx.web \
--add-reads=javafx.graphics=ALL-UNNAMED \
-jar "$SCRIPT_DIR/ZernMCLauncher.jar" "$@"
@@ -0,0 +1,825 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--bg-card-hover: #222230;
--bg-sidebar: #0d0d12;
--accent-primary: #e94560;
--accent-secondary: #ff6b6b;
--accent-glow: rgba(233, 69, 96, 0.3);
--text-primary: #ffffff;
--text-secondary: #a0a0b0;
--text-muted: #606070;
--border-color: #2a2a3a;
--success: #4ade80;
--error: #f87171;
--warning: #fbbf24;
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 30px var(--accent-glow);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--transition-fast: 150ms ease;
--transition-normal: 300ms ease;
--transition-slow: 500ms ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
#grid-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
opacity: 0.12;
pointer-events: none;
}
#app {
position: relative;
z-index: 1;
min-height: 100vh;
}
.screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: fadeIn var(--transition-slow) forwards;
}
.hidden {
display: none !important;
}
/* ==================== LOGIN SCREEN ==================== */
.login-container {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 48px;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-card);
border: 1px solid var(--border-color);
animation: slideUp var(--transition-slow) forwards;
}
.logo-section {
text-align: center;
margin-bottom: 40px;
}
.logo-placeholder {
display: inline-block;
margin-bottom: 16px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.app-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
background: linear-gradient(135deg, var(--text-primary), var(--accent-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.app-version {
color: var(--text-muted);
font-size: 14px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.input-group input {
width: 100%;
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 16px;
transition: var(--transition-fast);
}
.input-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.input-group input::placeholder {
color: var(--text-muted);
}
.btn-primary {
width: 100%;
padding: 14px 24px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
border-radius: var(--radius-sm);
color: white;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: var(--transition-fast);
position: relative;
overflow: hidden;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.btn-loader {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-message {
color: var(--error);
text-align: center;
font-size: 14px;
padding: 12px;
background: rgba(248, 113, 113, 0.1);
border-radius: var(--radius-sm);
animation: shake 0.5s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* ==================== MAIN LAYOUT ==================== */
.main-layout {
display: grid;
grid-template-columns: 280px 1fr 200px;
width: 100%;
max-width: 1600px;
height: calc(100vh - 40px);
gap: 0;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
animation: fadeIn var(--transition-slow) forwards;
}
/* Sidebar */
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
}
.logo-small svg {
display: block;
}
.header-info {
display: flex;
flex-direction: column;
}
.header-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.header-version {
font-size: 12px;
color: var(--text-muted);
}
.sidebar-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.section-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 12px;
}
.current-instance-section {
flex: 1;
}
.current-instance {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
transition: var(--transition-fast);
}
.current-instance:hover {
border-color: var(--accent-primary);
}
.instance-card-mini {
display: flex;
flex-direction: column;
gap: 8px;
}
.instance-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.instance-version {
font-size: 13px;
color: var(--accent-primary);
background: rgba(233, 69, 96, 0.15);
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
width: fit-content;
}
.btn-download {
width: 100%;
padding: 16px;
background: var(--bg-card);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: var(--transition-fast);
}
.btn-download:hover {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.sidebar-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid var(--border-color);
margin-top: 20px;
}
.username-display {
font-size: 13px;
color: var(--text-secondary);
}
.btn-logout {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition-fast);
}
.btn-logout:hover {
background: rgba(248, 113, 113, 0.1);
border-color: var(--error);
color: var(--error);
}
/* Main Content - Logs */
.main-content {
display: flex;
flex-direction: column;
padding: 20px;
background: var(--bg-primary);
}
.logs-section {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
overflow: hidden;
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.logs-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.btn-clear-logs {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: var(--transition-fast);
}
.btn-clear-logs:hover {
background: var(--bg-card-hover);
color: var(--text-secondary);
}
.logs-container {
flex: 1;
padding: 16px 20px;
overflow-y: auto;
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-entry {
padding: 4px 0;
color: var(--text-secondary);
animation: fadeIn var(--transition-fast) forwards;
}
.log-entry.info {
color: var(--text-secondary);
}
.log-entry.success {
color: var(--success);
}
.log-entry.warning {
color: var(--warning);
}
.log-entry.error {
color: var(--error);
}
/* Right Panel - Play Button */
.right-panel {
display: flex;
align-items: flex-end;
justify-content: center;
padding: 30px;
border-left: 1px solid var(--border-color);
background: var(--bg-sidebar);
}
.btn-play {
width: 100%;
padding: 20px 30px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
border-radius: var(--radius-md);
color: white;
font-size: 18px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
transition: var(--transition-normal);
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-play:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 8px 40px var(--accent-glow);
}
.btn-play:active {
transform: translateY(0);
}
.btn-play:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-update {
width: 100%;
padding: 20px 30px;
background: linear-gradient(135deg, var(--warning), #f59e0b);
border: none;
border-radius: var(--radius-md);
color: #1a1a24;
font-size: 18px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
transition: var(--transition-normal);
box-shadow: 0 4px 20px rgba(251, 191, 36, 0.4);
}
.btn-update:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 8px 40px rgba(251, 191, 36, 0.5);
}
.btn-update:active {
transform: translateY(0);
}
.btn-update:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* ==================== MODAL ==================== */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(10, 10, 15, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn var(--transition-fast) forwards;
}
.modal-content {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
animation: slideUp var(--transition-normal) forwards;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
}
.modal-close {
width: 32px;
height: 32px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 24px;
cursor: pointer;
transition: var(--transition-fast);
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-tabs {
display: flex;
padding: 16px 24px;
gap: 8px;
border-bottom: 1px solid var(--border-color);
}
.tab-btn {
flex: 1;
padding: 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: var(--transition-fast);
}
.tab-btn.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.tab-btn:hover:not(.active) {
background: var(--bg-card-hover);
}
.tab-content {
padding: 24px;
display: none;
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
}
.select-input, .text-input {
width: 100%;
padding: 12px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 14px;
transition: var(--transition-fast);
}
.select-input:focus, .text-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.select-input option {
background: var(--bg-secondary);
}
.btn-install {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
border-radius: var(--radius-sm);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: var(--transition-fast);
}
.btn-install:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
.download-progress {
padding: 24px;
border-top: 1px solid var(--border-color);
}
.progress-bar {
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 4px;
width: 0%;
transition: width var(--transition-normal);
}
.progress-text {
text-align: center;
color: var(--text-secondary);
font-size: 13px;
}
.progress-label {
margin-bottom: 8px;
font-weight: 500;
}
.progress-file {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-fill.animated {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary), var(--accent-primary));
background-size: 200% 100%;
animation: progressShimmer 1.5s ease-in-out infinite;
}
@keyframes progressShimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ==================== LOADING ==================== */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(10, 10, 15, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn var(--transition-fast) forwards;
}
.loader {
width: 48px;
height: 48px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
/* ==================== ANIMATIONS ==================== */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes cardFadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
/* ==================== RESPONSIVE ==================== */
@media (max-width: 1024px) {
.main-layout {
grid-template-columns: 240px 1fr 160px;
}
}
@media (max-width: 768px) {
.main-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.sidebar {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header {
padding-bottom: 0;
border-bottom: none;
margin-bottom: 0;
}
.sidebar-content {
display: none;
}
.sidebar-footer {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.right-panel {
padding: 16px;
border-left: none;
border-top: 1px solid var(--border-color);
}
}
/* ==================== SCROLLBAR ==================== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title>
<link rel="stylesheet" href="/css/styles.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&display=swap" rel="stylesheet">
</head>
<body>
<canvas id="grid-canvas"></canvas>
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="screen hidden">
<div class="login-container">
<div class="logo-section">
<div class="logo-placeholder">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="20" fill="#e94560"/>
<path d="M25 40 L40 25 L55 40 L40 55 Z" fill="white"/>
</svg>
</div>
<h1 class="app-title">ZernMC Launcher</h1>
<p class="app-version">v<span id="version">1.0.8</span></p>
</div>
<form id="login-form" class="login-form">
<div class="input-group">
<input type="text" id="username" name="username" placeholder="Имя пользователя" required autocomplete="username">
</div>
<div class="input-group">
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password">
</div>
<button type="submit" class="btn-primary">
<span class="btn-text">Войти</span>
<div class="btn-loader hidden"></div>
</button>
<p id="login-error" class="error-message hidden"></p>
</form>
</div>
</div>
<!-- Main Screen -->
<div id="main-screen" class="screen hidden">
<div class="main-layout">
<!-- Left Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo-small">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="10" fill="#e94560"/>
<path d="M12 20 L20 12 L28 20 L20 28 Z" fill="white"/>
</svg>
</div>
<div class="header-info">
<h1 class="header-title">ZernMC</h1>
<span class="header-version">v<span id="header-version">1.0.8</span></span>
</div>
</div>
<div class="sidebar-content">
<!-- Current Instance -->
<div class="current-instance-section">
<h3 class="section-label">Текущая сборка</h3>
<div id="current-instance" class="current-instance">
<div class="instance-card-mini">
<span class="instance-name">Загрузка...</span>
<span class="instance-version">-</span>
</div>
</div>
</div>
<!-- Download Button -->
<button id="download-btn" class="btn-download">
<svg width="20" height="20" 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>
Скачать сборку
</button>
</div>
<div class="sidebar-footer">
<span class="username-display" id="username-display"></span>
<button class="btn-logout" id="logout-btn" title="Выйти">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="logs-section">
<div class="logs-header">
<h2>Логи</h2>
<button class="btn-clear-logs" id="clear-logs">Очистить</button>
</div>
<div id="logs-container" class="logs-container">
<div class="log-entry info">Ожидание запуска...</div>
</div>
</div>
</main>
<!-- Right Panel - Play Button -->
<div class="right-panel">
<button id="play-btn" class="btn-play">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
ИГРАТЬ
</button>
</div>
</div>
</div>
<!-- Download Modal -->
<div id="download-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Скачать сборку</h2>
<button class="modal-close" id="close-download-modal">&times;</button>
</div>
<div class="modal-tabs">
<button class="tab-btn active" data-tab="zernmc">ZernMC сборки</button>
<button class="tab-btn" data-tab="vanilla">Чистый Minecraft</button>
</div>
<!-- ZernMC Tab -->
<div id="tab-zernmc" class="tab-content active">
<div class="form-group">
<label>Выберите сборку</label>
<select id="zernmc-pack-select" class="select-input">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label>Название сборки (системное)</label>
<input type="text" id="zernmc-instance-name" class="text-input" placeholder="my-zernmc-pack">
</div>
<button id="install-zernmc-btn" class="btn-install">
Скачать и установить
</button>
</div>
<!-- Vanilla Tab -->
<div id="tab-vanilla" class="tab-content">
<div class="form-group">
<label>Версия Minecraft</label>
<select id="mc-version-select" class="select-input">
<option value="">Выберите версию</option>
</select>
</div>
<div class="form-group">
<label>Лоадер</label>
<select id="loader-select" class="select-input">
<option value="vanilla">Vanilla (без лоадера)</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</div>
<div id="loader-version-group" class="form-group hidden">
<label>Версия лоадера</label>
<select id="loader-version-select" class="select-input">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label>Название сборки</label>
<input type="text" id="vanilla-instance-name" class="text-input" placeholder="my-minecraft">
</div>
<button id="install-vanilla-btn" class="btn-install">
Скачать и установить
</button>
</div>
<div id="download-progress" class="download-progress hidden">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<p class="progress-text" id="progress-text">Загрузка...</p>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="loader"></div>
<p>Загрузка...</p>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>
@@ -0,0 +1,638 @@
const API_BASE = '/api';
class App {
constructor() {
this.state = 'INIT';
this.username = null;
this.currentInstance = null;
this.instances = [];
this.zernmcPacks = [];
this.mcVersions = [];
this.hasUpdate = false;
this.hasMismatches = false;
this.isServerPack = false;
this.init();
}
async init() {
this.bindEvents();
this.initGridAnimation();
await this.checkAuth();
}
bindEvents() {
// Login form
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
this.handleLogin();
});
// Logout button
document.getElementById('logout-btn').addEventListener('click', () => {
this.handleLogout();
});
// Download button
document.getElementById('download-btn').addEventListener('click', () => {
this.showDownloadModal();
});
// Close modal
document.getElementById('close-download-modal').addEventListener('click', () => {
this.hideDownloadModal();
});
// Modal tabs
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// Play button
document.getElementById('play-btn').addEventListener('click', () => {
this.launchInstance();
});
// Clear logs
document.getElementById('clear-logs').addEventListener('click', () => {
this.clearLogs();
});
// Loader selection
document.getElementById('loader-select').addEventListener('change', (e) => {
this.onLoaderChange(e.target.value);
});
// Install buttons
document.getElementById('install-zernmc-btn').addEventListener('click', () => {
this.installZernMCPack();
});
document.getElementById('install-vanilla-btn').addEventListener('click', () => {
this.installVanilla();
});
}
// ==================== GRID ANIMATION ====================
initGridAnimation() {
const canvas = document.getElementById('grid-canvas');
const ctx = canvas.getContext('2d');
let mouseX = 0, mouseY = 0;
let offsetX = 0, offsetY = 0;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
};
window.addEventListener('resize', resize);
window.addEventListener('mousemove', (e) => {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
});
const animate = () => {
offsetX += (mouseX * 0.5 - offsetX) * 0.05;
offsetY += (mouseY * 0.5 - offsetY) * 0.05;
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
requestAnimationFrame(animate);
};
resize();
animate();
}
drawGrid(ctx, width, height, offsetX, offsetY) {
const gridSize = 50;
const dotSize = 1;
ctx.fillStyle = '#e94560';
for (let x = 0; x <= width; x += gridSize) {
for (let y = 0; y <= height; y += gridSize) {
const px = x + offsetX * 10;
const py = y + offsetY * 10;
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ==================== API ====================
async request(endpoint, options = {}) {
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
return await response.json();
} catch (error) {
console.error('API Error:', error);
return { success: false, error: error.message };
}
}
// ==================== AUTH ====================
async checkAuth() {
this.showLoading(true);
const result = await this.request('/auth/status');
if (result.loggedIn) {
this.username = result.username;
this.showMainScreen();
await this.loadCurrentInstance();
} else {
this.showLoginScreen();
}
this.showLoading(false);
}
async handleLogin() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('login-error');
const btn = document.querySelector('#login-form button[type="submit"]');
const btnText = btn.querySelector('.btn-text');
const btnLoader = btn.querySelector('.btn-loader');
if (!username || !password) {
this.showError('Введите имя пользователя и пароль');
return;
}
btn.disabled = true;
btnText.classList.add('hidden');
btnLoader.classList.remove('hidden');
errorEl.classList.add('hidden');
const result = await this.request('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
btn.disabled = false;
btnText.classList.remove('hidden');
btnLoader.classList.add('hidden');
if (result.success) {
this.username = result.username;
this.showMainScreen();
await this.loadCurrentInstance();
} else {
this.showError(result.error || 'Ошибка входа');
}
}
async handleLogout() {
await this.request('/auth/logout', { method: 'POST' });
this.username = null;
this.currentInstance = null;
this.showLoginScreen();
}
showError(message) {
const errorEl = document.getElementById('login-error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
// ==================== INSTANCES ====================
async loadCurrentInstance() {
const result = await this.request('/instances');
if (result.success && result.data && result.data.length > 0) {
this.currentInstance = result.data[0];
this.renderCurrentInstance(this.currentInstance);
this.isServerPack = this.currentInstance.isServerPack || false;
if (this.isServerPack) {
this.addLog('Проверка целостности файлов...', 'info');
const verifyResult = await this.request(`/instances/${this.currentInstance.name}/verify`);
if (verifyResult.success && verifyResult.data) {
this.hasMismatches = verifyResult.data.hasMismatches;
if (this.hasMismatches) {
this.addLog('Обнаружены изменённые файлы!', 'warning');
} else {
this.addLog('Файлы целы', 'success');
}
}
const updateResult = await this.request(`/instances/${this.currentInstance.name}/updates`);
if (updateResult.success && updateResult.data) {
this.hasUpdate = updateResult.data.hasUpdate;
if (this.hasUpdate) {
this.addLog('Доступно обновление: v' + updateResult.data.currentVersion + ' → v' + updateResult.data.latestVersion, 'warning');
}
}
}
this.updatePlayButton();
this.addLog('Сборка загружена: ' + this.currentInstance.name, 'success');
} else {
this.renderNoInstance();
this.enablePlayButton(false);
this.addLog('Установите сборку для игры', 'warning');
}
}
updatePlayButton() {
const btn = document.getElementById('play-btn');
if (!this.currentInstance) {
btn.disabled = true;
btn.className = 'btn-play';
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ИГРАТЬ';
return;
}
if (this.hasUpdate || this.hasMismatches) {
btn.disabled = false;
btn.className = 'btn-update';
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><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>ОБНОВИТЬ';
} else {
btn.disabled = false;
btn.className = 'btn-play';
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ИГРАТЬ';
}
}
renderCurrentInstance(instance) {
const container = document.getElementById('current-instance');
container.innerHTML = `
<div class="instance-card-mini">
<span class="instance-name">${this.escapeHtml(instance.name)}</span>
<span class="instance-version">${this.escapeHtml(instance.version || 'Vanilla')}</span>
</div>
`;
}
renderNoInstance() {
const container = document.getElementById('current-instance');
container.innerHTML = `
<div class="instance-card-mini">
<span class="instance-name" style="color: var(--text-muted)">Нет сборки</span>
<span class="instance-version" style="background: var(--bg-secondary)">Нажмите скачать</span>
</div>
`;
}
enablePlayButton(enabled) {
const btn = document.getElementById('play-btn');
btn.disabled = !enabled;
}
async launchInstance() {
if (!this.currentInstance) return;
if (this.hasUpdate || this.hasMismatches) {
await this.updateInstance();
return;
}
this.addLog('Проверка целостности файлов...', 'info');
this.enablePlayButton(false);
const result = await this.request(`/instances/${this.currentInstance.name}/launch`, {
method: 'POST'
});
if (result.success) {
this.addLog('Сборка запущена!', 'success');
} else {
this.addLog('Ошибка: ' + result.error, 'error');
this.enablePlayButton(true);
}
}
async updateInstance() {
if (!this.currentInstance || !this.isServerPack) return;
const packName = this.currentInstance.serverPackName;
if (!packName) {
this.addLog('Ошибка: неизвестная сборка', 'error');
return;
}
this.addLog('Обновление сборки...', 'info');
this.enablePlayButton(false);
const progressContainer = this.showAnimatedProgress('Обновление сборки...');
let eventSource = null;
let progressData = { totalFiles: 0, downloadedFiles: 0 };
try {
eventSource = new EventSource(`/api/instances/${this.currentInstance.name}/install/stream`);
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.phase === 'starting') {
progressData.totalFiles = data.totalFiles || 0;
this.updateAnimatedProgress(progressContainer, `Загрузка: 0/${progressData.totalFiles} файлов`, 5);
} else if (data.phase === 'downloading') {
progressData.downloadedFiles = data.downloadedFiles || 0;
const total = data.totalFiles || progressData.totalFiles || 1;
const percent = Math.round((progressData.downloadedFiles / total) * 100);
const fileName = data.currentFile ? data.currentFile.split('/').pop() : '';
const filePercent = data.filePercent || 0;
this.updateAnimatedProgress(progressContainer,
`Файл ${progressData.downloadedFiles}/${total} (${percent}%)`,
percent,
fileName,
filePercent
);
} else if (data.phase === 'complete') {
this.updateAnimatedProgress(progressContainer, 'Готово!', 100);
} else if (data.phase === 'error') {
this.addLog('Ошибка: ' + (data.message || 'неизвестная ошибка'), 'error');
}
} catch (err) {}
};
} catch (e) {
console.log('SSE not available, using fallback progress');
}
const result = await this.request('/instances/zernmc/install', {
method: 'POST',
body: JSON.stringify({
packName: packName,
instanceName: this.currentInstance.name
})
});
if (eventSource) {
eventSource.close();
}
this.hideProgress();
if (result.success) {
this.addLog('Сборка обновлена!', 'success');
this.addLog('Проверка после обновления...', 'info');
const verifyResult = await this.request(`/instances/${this.currentInstance.name}/verify`);
if (verifyResult.success && verifyResult.data) {
this.hasMismatches = verifyResult.data.hasMismatches;
}
const updateResult = await this.request(`/instances/${this.currentInstance.name}/updates`);
if (updateResult.success && updateResult.data) {
this.hasUpdate = updateResult.data.hasUpdate;
}
this.updatePlayButton();
if (!this.hasUpdate && !this.hasMismatches) {
this.addLog('Готово к игре!', 'success');
}
} else {
this.addLog('Ошибка обновления: ' + result.error, 'error');
this.updatePlayButton();
}
}
showAnimatedProgress(text) {
const progress = document.getElementById('download-progress');
const progressText = document.getElementById('progress-text');
const progressFill = document.getElementById('progress-fill');
progress.classList.remove('hidden');
progressText.innerHTML = `<div class="progress-label">${text}</div>
<div class="progress-file"></div>`;
progressFill.style.width = '5%';
progressFill.classList.add('animated');
return { container: progress, text: progressText, fill: progressFill };
}
updateAnimatedProgress(progressContainer, text, percent, fileName = '', filePercent = 0) {
const { text: progressText, fill: progressFill } = progressContainer;
if (fileName) {
progressText.innerHTML = `<div class="progress-label">${text}</div>
<div class="progress-file">${fileName} (${filePercent}%)</div>`;
} else {
progressText.innerHTML = `<div class="progress-label">${text}</div>`;
}
progressFill.style.width = percent + '%';
}
// ==================== DOWNLOAD MODAL ====================
async showDownloadModal() {
document.getElementById('download-modal').classList.remove('hidden');
await this.loadZernMCPacks();
await this.loadMCVersions();
}
hideDownloadModal() {
document.getElementById('download-modal').classList.add('hidden');
this.hideProgress();
}
switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === 'tab-' + tab);
});
}
async loadZernMCPacks() {
const select = document.getElementById('zernmc-pack-select');
select.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request('/instances/zernmc');
if (result.success && result.data && result.data.length > 0) {
this.zernmcPacks = result.data;
select.innerHTML = result.data.map(pack =>
`<option value="${this.escapeHtml(pack.name)}">${this.escapeHtml(pack.name)} (v${pack.version})</option>`
).join('');
} else {
select.innerHTML = '<option value="">Нет доступных сборок</option>';
}
}
async loadMCVersions() {
const select = document.getElementById('mc-version-select');
select.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request('/versions');
if (result.success && result.data) {
this.mcVersions = result.data;
select.innerHTML = '<option value="">Выберите версию</option>' +
result.data.map(v => `<option value="${v.id}">${v.id}</option>`).join('');
} else {
select.innerHTML = '<option value="">Не удалось загрузить</option>';
}
}
async onLoaderChange(loader) {
const loaderVersionGroup = document.getElementById('loader-version-group');
const loaderVersionSelect = document.getElementById('loader-version-select');
if (loader === 'vanilla') {
loaderVersionGroup.classList.add('hidden');
} else {
loaderVersionGroup.classList.remove('hidden');
loaderVersionSelect.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request(`/versions/${document.getElementById('mc-version-select').value}/loaders/${loader}`);
if (result.success && result.data) {
loaderVersionSelect.innerHTML = result.data.map(v =>
`<option value="${v.version}">${v.version}</option>`
).join('');
} else {
loaderVersionSelect.innerHTML = '<option value="">Нет версий</option>';
}
}
}
async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value;
const instanceName = document.getElementById('zernmc-instance-name').value;
if (!packName) {
alert('Выберите сборку');
return;
}
if (!instanceName) {
alert('Введите название сборки');
return;
}
this.showProgress('Установка ZernMC сборки...');
this.addLog('Начало установки: ' + packName, 'info');
const result = await this.request('/instances/zernmc/install', {
method: 'POST',
body: JSON.stringify({ packName, instanceName })
});
if (result.success) {
this.hideDownloadModal();
await this.loadCurrentInstance();
this.addLog('Сборка установлена!', 'success');
} else {
this.addLog('Ошибка установки: ' + result.error, 'error');
this.hideProgress();
}
}
async installVanilla() {
const mcVersion = document.getElementById('mc-version-select').value;
const loader = document.getElementById('loader-select').value;
const loaderVersion = document.getElementById('loader-version-select').value;
const instanceName = document.getElementById('vanilla-instance-name').value;
if (!mcVersion) {
alert('Выберите версию Minecraft');
return;
}
if (!instanceName) {
alert('Введите название сборки');
return;
}
if (loader !== 'vanilla' && !loaderVersion) {
alert('Выберите версию лоадера');
return;
}
this.showProgress('Установка сборки...');
this.addLog(`Начало установки: Minecraft ${mcVersion} ${loader !== 'vanilla' ? loader + ' ' + loaderVersion : ''}`, 'info');
const result = await this.request('/instances/vanilla/install', {
method: 'POST',
body: JSON.stringify({
mcVersion,
loader: loader === 'vanilla' ? null : loader,
loaderVersion: loader === 'vanilla' ? null : loaderVersion,
instanceName
})
});
if (result.success) {
this.hideDownloadModal();
await this.loadCurrentInstance();
this.addLog('Сборка установлена!', 'success');
} else {
this.addLog('Ошибка установки: ' + result.error, 'error');
this.hideProgress();
}
}
showProgress(text) {
const progress = document.getElementById('download-progress');
const progressText = document.getElementById('progress-text');
const progressFill = document.getElementById('progress-fill');
progress.classList.remove('hidden');
progressText.textContent = text;
progressFill.style.width = '50%';
}
hideProgress() {
document.getElementById('download-progress').classList.add('hidden');
}
// ==================== LOGS ====================
addLog(message, type = 'info') {
const container = document.getElementById('logs-container');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
container.appendChild(entry);
container.scrollTop = container.scrollHeight;
}
clearLogs() {
const container = document.getElementById('logs-container');
container.innerHTML = '<div class="log-entry info">Логи очищены</div>';
}
// ==================== UI HELPERS ====================
showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
showMainScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('hidden');
document.getElementById('username-display').textContent = this.username || '';
}
showLoading(show) {
const overlay = document.getElementById('loading-overlay');
if (show) {
overlay.classList.remove('hidden');
} else {
overlay.classList.add('hidden');
}
}
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
const app = new App();
@@ -0,0 +1,67 @@
package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class InstanceServiceTest {
@Test
void instanceService_instantiates() {
InstanceService service = new InstanceService();
assertNotNull(service, "InstanceService должен создаваться");
}
@Test
void getAllInstances_returnsResponse() {
InstanceService service = new InstanceService();
ApiResponse<?> response = service.getAllInstances();
assertNotNull(response, "Ответ не должен быть null");
assertTrue(response.isSuccess() || !response.isSuccess(), "Должен быть валидный ответ");
}
@Test
void getAllInstances_returnsList() {
InstanceService service = new InstanceService();
ApiResponse<?> response = service.getAllInstances();
assertNotNull(response.getData(), "Data не должен быть null");
}
@Test
void isInstanceExists_returnsBoolean() {
InstanceService service = new InstanceService();
ApiResponse<Boolean> response = service.isInstanceExists("nonexistent");
assertNotNull(response, "Ответ не должен быть null");
assertTrue(response.isSuccess(), "Проверка должна быть успешной");
assertNotNull(response.getData(), "Data должен быть boolean");
}
@Test
void isInstanceExists_nonexistentReturnsFalse() {
InstanceService service = new InstanceService();
ApiResponse<Boolean> response = service.isInstanceExists("definitely_nonexistent_12345");
assertTrue(response.isSuccess());
assertFalse(response.getData(), "Несуществующая сборка должна вернуть false");
}
@Test
void deleteInstance_invalidName_returnsError() {
InstanceService service = new InstanceService();
ApiResponse<Boolean> response = service.deleteInstance("nonexistent");
assertNotNull(response, "Ответ не должен быть null");
}
@Test
void getInstance_nonexistent_returnsError() {
InstanceService service = new InstanceService();
ApiResponse<?> response = service.getInstance("definitely_nonexistent_12345");
assertNotNull(response, "Ответ не должен быть null");
assertFalse(response.isSuccess(), "Несуществующая сборка должна вернуть ошибку");
}
}
@@ -0,0 +1,90 @@
package me.sashegdev.zernmc.launcher.auth;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for AuthManager error extraction and response parsing.
* Tests the contract between server error responses and Java client parsing.
*/
class AuthManagerParsingTest {
@Test
void extractError_simpleStringDetail() {
// Server: raise HTTPException(401, "Неверное имя пользователя или пароль")
String body = "{\"detail\":\"Неверное имя пользователя или пароль\"}";
String error = extractError(body);
assertEquals("Неверное имя пользователя или пароль", error);
}
@Test
void extractError_validationErrorArray() {
// FastAPI 422: {"detail": [{"loc": ["body", "username"], "msg": "...", "type": "..."}]}
String body = "{" +
"\"detail\":[" +
"{\"loc\":[\"body\",\"username\"],\"msg\":\"String should have at least 3 characters\",\"type\":\"string_too_short\"}" +
"]" +
"}";
String error = extractError(body);
assertEquals("String should have at least 3 characters", error);
}
@Test
void extractError_multipleValidationErrors_returnsFirst() {
String body = "{" +
"\"detail\":[" +
"{\"loc\":[\"body\",\"username\"],\"msg\":\"Username error\",\"type\":\"value_error\"}," +
"{\"loc\":[\"body\",\"password\"],\"msg\":\"Password error\",\"type\":\"value_error\"}" +
"]" +
"}";
String error = extractError(body);
assertEquals("Username error", error);
}
@Test
void extractError_plainTextBody() {
// Non-JSON error body
String body = "Internal Server Error";
String error = extractError(body);
assertEquals("Internal Server Error", error);
}
@Test
void extractError_longBody_truncated() {
String longBody = "A".repeat(300);
String error = extractError(longBody);
assertEquals(203, error.length()); // 200 + "..."
assertTrue(error.endsWith("..."));
}
@Test
void extractError_emptyDetail() {
String body = "{\"detail\":\"\"}";
String error = extractError(body);
assertEquals("", error);
}
@Test
void extractError_noDetailField_returnsBody() {
String body = "{\"error\":\"something went wrong\"}";
String error = extractError(body);
assertEquals("{\"error\":\"something went wrong\"}", error);
}
/**
* Replicates AuthManager.extractError() logic for testing.
* If this passes, the real method in AuthManager works correctly.
*/
private static String extractError(String body) {
try {
com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(body).getAsJsonObject();
if (json.has("detail")) {
if (json.get("detail").isJsonArray()) {
return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
}
return json.get("detail").getAsString();
}
} catch (Exception ignored) {}
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
}
}
@@ -0,0 +1,469 @@
package me.sashegdev.zernmc.launcher.integration;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
/**
* Integration tests: real Java client ↔ real Python server.
*
* These tests:
* 1. Start the FastAPI test server via Python subprocess
* 2. Use actual Java HTTP client code to make requests
* 3. Verify JSON parsing and response handling
*
* Requires: Python 3, pytest, and the server/.venv to be available.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ServerIntegrationTest {
private static Process serverProcess;
private static String serverBaseUrl;
private static Path testDir;
private static final Gson gson = new GsonBuilder().setPrettyPrinting().create();
@BeforeAll
static void startTestServer() throws Exception {
// Create temp directory for test data
testDir = Files.createTempDirectory("zern_integration_test_");
// Find the server directory
String serverDir = findServerDir();
if (serverDir == null) {
System.out.println("WARNING: Server directory not found, skipping integration tests");
serverBaseUrl = null;
return;
}
// Start the test server on a random port
int port = findFreePort();
serverBaseUrl = "http://127.0.0.1:" + port;
System.out.println("Starting test server on " + serverBaseUrl);
System.out.println("Server directory: " + serverDir);
// Find Python executable (prefer venv python)
String pythonPath = findPythonPath(serverDir);
if (pythonPath == null) {
System.out.println("WARNING: Python not found, skipping integration tests");
serverBaseUrl = null;
return;
}
// Create a Python startup script that properly sets up paths
String startupScript =
"import sys, os, tempfile\n" +
"from pathlib import Path\n" +
"sys.path.insert(0, '" + serverDir + "')\n" +
"os.chdir('" + serverDir + "')\n" +
"import auth\n" +
"db_dir = tempfile.mkdtemp()\n" +
"auth.AUTH_DB = Path(db_dir) / 'auth.db'\n" +
"auth.SECRET_KEY = Path(db_dir) / '.secret_key'\n" +
"auth.init_db()\n" +
"import uvicorn\n" +
"import main\n" +
"uvicorn.run(main.app, host='127.0.0.1', port=" + port + ", log_level='error')\n";
ProcessBuilder pb = new ProcessBuilder(pythonPath, "-c", startupScript);
pb.directory(new File(serverDir));
pb.redirectErrorStream(true);
try {
serverProcess = pb.start();
System.out.println("Server process started, PID: " + serverProcess.pid());
} catch (IOException e) {
System.out.println("WARNING: Could not start server process: " + e.getMessage());
System.out.println("Skipping integration tests");
serverBaseUrl = null;
return;
}
// Wait for server to start
Thread.sleep(4000);
// Verify server is running
try {
URL url = new URL(serverBaseUrl + "/health");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.connect();
if (conn.getResponseCode() != 200) {
System.out.println("WARNING: Server health check failed: " + conn.getResponseCode());
System.out.println("Skipping integration tests");
serverBaseUrl = null;
if (serverProcess != null) serverProcess.destroy();
conn.disconnect();
return;
}
conn.disconnect();
System.out.println("Test server started successfully");
} catch (Exception e) {
System.out.println("WARNING: Server failed to start: " + e.getMessage());
System.out.println("Skipping integration tests");
serverBaseUrl = null;
if (serverProcess != null) serverProcess.destroy();
}
}
@AfterAll
static void stopTestServer() {
if (serverProcess != null) {
serverProcess.destroy();
try {
serverProcess.waitFor(5000, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {}
}
// Cleanup temp dir
if (testDir != null) {
try {
Files.walk(testDir)
.sorted(java.util.Comparator.reverseOrder())
.forEach(path -> {
try { Files.delete(path); } catch (IOException ignored) {}
});
} catch (IOException ignored) {}
}
}
@BeforeEach
void setUp() {
if (serverBaseUrl != null) {
ZHttpClient.setBaseUrl(serverBaseUrl);
}
}
// ===== Auth flow tests =====
@Test
@Order(1)
void testRegister() throws Exception {
assumeServerRunning();
String response = httpPost("/auth/register", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("access_token"));
assertTrue(json.has("refresh_token"));
assertTrue(json.has("expires_in"));
assertTrue(json.has("uuid"));
assertEquals("integration_test_user", json.get("username").getAsString());
assertTrue(json.has("role"));
}
@Test
@Order(2)
void testLogin() throws Exception {
assumeServerRunning();
String response = httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("access_token"));
assertTrue(json.has("refresh_token"));
assertEquals("integration_test_user", json.get("username").getAsString());
assertTrue(json.has("role"));
assertTrue(json.has("uuid"));
}
@Test
@Order(3)
void testDuplicateRegistration() throws Exception {
assumeServerRunning();
try {
httpPost("/auth/register", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"AnotherPassword123\"" +
"}");
fail("Should have thrown IOException for duplicate registration");
} catch (IOException e) {
assertTrue(e.getMessage().contains("409") || e.getMessage().contains("409"),
"Expected 409 conflict, got: " + e.getMessage());
}
}
@Test
@Order(4)
void testLoginWrongPassword() throws Exception {
assumeServerRunning();
try {
httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"WrongPassword\"" +
"}");
fail("Should have thrown IOException for wrong password");
} catch (IOException e) {
assertTrue(e.getMessage().contains("401"),
"Expected 401, got: " + e.getMessage());
}
}
@Test
@Order(5)
void testGetAdminMe() throws Exception {
assumeServerRunning();
// Login to get token
String loginResp = httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
String token = loginJson.get("access_token").getAsString();
// Get user info
String response = httpGet("/admin/me", token);
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("id"));
assertEquals("integration_test_user", json.get("username").getAsString());
assertTrue(json.has("uuid"));
assertTrue(json.has("role"));
assertTrue(json.has("role_name"));
assertTrue(json.has("has_pass"));
assertTrue(json.has("permissions"));
}
@Test
@Order(6)
void testValidateToken() throws Exception {
assumeServerRunning();
String loginResp = httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
String token = loginJson.get("access_token").getAsString();
String uuid = loginJson.get("uuid").getAsString();
// Validate
String response = httpPost("/auth/validate",
"{\"username\":\"integration_test_user\",\"uuid\":\"" + uuid + "\"}",
token);
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("valid"));
assertTrue(json.get("valid").getAsBoolean());
assertEquals("integration_test_user", json.get("username").getAsString());
}
@Test
@Order(7)
void testRefreshToken() throws Exception {
assumeServerRunning();
String loginResp = httpPost("/auth/login", "{" +
"\"username\":\"integration_test_user\"," +
"\"password\":\"IntegrationTest123\"" +
"}");
JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
String refreshToken = loginJson.get("refresh_token").getAsString();
// Refresh
String response = httpPost("/auth/refresh",
"{\"refresh_token\":\"" + refreshToken + "\"}");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("access_token"));
assertTrue(json.has("refresh_token"));
assertTrue(json.has("expires_in"));
assertEquals("integration_test_user", json.get("username").getAsString());
}
// ===== Pack endpoint tests =====
@Test
@Order(8)
void testPacksNoAuth() throws Exception {
assumeServerRunning();
try {
httpGet("/packs");
fail("Should have thrown IOException for unauthenticated access");
} catch (IOException e) {
assertTrue(e.getMessage().contains("401") || e.getMessage().contains("403"));
}
}
@Test
@Order(9)
void testPackManifestPublic() throws Exception {
assumeServerRunning();
// /pack/{name} is public
try {
String response = httpGet("/pack/nonexistent-pack");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
fail("Should have thrown IOException for non-existent pack");
} catch (IOException e) {
assertTrue(e.getMessage().contains("404"),
"Expected 404, got: " + e.getMessage());
}
}
@Test
@Order(10)
void testLauncherVersion() throws Exception {
assumeServerRunning();
String response = httpGet("/launcher/version");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
assertTrue(json.has("version") || json.has("latest"));
}
// ===== Helper methods =====
private static void assumeServerRunning() {
org.junit.jupiter.api.Assumptions.assumeTrue(
serverBaseUrl != null && serverProcess != null && serverProcess.isAlive(),
"Test server is not running"
);
}
private static String httpPost(String endpoint, String body) throws IOException {
return httpPost(endpoint, body, null);
}
private static String httpPost(String endpoint, String body, String token) throws IOException {
URL url = new URL(serverBaseUrl + endpoint);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
if (token != null) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
conn.setDoOutput(true);
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
byte[] input = body.getBytes(StandardCharsets.UTF_8);
conn.setFixedLengthStreamingMode(input.length);
try (var os = conn.getOutputStream()) {
os.write(input);
}
int code = conn.getResponseCode();
String response = readResponse(conn, code);
if (code >= 400) {
throw new IOException("HTTP " + code + ": " + response);
}
conn.disconnect();
return response;
}
private static String httpGet(String endpoint) throws IOException {
return httpGet(endpoint, null);
}
private static String httpGet(String endpoint, String token) throws IOException {
URL url = new URL(serverBaseUrl + endpoint);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
if (token != null) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
int code = conn.getResponseCode();
String response = readResponse(conn, code);
if (code >= 400) {
throw new IOException("HTTP " + code + ": " + response);
}
conn.disconnect();
return response;
}
private static String readResponse(HttpURLConnection conn, int code) throws IOException {
var is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
if (is == null) {
return "";
}
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
return scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
}
}
private static String findPythonPath(String serverDir) {
String[] paths = {
serverDir + "/.venv/bin/python3",
serverDir + "/.venv/bin/python",
"python3",
"python"
};
for (String path : paths) {
File f = new File(path);
if (f.exists() && f.canExecute()) {
return path;
}
// Try which command
try {
Process p = new ProcessBuilder(path, "--version").start();
int exit = p.waitFor();
if (exit == 0) return path;
} catch (Exception ignored) {}
}
return null;
}
private static String findServerDir() {
String[] paths = {
"../server",
"server",
System.getenv("SERVER_DIR")
};
for (String path : paths) {
if (path != null && new File(path).exists() && new File(path, "main.py").exists()) {
return path;
}
}
return null;
}
private static int findFreePort() throws IOException {
try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) {
return socket.getLocalPort();
}
}
private static String readProcessOutput() throws IOException {
if (serverProcess == null) return "";
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(serverProcess.getInputStream(), StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
return sb.toString();
}
}
}
@@ -0,0 +1,287 @@
package me.sashegdev.zernmc.launcher.minecraft;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for PackDownloader JSON parsing.
* Tests that the Java client correctly parses server JSON responses.
*/
class PackDownloaderParsingTest {
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
// ===== /packs response parsing =====
@Test
void parsePacksResponse_singlePack() {
String body = "{" +
"\"packs\":[" +
"{" +
"\"name\":\"test-modpack\"," +
"\"version\":3," +
"\"files_count\":15," +
"\"updated_at\":\"2024-01-15T10:30:00\"," +
"\"minecraft_version\":\"1.20.4\"," +
"\"loader_type\":\"fabric\"," +
"\"loader_version\":\"0.15.6\"" +
"}" +
"]" +
"}";
List<ServerPack> packs = parsePacksResponse(body);
assertEquals(1, packs.size());
ServerPack pack = packs.get(0);
assertEquals("test-modpack", pack.getName());
assertEquals(3, pack.getVersion());
assertEquals(15, pack.getFilesCount());
assertEquals("1.20.4", pack.getMinecraftVersion());
assertEquals("fabric", pack.getLoaderType());
assertEquals("0.15.6", pack.getLoaderVersion());
assertNotNull(pack.getUpdatedAt());
}
@Test
void parsePacksResponse_multiplePacks() {
String body = "{" +
"\"packs\":[" +
"{\"name\":\"survival\",\"version\":1,\"files_count\":5,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
"{\"name\":\"pvp\",\"version\":10,\"files_count\":50,\"minecraft_version\":\"1.20.4\",\"loader_type\":\"fabric\",\"loader_version\":\"0.15.6\",\"updated_at\":\"2024-02-01T00:00:00\"}" +
"]" +
"}";
List<ServerPack> packs = parsePacksResponse(body);
assertEquals(2, packs.size());
assertEquals("survival", packs.get(0).getName());
assertEquals("pvp", packs.get(1).getName());
}
@Test
void parsePacksResponse_skipsErroredPacks() {
String body = "{" +
"\"packs\":[" +
"{\"name\":\"good-pack\",\"version\":1,\"files_count\":1,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
"{\"name\":\"bad-pack\",\"error\":\"scan failed\"}," +
"{\"name\":\"not-scanned\",\"status\":\"not_scanned\"}" +
"]" +
"}";
List<ServerPack> packs = parsePacksResponse(body);
assertEquals(1, packs.size());
assertEquals("good-pack", packs.get(0).getName());
}
@Test
void parsePacksResponse_missingFields_defaults() {
String body = "{" +
"\"packs\":[" +
"{\"name\":\"minimal-pack\"}" +
"]" +
"}";
List<ServerPack> packs = parsePacksResponse(body);
assertEquals(1, packs.size());
ServerPack pack = packs.get(0);
assertEquals("minimal-pack", pack.getName());
assertEquals(0, pack.getVersion()); // default
assertEquals("unknown", pack.getMinecraftVersion()); // default
assertEquals("vanilla", pack.getLoaderType()); // default
assertEquals("", pack.getLoaderVersion()); // default
assertEquals(0, pack.getFilesCount()); // default
assertNull(pack.getUpdatedAt()); // default
}
@Test
void parsePacksResponse_emptyList() {
String body = "{\"packs\":[]}";
List<ServerPack> packs = parsePacksResponse(body);
assertTrue(packs.isEmpty());
}
// ===== PackManifest parsing =====
@Test
void parsePackManifest_withFiles() {
String body = "{" +
"\"pack_name\":\"my-pack\"," +
"\"version\":5," +
"\"minecraft_version\":\"1.20.4\"," +
"\"loader_type\":\"fabric\"," +
"\"loader_version\":\"0.15.6\"," +
"\"asset_index\":\"1.20.4\"," +
"\"files\":{" +
"\"mods/sodium.jar\":{\"path\":\"mods/sodium.jar\",\"url\":\"/pack/my-pack/file/mods/sodium.jar\",\"size\":1024000,\"hash\":\"abc123\"}," +
"\"mods/fabric-api.jar\":{\"path\":\"mods/fabric-api.jar\",\"url\":\"/pack/my-pack/file/mods/fabric-api.jar\",\"size\":2048000,\"hash\":\"def456\"}" +
"}" +
"}";
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
assertEquals("my-pack", manifest.getPackName());
assertEquals(5, manifest.getVersion());
assertEquals("1.20.4", manifest.getMinecraftVersion());
assertEquals("fabric", manifest.getLoaderType());
assertEquals("0.15.6", manifest.getLoaderVersion());
assertEquals("1.20.4", manifest.getAssetIndex());
assertFalse(manifest.isEmpty());
assertEquals(2, manifest.getFiles().size());
}
@Test
void parsePackManifest_nullAssetIndex_defaultsToMinecraftVersion() {
String body = "{" +
"\"pack_name\":\"no-asset\"," +
"\"version\":1," +
"\"minecraft_version\":\"1.19.4\"," +
"\"loader_type\":\"vanilla\"," +
"\"loader_version\":null" +
"}";
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
assertEquals("1.19.4", manifest.getAssetIndex()); // defaults to minecraft_version
}
@Test
void parsePackManifest_noFiles_isEmpty() {
String body = "{" +
"\"pack_name\":\"empty-pack\"," +
"\"version\":1," +
"\"minecraft_version\":\"1.20.1\"," +
"\"loader_type\":\"vanilla\"," +
"\"loader_version\":null" +
"}";
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
assertTrue(manifest.isEmpty());
}
// ===== DiffResponse parsing =====
@Test
void parseDiffResponse_allFields() {
String body = "{" +
"\"version\":6," +
"\"to_download\":[" +
"{\"path\":\"mods/new-mod.jar\",\"url\":\"/pack/test/file/mods/new-mod.jar\",\"size\":512000,\"hash\":\"aaa111\"}" +
"]," +
"\"to_delete\":[\"mods/old-mod.jar\"]," +
"\"to_update\":[\"mods/updated-mod.jar\"]" +
"}";
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
assertEquals(6, diff.getVersion());
assertEquals(1, diff.getToDownload().size());
assertEquals(1, diff.getToDelete().size());
assertEquals(1, diff.getToUpdate().size());
PackDownloader.FileInfo fileInfo = diff.getToDownload().get(0);
assertEquals("mods/new-mod.jar", fileInfo.getPath());
assertEquals("/pack/test/file/mods/new-mod.jar", fileInfo.getUrl());
assertEquals(512000, fileInfo.getSize());
assertEquals("aaa111", fileInfo.getHash());
}
@Test
void parseDiffResponse_emptyArrays() {
String body = "{" +
"\"version\":1," +
"\"to_download\":[]," +
"\"to_delete\":[]," +
"\"to_update\":[]" +
"}";
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
assertTrue(diff.getToDownload().isEmpty());
assertTrue(diff.getToDelete().isEmpty());
assertTrue(diff.getToUpdate().isEmpty());
}
@Test
void parseDiffResponse_nullArrays_returnsEmpty() {
String body = "{\"version\":1}";
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
assertNotNull(diff.getToDownload());
assertNotNull(diff.getToDelete());
assertNotNull(diff.getToUpdate());
assertTrue(diff.getToDownload().isEmpty());
assertTrue(diff.getToDelete().isEmpty());
}
// ===== ServerPack toString =====
@Test
void serverPack_toString_withDate() {
java.time.LocalDateTime date = java.time.LocalDateTime.of(2024, 3, 15, 12, 0);
ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", date, 25);
String str = pack.toString();
assertTrue(str.contains("my-pack"));
assertTrue(str.contains("1.20.4"));
assertTrue(str.contains("fabric"));
assertTrue(str.contains("25 файлов"));
assertTrue(str.contains("15.03.2024"));
}
@Test
void serverPack_toString_withoutDate() {
ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", null, 25);
String str = pack.toString();
assertTrue(str.contains("my-pack"));
assertTrue(str.contains("25 файлов"));
assertFalse(str.contains("обновлен"));
}
// ===== Helper: replicates PackDownloader.parsePacksResponse() =====
private static List<ServerPack> parsePacksResponse(String responseBody) {
JsonObject root = com.google.gson.JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray packsArray = root.getAsJsonArray("packs");
List<ServerPack> result = new ArrayList<>();
for (var elem : packsArray) {
JsonObject pack = elem.getAsJsonObject();
if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
continue;
}
try {
String name = pack.get("name").getAsString();
int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull()
? pack.get("loader_version").getAsString() : "";
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
java.time.LocalDateTime updatedAt = null;
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
try {
updatedAt = java.time.LocalDateTime.parse(pack.get("updated_at").getAsString(),
java.time.format.DateTimeFormatter.ISO_DATE_TIME);
} catch (Exception ignored) {}
}
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
loaderVersion, updatedAt, filesCount));
} catch (Exception e) {
System.err.println("Ошибка парсинга пака: " + e.getMessage());
}
}
return result;
}
}
@@ -0,0 +1,33 @@
package me.sashegdev.zernmc.launcher.web;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.GraphicsEnvironment;
class HeadlessDetectionTest {
@Test
void headlessDetection_works() {
boolean isHeadless = GraphicsEnvironment.isHeadless();
assertNotNull(isHeadless, "isHeadless() должен возвращать boolean");
}
@Test
void headlessDetection_consistentResult() {
boolean isHeadless1 = GraphicsEnvironment.isHeadless();
boolean isHeadless2 = GraphicsEnvironment.isHeadless();
assertEquals(isHeadless1, isHeadless2, "Результат должен быть консистентным");
}
@Test
void javaFxCheck_works() {
try {
boolean isHeadless = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()
.getDefaultScreenDevice() != null;
assertFalse(isHeadless, "На Linux без дисплея должно быть headless");
} catch (Exception e) {
assertTrue(true, "Ожидаемая ошибка на headless");
}
}
}
@@ -0,0 +1,37 @@
package me.sashegdev.zernmc.launcher.web;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.net.ServerSocket;
class WebServerTest {
@Test
void findFreePort_returnsValidPort() throws IOException {
int port = WebServer.findFreePort(8080);
assertTrue(port >= 8080, "Порт должен быть >= 8080");
assertTrue(port < 8180, "Порт должен быть < 8180");
}
@Test
void findFreePort_findsDifferentPorts() throws IOException {
int port1 = WebServer.findFreePort(9000);
int port2 = WebServer.findFreePort(9100);
assertNotEquals(port1, port2, "Должены быть разные порты");
}
@Test
void findFreePort_respectsStartPort() throws IOException {
int port = WebServer.findFreePort(9500);
assertTrue(port >= 9500, "Порт должен быть >= указанного startPort");
}
@Test
void portRangeTest() throws IOException {
int port = WebServer.findFreePort(8080);
assertTrue(port >= 8080 && port < 8180, "Порт в допустимом диапазоне 8080-8179");
}
}
+589
View File
@@ -0,0 +1,589 @@
# admin_router.py
from fastapi import APIRouter, HTTPException, Depends, Request, status
from pydantic import BaseModel, Field
from typing import Optional, List
import structlog
import time
import secrets
from datetime import datetime
from auth import get_db, require_role, log_audit, get_current_user
from roles import (
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"])
# ====================== МОДЕЛИ ======================
class UpdateRoleRequest(BaseModel):
user_id: int
role: int = Field(..., ge=0, le=4)
class PassRequest(BaseModel):
username: str
reason: Optional[str] = None
class PassDecision(BaseModel):
request_id: int
approved: bool
reason: Optional[str] = None
class CreatePassDirectRequest(BaseModel):
username: str
expires_days: Optional[int] = Field(None, ge=1, le=365)
max_uses: int = Field(1, ge=1, le=10)
class BanUserRequest(BaseModel):
user_id: int
days: int = Field(..., ge=1, le=365)
reason: str
# ====================== ЭНДПОИНТЫ ======================
@router.get("/users")
async def list_users(
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
search: Optional[str] = None
):
"""Список пользователей (модераторы видят всех, но без sensitive данных)"""
with get_db() as conn:
query = "SELECT id, username, uuid, role, created_at, last_login, is_active"
params = []
if current_user["role"] < ROLE_ELDER:
# Модераторы не видят забаненных
query += " FROM users WHERE is_active = 1"
else:
query += " FROM users"
if search:
query += " AND (username LIKE ? OR email LIKE ?)"
params.extend([f"%{search}%", f"%{search}%"])
query += " ORDER BY role DESC, username"
rows = conn.execute(query, params).fetchall()
users = []
for row in rows:
user_data = {
"id": row["id"],
"username": row["username"],
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
"created_at": row["created_at"],
"last_login": row["last_login"],
}
# Elder и Creator видят больше информации
if current_user["role"] >= ROLE_ELDER:
user_data["is_active"] = row["is_active"]
# Получаем информацию о проходке
pass_info = conn.execute("""
SELECT p.code, p.expires_at, up.activated_at
FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
LIMIT 1
""", (row["id"], time.time())).fetchone()
if pass_info:
user_data["has_pass"] = True
user_data["pass_expires"] = pass_info["expires_at"]
users.append(user_data)
return {"users": users, "total": len(users)}
@router.get("/users/{user_id}")
async def get_user_detail(
user_id: int,
current_user: dict = Depends(require_role(ROLE_MODERATOR))
):
"""Детальная информация о пользователе"""
with get_db() as conn:
row = conn.execute("""
SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until
FROM users WHERE id = ?
""", (user_id,)).fetchone()
if not row:
raise HTTPException(404, "Пользователь не найден")
# Модераторы не видят email обычных пользователей
if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR:
email = None
else:
email = row["email"]
# Получаем активную проходку
pass_info = None
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
pass_row = conn.execute("""
SELECT p.code, p.expires_at, up.activated_at
FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
LIMIT 1
""", (user_id, time.time())).fetchone()
if pass_row:
pass_info = {
"code": pass_row["code"][:8] + "..." if current_user["role"] < ROLE_ELDER else pass_row["code"],
"expires_at": pass_row["expires_at"],
"activated_at": pass_row["activated_at"]
}
# Логи действий (только для Elder+)
actions = []
if current_user["role"] >= ROLE_ELDER:
action_rows = conn.execute("""
SELECT action, details, timestamp FROM audit_log
WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20
""", (user_id,)).fetchall()
actions = [dict(row) for row in action_rows]
return {
"id": row["id"],
"username": row["username"],
"email": email,
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
"created_at": row["created_at"],
"last_login": row["last_login"],
"is_active": row["is_active"],
"banned_until": row["banned_until"],
"has_pass": pass_info is not None,
"pass_info": pass_info,
"recent_actions": actions if current_user["role"] >= ROLE_ELDER else None
}
@router.put("/users/{user_id}/role")
async def update_user_role(
user_id: int,
body: UpdateRoleRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Изменение роли пользователя"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
raise HTTPException(404, "Пользователь не найден")
# Проверки прав
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Нельзя изменить роль создателя")
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Нельзя изменять роль пользователя с равным или высшим уровнем")
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, f"Нельзя назначить роль выше своей ({ROLE_NAMES[current_user['role']]})")
# Elder не может создавать других Elder (только Creator)
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Только создатель может назначать Elder Moderator")
# Проверяем, нужно ли выдать/отозвать проходку
old_role = target["role"]
new_role = body.role
conn.execute(
"UPDATE users SET role = ? WHERE id = ?",
(new_role, user_id)
)
# Управление проходками при изменении роли
now = time.time()
if new_role >= ROLE_PASS_HOLDER and old_role < ROLE_PASS_HOLDER:
# Выдаем проходку если её нет
existing = conn.execute("""
SELECT 1 FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (user_id, now)).fetchone()
if not existing:
# Создаем автоматическую проходку
pass_code = f"AUTO_{secrets.token_hex(8).upper()}"
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
VALUES (?, ?, NULL, 1, 1)
""", (pass_code, target["username"]))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
VALUES (?, ?, ?)
""", (user_id, pass_code, now))
logger.info("Auto-pass issued", user=target["username"], role=new_role)
elif new_role < ROLE_PASS_HOLDER and old_role >= ROLE_PASS_HOLDER:
# Отзываем проходку
conn.execute("""
UPDATE passes SET is_active = 0
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
""", (user_id,))
logger.info("Auto-pass revoked", user=target["username"])
conn.commit()
log_audit(
current_user["id"],
"role_change",
f"Changed role of {target['username']} from {old_role} to {new_role}",
ip
)
logger.info("Role updated", admin=current_user["username"], target=target["username"], new_role=new_role)
return {
"success": True,
"user_id": user_id,
"username": target["username"],
"old_role": old_role,
"old_role_name": ROLE_NAMES.get(old_role, "Неизвестно"),
"new_role": new_role,
"new_role_name": ROLE_NAMES.get(new_role, "Неизвестно")
}
@router.post("/pass/grant")
async def grant_pass(
body: CreatePassDirectRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Выдача проходки пользователю (Elder+ могут выдавать)"""
ip = request.client.host if request.client else "unknown"
# Проверяем право на прямую выдачу
if current_user["role"] < ROLE_CREATOR and not has_permission(current_user["role"], Permissions.APPROVE_PASS):
raise HTTPException(403, "Недостаточно прав для выдачи проходки")
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
(body.username,)
).fetchone()
if not target:
raise HTTPException(404, f"Пользователь {body.username} не найден")
# Проверяем, есть ли уже активная проходка
existing = conn.execute("""
SELECT p.code FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (target["id"], time.time())).fetchone()
if existing:
raise HTTPException(409, f"У пользователя {body.username} уже есть активная проходка")
# Создаем проходку
pass_code = secrets.token_hex(12).upper()
now = time.time()
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
VALUES (?, ?, ?, ?, 1)
""", (pass_code, target["username"], expires_at, body.max_uses))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
VALUES (?, ?, ?)
""", (target["id"], pass_code, now))
# Обновляем роль если нужно
if target["role"] < ROLE_PASS_HOLDER:
conn.execute(
"UPDATE users SET role = ? WHERE id = ?",
(ROLE_PASS_HOLDER, target["id"])
)
conn.commit()
log_audit(
current_user["id"],
"grant_pass",
f"Granted pass to {target['username']} (expires: {body.expires_days}d, max_uses: {body.max_uses})",
ip
)
logger.info("Pass granted", admin=current_user["username"], target=target["username"], code=pass_code)
return {
"success": True,
"pass_code": pass_code,
"username": target["username"],
"expires_at": expires_at,
"expires_days": body.expires_days,
"max_uses": body.max_uses
}
@router.delete("/pass/revoke/{username}")
async def revoke_pass(
username: str,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Отзыв проходки у пользователя"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
(username,)
).fetchone()
if not target:
raise HTTPException(404, f"Пользователь {username} не найден")
# Отзываем проходку
conn.execute("""
UPDATE passes SET is_active = 0
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
""", (target["id"],))
# Понижаем роль если она была только из-за проходки
if target["role"] == ROLE_PASS_HOLDER:
conn.execute(
"UPDATE users SET role = ? WHERE id = ?",
(ROLE_USER, target["id"])
)
conn.commit()
log_audit(current_user["id"], "revoke_pass", f"Revoked pass from {username}", ip)
logger.info("Pass revoked", admin=current_user["username"], target=username)
return {"success": True, "message": f"Проходка {username} отозвана"}
@router.post("/user/ban")
async def ban_user(
body: BanUserRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Бан пользователя (Elder+ могут банить)"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE id = ?",
(body.user_id,)
).fetchone()
if not target:
raise HTTPException(404, "Пользователь не найден")
# Нельзя забанить создателя
if target["role"] == ROLE_CREATOR:
raise HTTPException(403, "Нельзя забанить создателя")
# Elder не может банить других Elder
if target["role"] >= ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Недостаточно прав для бана этого пользователя")
banned_until = time.time() + (body.days * 86400)
conn.execute(
"UPDATE users SET is_active = 0, banned_until = ? WHERE id = ?",
(banned_until, target["id"])
)
# Отзываем проходку при бане
conn.execute("""
UPDATE passes SET is_active = 0
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
""", (target["id"],))
conn.commit()
log_audit(
current_user["id"],
"ban_user",
f"Banned {target['username']} for {body.days} days. Reason: {body.reason}",
ip
)
logger.info("User banned", admin=current_user["username"], target=target["username"], days=body.days)
return {
"success": True,
"username": target["username"],
"banned_until": banned_until,
"days": body.days
}
@router.post("/user/unban/{user_id}")
async def unban_user(
user_id: int,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Разбан пользователя"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
raise HTTPException(404, "Пользователь не найден")
conn.execute(
"UPDATE users SET is_active = 1, banned_until = NULL WHERE id = ?",
(user_id,)
)
conn.commit()
log_audit(current_user["id"], "unban_user", f"Unbanned {target['username']}", ip)
logger.info("User unbanned", admin=current_user["username"], target=target["username"])
return {"success": True, "username": target["username"]}
@router.get("/audit")
async def get_audit_log(
current_user: dict = Depends(require_role(ROLE_ELDER)),
limit: int = 50,
offset: int = 0,
user_id: Optional[int] = None
):
"""Просмотр логов аудита (только Elder+)"""
with get_db() as conn:
query = """
SELECT al.*, u.username
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
"""
params = []
if user_id:
query += " WHERE al.user_id = ?"
params.append(user_id)
query += " ORDER BY al.timestamp DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(query, params).fetchall()
total = conn.execute("SELECT COUNT(*) as count FROM audit_log").fetchone()["count"]
return {
"logs": [dict(row) for row in rows],
"total": total,
"limit": limit,
"offset": offset
}
@router.get("/stats")
async def get_admin_stats(
current_user: dict = Depends(require_role(ROLE_MODERATOR))
):
"""Статистика для админов"""
with get_db() as conn:
# Общая статистика
total_users = conn.execute("SELECT COUNT(*) as count FROM users").fetchone()["count"]
# Статистика по ролям
role_stats = conn.execute("""
SELECT role, COUNT(*) as count
FROM users
GROUP BY role
ORDER BY role DESC
""").fetchall()
# Активные проходки
active_passes = conn.execute("""
SELECT COUNT(*) as count FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE p.expires_at IS NULL OR p.expires_at > ?
""", (time.time(),)).fetchone()["count"]
# Забаненные пользователи
banned_users = conn.execute("""
SELECT COUNT(*) as count FROM users
WHERE is_active = 0 AND banned_until > ?
""", (time.time(),)).fetchone()["count"]
# Недавние регистрации (последние 7 дней)
week_ago = time.time() - (7 * 86400)
recent_registrations = conn.execute("""
SELECT COUNT(*) as count FROM users WHERE created_at > ?
""", (week_ago,)).fetchone()["count"]
return {
"total_users": total_users,
"active_passes": active_passes,
"banned_users": banned_users,
"recent_registrations_7d": recent_registrations,
"roles_distribution": [
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
for r in role_stats
],
"my_info": {
"role": current_user["role"],
"role_name": ROLE_NAMES.get(current_user["role"], "Неизвестно"),
"username": current_user["username"]
}
}
@router.get("/me")
async def get_my_info(current_user: dict = Depends(get_current_user)):
"""Информация о текущем пользователе с правами"""
with get_db() as conn:
row = conn.execute("""
SELECT id, username, uuid, role, created_at, last_login
FROM users WHERE id = ?
""", (current_user["id"],)).fetchone()
# Проверяем наличие активной проходки
has_pass = False
if row["role"] >= ROLE_PASS_HOLDER:
pass_row = conn.execute("""
SELECT 1 FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (current_user["id"], time.time())).fetchone()
has_pass = pass_row is not None
permissions = list(ROLE_PERMISSIONS.get(row["role"], set()))
return {
"id": row["id"],
"username": row["username"],
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
"created_at": row["created_at"],
"last_login": row["last_login"],
"has_pass": has_pass,
"permissions": permissions
}
+605 -260
View File
File diff suppressed because it is too large Load Diff
-25
View File
@@ -1,25 +0,0 @@
# http_logger.py
import logging
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
import time
import uuid
logger = logging.getLogger("uvicorn.error")
class HTTPLogger:
"""Custom HTTP logger to catch invalid requests"""
@staticmethod
def log_invalid_request(data: bytes, client_addr: tuple):
"""Log invalid HTTP requests"""
try:
# Try to decode as much as possible
request_str = data.decode('utf-8', errors='replace')[:500]
logger.warning(
f"Invalid HTTP request received\n"
f"Client: {client_addr[0]}:{client_addr[1]}\n"
f"Data: {request_str}"
)
except Exception as e:
logger.warning(f"Invalid HTTP request from {client_addr}, could not decode: {e}")
+448 -46
View File
@@ -1,12 +1,15 @@
from fastapi import FastAPI, HTTPException, Request, Response import re
from fastapi.responses import FileResponse, JSONResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
import httpx
import json import json
import structlog import structlog
from cachetools import TTLCache from cachetools import TTLCache
import logging from fastapi import Depends, FastAPI, HTTPException, Request, Response
from datetime import datetime from fastapi.responses import FileResponse, JSONResponse
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
@@ -15,11 +18,9 @@ from middleware import LoggingMiddleware
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
from log_manager import init_logging from log_manager import init_logging
import httpx from auth import get_current_user, router as auth_router, init_db, verify_jwt
import base64 from roles import Permissions, has_permission
from fastapi.responses import StreamingResponse from admin_router import router as admin_router
from auth import router as auth_router, init_db, verify_jwt
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -35,7 +36,6 @@ async def lifespan(app: FastAPI):
# Initialize logging # Initialize logging
init_logging() init_logging()
#logger = logging.getLogger(__name__)
# Determine environment # Determine environment
if args.test: if args.test:
@@ -53,8 +53,6 @@ async def lifespan(app: FastAPI):
DATA_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True)
init_db() init_db()
app.include_router(auth_router)
if args.test: if args.test:
await run_test_mode() await run_test_mode()
@@ -76,16 +74,355 @@ async def lifespan(app: FastAPI):
logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True) logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True)
logger.info("All packs ready. Server is running.") logger.info("All packs ready. Server is running.")
# Initialize proxy client
global proxy_client
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
yield yield
# Cleanup proxy client
if proxy_client:
await proxy_client.aclose()
logger.info("Server shutting down...") logger.info("Server shutting down...")
# ====================== ШАБЛОН СТРАНИЦЫ АКТИВАЦИИ ======================
ACTIVATE_PASS_HTML = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Активация проходки | ZernMC</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 40px;
max-width: 450px;
width: 100%;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.logo {
text-align: center;
margin-bottom: 30px;
}
.logo h1 {
color: #00d4ff;
font-size: 32px;
font-weight: 700;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
}
.logo p {
color: #8892b0;
margin-top: 8px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
color: #ccd6f6;
margin-bottom: 8px;
font-weight: 500;
}
input {
width: 100%;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.07);
border: 1.5px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: #ffffff;
font-size: 16px;
transition: all 0.3s ease;
outline: none;
}
input:focus {
border-color: #00d4ff;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 4px rgba(0, 212, 255, 0.1);
}
input::placeholder {
color: #8892b0;
opacity: 0.6;
}
button {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #00d4ff 0%, #0099cc 100%);
border: none;
border-radius: 12px;
color: white;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 212, 255, 0.4);
}
button:active {
transform: translateY(0);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
#message {
margin-top: 20px;
padding: 14px;
border-radius: 12px;
display: none;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.success {
background: rgba(0, 255, 100, 0.15);
border: 1px solid rgba(0, 255, 100, 0.3);
color: #00ff64;
}
.error {
background: rgba(255, 50, 50, 0.15);
border: 1px solid rgba(255, 50, 50, 0.3);
color: #ff5050;
}
.info {
background: rgba(0, 212, 255, 0.15);
border: 1px solid rgba(0, 212, 255, 0.3);
color: #00d4ff;
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.6s linear infinite;
margin-left: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.footer {
text-align: center;
margin-top: 24px;
color: #8892b0;
font-size: 14px;
}
.footer a {
color: #00d4ff;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<h1>ZernMC</h1>
<p>Активация проходки</p>
</div>
<form id="activateForm">
<div class="form-group">
<label for="username">Ваш никнейм</label>
<input
type="text"
id="username"
name="username"
placeholder="Введите ваш ник в игре"
required
minlength="3"
maxlength="16"
pattern="[a-zA-Z0-9_]+"
autocomplete="off"
>
</div>
<div class="form-group">
<label for="passCode">Код проходки</label>
<input
type="text"
id="passCode"
name="passCode"
placeholder="XXXX-XXXX-XXXX"
required
minlength="8"
maxlength="20"
autocomplete="off"
style="text-transform: uppercase;"
>
</div>
<button type="submit" id="submitBtn">
Активировать проходку
</button>
</form>
<div id="message"></div>
<div class="footer">
<p>Нет проходки? <a href="https://zernmc.ru" target="_blank">Получить на сайте</a></p>
</div>
</div>
<script>
const form = document.getElementById('activateForm');
const submitBtn = document.getElementById('submitBtn');
const messageDiv = document.getElementById('message');
const usernameInput = document.getElementById('username');
const passCodeInput = document.getElementById('passCode');
passCodeInput.addEventListener('input', (e) => {
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
});
function showMessage(text, type) {
messageDiv.textContent = text;
messageDiv.className = '';
messageDiv.classList.add(type);
messageDiv.style.display = 'block';
if (type === 'success' || type === 'info') {
setTimeout(() => {
messageDiv.style.display = 'none';
}, 5000);
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const username = usernameInput.value.trim();
const passCode = passCodeInput.value.trim().toUpperCase();
const password = prompt("Введите пароль от вашего аккаунта " + username + ":");
if (!password) {
showMessage('Необходимо ввести пароль', 'error');
return;
}
if (!username || !passCode) {
showMessage('Пожалуйста, заполните все поля', 'error');
return;
}
submitBtn.disabled = true;
const originalText = submitBtn.textContent;
submitBtn.innerHTML = 'Активация... <span class="spinner"></span>';
messageDiv.style.display = 'none';
try {
// Логинимся с существующим аккаунтом
const loginResponse = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, password: password })
});
if (!loginResponse.ok) {
const errorData = await loginResponse.json();
throw new Error(errorData.detail || 'Неверный логин или пароль');
}
const tokenData = await loginResponse.json();
// Активируем проходку
const activateResponse = await fetch('/auth/pass/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenData.access_token}`
},
body: JSON.stringify({ pass_code: passCode })
});
if (!activateResponse.ok) {
const errorData = await activateResponse.json();
throw new Error(errorData.detail || 'Ошибка активации проходки');
}
const activateData = await activateResponse.json();
showMessage('' + activateData.message, 'success');
form.reset();
} catch (error) {
console.error('Error:', error);
showMessage('' + error.message, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
</script>
</body>
</html>
"""
# Create app with lifespan # Create app with lifespan
app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan) app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
# Add logging middleware # Add Logging middleware
app.add_middleware(LoggingMiddleware) app.add_middleware(LoggingMiddleware)
# Register routers
app.include_router(auth_router)
app.include_router(admin_router)
# Monkey patch to catch invalid HTTP requests # Monkey patch to catch invalid HTTP requests
original_data_received = HttpToolsProtocol.data_received original_data_received = HttpToolsProtocol.data_received
@@ -95,9 +432,7 @@ def patched_data_received(self, data):
return original_data_received(self, data) return original_data_received(self, data)
except Exception as e: except Exception as e:
client = self.transport.get_extra_info('peername') client = self.transport.get_extra_info('peername')
logger = logging.getLogger(__name__)
# Показываем первые 200 байт запроса в HEX для диагностики
hex_preview = data[:100].hex() if len(data) > 0 else "empty" hex_preview = data[:100].hex() if len(data) > 0 else "empty"
logger.error(f"Invalid HTTP request from {client}") logger.error(f"Invalid HTTP request from {client}")
@@ -107,16 +442,14 @@ def patched_data_received(self, data):
try: try:
raw_data = data[:500].decode('utf-8', errors='replace') raw_data = data[:500].decode('utf-8', errors='replace')
logger.error(f"Raw request data: {repr(raw_data)}") logger.error(f"Raw request data: {repr(raw_data)}")
except: except Exception:
pass pass
# Не перевыбрасываем исключение, а возвращаем 400 ответ
# Это важно! Иначе клиент не получит ответ
try: try:
response = b"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: 21\r\n\r\nInvalid HTTP request" response = b"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: 21\r\n\r\nInvalid HTTP request"
self.transport.write(response) self.transport.write(response)
self.transport.close() self.transport.close()
except: except Exception:
pass pass
return return
@@ -128,7 +461,6 @@ HttpToolsProtocol.data_received = patched_data_received
@app.get("/") @app.get("/")
async def root(): async def root():
"""Root endpoint""" """Root endpoint"""
logger = logging.getLogger(__name__)
logger.info("Root endpoint accessed") logger.info("Root endpoint accessed")
return { return {
"status": "ok", "status": "ok",
@@ -144,12 +476,30 @@ async def health():
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()} return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
# ====================== WEB ИНТЕРФЕЙС ДЛЯ АКТИВАЦИИ ПРОХОДКИ ======================
@app.get("/activate-pass")
async def activate_pass_page():
"""Веб-интерфейс для активации проходки"""
return Response(
content=ACTIVATE_PASS_HTML,
media_type="text/html"
)
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ====================== # ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
@app.get("/packs") @app.get("/packs")
async def list_packs(): async def list_packs(current_user: dict = Depends(get_current_user)):
"""List all available packs""" """List all available packs - требует проходку для просмотра"""
logger = logging.getLogger(__name__)
# Проверяем, есть ли право на просмотр сборок
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
raise HTTPException(
status_code=403,
detail="Для просмотра сборок требуется активная проходка"
)
packs = [] packs = []
for pack_dir in PACKS_DIR.iterdir(): for pack_dir in PACKS_DIR.iterdir():
@@ -159,7 +509,6 @@ async def list_packs():
try: try:
with open(meta_path, 'r', encoding='utf-8') as f: with open(meta_path, 'r', encoding='utf-8') as f:
meta = json.load(f) meta = json.load(f)
# Исправлено: конвертируем updated_at в строку если это datetime
updated_at = meta.get("updated_at") updated_at = meta.get("updated_at")
if updated_at and isinstance(updated_at, datetime): if updated_at and isinstance(updated_at, datetime):
updated_at = updated_at.isoformat() updated_at = updated_at.isoformat()
@@ -171,7 +520,8 @@ async def list_packs():
"updated_at": updated_at, "updated_at": updated_at,
"minecraft_version": meta.get("minecraft_version", "unknown"), "minecraft_version": meta.get("minecraft_version", "unknown"),
"loader_type": meta.get("loader_type", "vanilla"), "loader_type": meta.get("loader_type", "vanilla"),
"loader_version": meta.get("loader_version") "loader_version": meta.get("loader_version"),
"asset_index": meta.get("asset_index")
}) })
except Exception as e: except Exception as e:
logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}") logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}")
@@ -189,11 +539,22 @@ async def list_packs():
@app.post("/pack/{pack_name}/diff") @app.post("/pack/{pack_name}/diff")
async def get_pack_diff(pack_name: str, request: Request): async def get_pack_diff(
""" pack_name: str,
Client sends: { "mods/jei.jar": "sha256_hash", ... } request: Request,
current_user: dict = Depends(get_current_user) # Добавляем зависимость
):
"""Client sends: { "mods/jei.jar": "sha256_hash", ... }
Server returns diff information Server returns diff information
""" ТРЕБУЕТ ПРОХОДКУ ДЛЯ СКАЧИВАНИЯ"""
# Проверяем наличие проходки
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
raise HTTPException(
status_code=403,
detail="Для скачивания сборок требуется активная проходка. Обратитесь к администратору."
)
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
# Читаем тело запроса # Читаем тело запроса
@@ -491,17 +852,12 @@ async def get_launcher_full_info():
# Эти эндпоинты позволяют клиентам с сетевыми проблемами # Эти эндпоинты позволяют клиентам с сетевыми проблемами
# скачивать файлы через сервер Zern # скачивать файлы через сервер Zern
# Создаем HTTP клиент для прокси # HTTP клиент для прокси — создаётся в lifespan, закрывается при shutdown
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) proxy_client: httpx.AsyncClient | None = None
# Кэш для часто запрашиваемых данных (5 минут) # Кэш для часто запрашиваемых данных (5 минут)
from cachetools import TTLCache
proxy_cache = TTLCache(maxsize=50, ttl=300) proxy_cache = TTLCache(maxsize=50, ttl=300)
# Список заблокированных/проблемных хостов (можно обновлять)
BLOCKED_HOSTS = []
@app.get("/proxy/fabric/versions/loader") @app.get("/proxy/fabric/versions/loader")
async def proxy_fabric_versions(request: Request): async def proxy_fabric_versions(request: Request):
"""Прокси для Fabric Meta API - список версий загрузчика""" """Прокси для Fabric Meta API - список версий загрузчика"""
@@ -548,7 +904,6 @@ async def proxy_fabric_installer_latest(request: Request):
xml = response.text xml = response.text
# Парсим последнюю версию из XML # Парсим последнюю версию из XML
import re
match = re.search(r'<latest>([^<]+)</latest>', xml) match = re.search(r'<latest>([^<]+)</latest>', xml)
if match: if match:
version = match.group(1) version = match.group(1)
@@ -725,6 +1080,58 @@ async def proxy_forge_maven(path: str, request: Request):
raise HTTPException(502, f"Bad Gateway: {str(e)}") raise HTTPException(502, f"Bad Gateway: {str(e)}")
@app.get("/proxy/neoforge/versions")
async def proxy_neoforge_versions(request: Request):
"""Прокси для списка версий NeoForge"""
client_ip = request.client.host if request.client else "unknown"
logger.info(f"Proxy request: NeoForge versions from {client_ip}")
url = "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml"
try:
response = await proxy_client.get(url)
response.raise_for_status()
return Response(
content=response.content,
media_type="application/xml",
headers={"X-Proxied-By": "ZernMC"}
)
except httpx.HTTPError as e:
logger.error(f"Proxy error for NeoForge versions: {e}")
raise HTTPException(502, f"Bad Gateway: {str(e)}")
@app.get("/proxy/neoforge/maven/{path:path}")
async def proxy_neoforge_maven(path: str, request: Request):
"""Прокси для NeoForge Maven файлов"""
client_ip = request.client.host if request.client else "unknown"
logger.info(f"Proxy request: NeoForge Maven {path} from {client_ip}")
full_url = f"https://maven.neoforged.net/{path}"
try:
response = await proxy_client.get(full_url)
response.raise_for_status()
content_type = "application/octet-stream"
if path.endswith(".jar"):
content_type = "application/java-archive"
elif path.endswith(".pom"):
content_type = "application/xml"
return Response(
content=response.content,
media_type=content_type,
headers={"X-Proxied-By": "ZernMC"}
)
except httpx.HTTPError as e:
logger.error(f"Proxy error for NeoForge Maven {path}: {e}")
raise HTTPException(502, f"Bad Gateway: {str(e)}")
@app.get("/proxy/download") @app.get("/proxy/download")
async def proxy_download(request: Request): async def proxy_download(request: Request):
"""Универсальный прокси для скачивания файлов""" """Универсальный прокси для скачивания файлов"""
@@ -742,11 +1149,11 @@ async def proxy_download(request: Request):
"launchermeta.mojang.com", "launchermeta.mojang.com",
"resources.download.minecraft.net", "resources.download.minecraft.net",
"maven.minecraftforge.net", "maven.minecraftforge.net",
"files.minecraftforge.net" "files.minecraftforge.net",
"maven.neoforged.net"
] ]
# Проверяем, что URL ведет на разрешенный домен # Проверяем, что URL ведет на разрешенный домен
from urllib.parse import urlparse
parsed = urlparse(url) parsed = urlparse(url)
domain = parsed.netloc.lower() domain = parsed.netloc.lower()
@@ -819,7 +1226,8 @@ async def proxy_status():
"piston-meta.mojang.com", "piston-meta.mojang.com",
"launchermeta.mojang.com", "launchermeta.mojang.com",
"resources.download.minecraft.net", "resources.download.minecraft.net",
"maven.minecraftforge.net" "maven.minecraftforge.net",
"maven.neoforged.net"
], ],
"note": "Use this proxy if you have network issues connecting to Fabric/Mojang/Forge" "note": "Use this proxy if you have network issues connecting to Fabric/Mojang/Forge"
} }
@@ -834,12 +1242,6 @@ async def global_exception_handler(request: Request, exc: Exception):
) )
# Cleanup on shutdown
@app.on_event("shutdown")
async def shutdown_proxy():
await proxy_client.close()
# ====================== ЗАПУСК ====================== # ====================== ЗАПУСК ======================
if __name__ == "__main__": if __name__ == "__main__":
+1 -13
View File
@@ -28,16 +28,4 @@ class PackMeta(BaseModel):
minecraft_version: str minecraft_version: str
loader_type: str loader_type: str
loader_version: Optional[str] = None loader_version: Optional[str] = None
asset_index: Optional[str] = None
class MinecraftVersion(BaseModel):
version: str
type: str # release, snapshot, old_alpha, old_beta
release_time: datetime
url: Optional[str] = None
class ModLoader(BaseModel):
type: str
version: str
minecraft_version: str
installer_url: Optional[str] = None
libraries: List[str] = Field(default_factory=list)
+4 -1
View File
@@ -109,6 +109,7 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
minecraft_version = "1.20.4" minecraft_version = "1.20.4"
loader_type = "vanilla" loader_type = "vanilla"
loader_version = None loader_version = None
asset_index = None
pack_config_path = pack_path / "instance.json" pack_config_path = pack_path / "instance.json"
if pack_config_path.exists(): if pack_config_path.exists():
@@ -119,6 +120,7 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
minecraft_version = config.get("minecraftVersion", minecraft_version) minecraft_version = config.get("minecraftVersion", minecraft_version)
loader_type = config.get("loaderType", loader_type) loader_type = config.get("loaderType", loader_type)
loader_version = config.get("loaderVersion") loader_version = config.get("loaderVersion")
asset_index = config.get("assetIndex")
except Exception as e: except Exception as e:
logger.warning(f"Failed to load instance.json for {pack_name}: {e}") logger.warning(f"Failed to load instance.json for {pack_name}: {e}")
@@ -131,7 +133,8 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
ignored_dirs=ignored_dirs, ignored_dirs=ignored_dirs,
minecraft_version=minecraft_version, minecraft_version=minecraft_version,
loader_type=loader_type, loader_type=loader_type,
loader_version=loader_version loader_version=loader_version,
asset_index=asset_index
) )
# Save to disk (синхронно) # Save to disk (синхронно)
-77
View File
@@ -1,77 +0,0 @@
import json
from pathlib import Path
from datetime import datetime
import structlog
logger = structlog.get_logger(__name__)
PASSES_FILE = Path("data/passes.json")
def load_passes():
if not PASSES_FILE.exists():
PASSES_FILE.parent.mkdir(exist_ok=True)
default = {"passes": {}}
PASSES_FILE.write_text(json.dumps(default, indent=2, ensure_ascii=False))
return default
try:
return json.loads(PASSES_FILE.read_text(encoding="utf-8"))
except:
return {"passes": {}}
def save_passes(data):
PASSES_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
def activate_pass(pass_code: str, username: str, user_id: int) -> dict:
data = load_passes()
pass_code = pass_code.upper().strip()
if pass_code not in data["passes"]:
return {"success": False, "error": "Проходка не найдена"}
p = data["passes"][pass_code]
if not p.get("is_active", True):
return {"success": False, "error": "Проходка деактивирована"}
if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
return {"success": False, "error": "Проходка истекла"}
if p.get("owner") is not None:
if p.get("owner") != username:
return {"success": False, "error": "Проходка уже активирована другим пользователем"}
return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"}
# Активация
now = datetime.now().timestamp()
p["owner"] = username
p["activated_at"] = now
p["uses"] = p.get("uses", 0) + 1
save_passes(data)
logger.info("Pass activated", pass_code=pass_code, username=username)
return {"success": True, "message": "Проходка успешно активирована!"}
def has_active_pass(username: str) -> bool:
data = load_passes()
for p in data["passes"].values():
if p.get("owner") == username:
if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
continue
if p.get("is_active", True):
return True
return False
def get_user_passes(username: str) -> list:
data = load_passes()
result = []
now = datetime.now().timestamp()
for p in data["passes"].values():
if p.get("owner") == username:
result.append({
"code": p["code"],
"activated_at": p.get("activated_at"),
"expires_at": p.get("expires_at"),
"is_active": p.get("is_active", True) and (not p.get("expires_at") or p.get("expires_at") > now)
})
return result
+114
View File
@@ -0,0 +1,114 @@
# roles.py
from enum import IntEnum
from typing import Dict, Set
class UserRole(IntEnum):
USER = 0 # Обычный пользователь
PASS_HOLDER = 1 # Пользователь с проходкой
MODERATOR = 2 # Модератор
ELDER = 3 # Elder Moderator
CREATOR = 4 # Создатель
# Aliases for backwards compatibility with admin_router.py
ROLE_USER = UserRole.USER
ROLE_PASS_HOLDER = UserRole.PASS_HOLDER
ROLE_MODERATOR = UserRole.MODERATOR
ROLE_ELDER = UserRole.ELDER
ROLE_CREATOR = UserRole.CREATOR
__all__ = [
"UserRole", "ROLE_USER", "ROLE_PASS_HOLDER", "ROLE_MODERATOR",
"ROLE_ELDER", "ROLE_CREATOR", "ROLE_NAMES", "Permissions",
"ROLE_PERMISSIONS", "has_permission", "require_permission",
]
ROLE_NAMES: Dict[int, str] = {
UserRole.USER: "Игрок",
UserRole.PASS_HOLDER: "Игрок [Проходка]",
UserRole.MODERATOR: "Модератор",
UserRole.ELDER: "Elder Moderator",
UserRole.CREATOR: "Создатель"
}
# Права доступа
class Permissions:
# Базовые права
DOWNLOAD_PACK = "download_pack" # Скачивание сборок
VIEW_PACKS = "view_packs" # Просмотр списка сборок
# Права модератора
REQUEST_PASS = "request_pass" # Запрос проходки для игрока
VIEW_USER_LIST = "view_user_list" # Просмотр списка пользователей
# Права Elder Moderator
APPROVE_PASS = "approve_pass" # Одобрение проходок
REJECT_PASS = "reject_pass" # Отклонение проходок
VIEW_PASS_REQUESTS = "view_pass_requests" # Просмотр запросов проходок
MANAGE_MODERATORS = "manage_moderators" # Управление модераторами
# Права создателя
DIRECT_PASS = "direct_pass" # Прямая выдача проходки
MANAGE_ELDER = "manage_elder" # Управление Elder
MANAGE_SERVER = "manage_server" # Управление сервером
VIEW_AUDIT_LOG = "view_audit_log" # Просмотр логов
# Маппинг ролей на права
ROLE_PERMISSIONS: Dict[int, Set[str]] = {
UserRole.USER: {
# Обычный игрок НЕ может даже смотреть сборки!
# Только авторизоваться и смотреть свой профиль
},
UserRole.PASS_HOLDER: {
Permissions.VIEW_PACKS, # Может видеть список сборок
Permissions.DOWNLOAD_PACK, # Может скачивать сборки
},
UserRole.MODERATOR: {
Permissions.VIEW_PACKS,
Permissions.DOWNLOAD_PACK,
Permissions.REQUEST_PASS, # Может запрашивать проходки для игроков
Permissions.VIEW_USER_LIST, # Может видеть список пользователей
},
UserRole.ELDER: {
Permissions.VIEW_PACKS,
Permissions.DOWNLOAD_PACK,
Permissions.REQUEST_PASS,
Permissions.VIEW_USER_LIST,
Permissions.APPROVE_PASS, # Может одобрять проходки
Permissions.REJECT_PASS, # Может отклонять проходки
Permissions.VIEW_PASS_REQUESTS,
Permissions.MANAGE_MODERATORS, # Может управлять модераторами
},
UserRole.CREATOR: {
Permissions.VIEW_PACKS,
Permissions.DOWNLOAD_PACK,
Permissions.REQUEST_PASS,
Permissions.VIEW_USER_LIST,
Permissions.APPROVE_PASS,
Permissions.REJECT_PASS,
Permissions.VIEW_PASS_REQUESTS,
Permissions.MANAGE_MODERATORS,
Permissions.DIRECT_PASS, # Прямая выдача проходки
Permissions.MANAGE_ELDER, # Управление Elder
Permissions.MANAGE_SERVER, # Управление сервером
Permissions.VIEW_AUDIT_LOG, # Просмотр логов
}
}
def has_permission(role: int, permission: str) -> bool:
"""Проверка наличия права у роли"""
return permission in ROLE_PERMISSIONS.get(role, set())
def require_permission(permission: str):
"""Декоратор для проверки права"""
from functools import wraps
from fastapi import HTTPException, Depends
from auth import get_current_user
def decorator(func):
@wraps(func)
async def wrapper(*args, current_user: dict = Depends(get_current_user), **kwargs):
if not has_permission(current_user["role"], permission):
raise HTTPException(403, f"Недостаточно прав. Требуется право: {permission}")
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
+125
View File
@@ -0,0 +1,125 @@
import os
import sys
import pytest
import tempfile
import shutil
from pathlib import Path
def auth_headers(token):
"""Create Authorization headers."""
return {"Authorization": f"Bearer {token}"}
@pytest.fixture(scope="session")
def test_db_dir():
"""Temporary directory for test databases."""
d = tempfile.mkdtemp(prefix="zern_test_")
yield Path(d)
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture(scope="session")
def test_app(test_db_dir):
"""Create FastAPI app with test database."""
# Patch auth module paths BEFORE importing anything
import auth
auth.AUTH_DB = test_db_dir / "auth.db"
auth.SECRET_KEY = test_db_dir / ".secret_key"
auth._rate_limit_cache.clear()
# Initialize test database
auth.init_db()
from main import app
return app
@pytest.fixture
def client(test_app):
"""TestClient instance."""
from fastapi.testclient import TestClient
return TestClient(test_app)
@pytest.fixture
def registered_user(client):
"""Register a unique test user."""
import secrets
username = f"testuser_{secrets.token_hex(4)}"
password = "TestPassword123"
resp = client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 200, f"Registration failed: {resp.text}"
return {"username": username, "password": password}
@pytest.fixture
def logged_in_user(client, registered_user):
"""Login and return tokens."""
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200, f"Login failed: {resp.text}"
data = resp.json()
return {
"username": registered_user["username"],
"password": registered_user["password"],
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
"expires_in": data["expires_in"],
"uuid": data["uuid"],
"role": data["role"],
}
@pytest.fixture
def logged_in_user_with_pass(client, registered_user):
"""Login user and give them role 1 (pass holder)."""
# Promote to pass holder
import sqlite3
import auth
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],))
conn.commit()
conn.close()
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200, f"Login failed: {resp.text}"
data = resp.json()
return {
"username": registered_user["username"],
"password": registered_user["password"],
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
"expires_in": data["expires_in"],
"uuid": data["uuid"],
"role": data["role"],
}
@pytest.fixture
def admin_user(client):
"""Create and login a creator/admin user."""
import secrets
import sqlite3
import auth
username = f"admin_{secrets.token_hex(4)}"
password = "AdminPassword123"
resp = client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 200
# Promote to creator
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute("UPDATE users SET role = 4 WHERE username = ?", (username,))
conn.commit()
conn.close()
resp = client.post("/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200
data = resp.json()
return {
"username": username,
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
}
+128
View File
@@ -0,0 +1,128 @@
"""Tests for admin endpoints."""
import pytest
import sqlite3
import time
from tests.conftest import auth_headers
from auth import AUTH_DB
class TestAdminMe:
"""Test /admin/me endpoint."""
def test_admin_me_success(self, client, logged_in_user):
resp = client.get("/admin/me", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "id" in data
assert "username" in data
assert "uuid" in data
assert "role" in data
assert "role_name" in data
assert "has_pass" in data
assert "permissions" in data
def test_admin_me_no_auth(self, client):
resp = client.get("/admin/me")
assert resp.status_code in (401, 403) # Either is acceptable
class TestAdminUsersList:
"""Test /admin/users endpoint."""
def test_admin_users_list(self, client, admin_user):
resp = client.get("/admin/users", headers=auth_headers(admin_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "users" in data
assert isinstance(data["users"], list)
assert len(data["users"]) >= 1 # At least the admin user
def test_admin_users_list_no_admin(self, client, logged_in_user):
"""Regular user should not access admin endpoints."""
resp = client.get("/admin/users", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code in (401, 403)
def test_admin_users_list_no_auth(self, client):
resp = client.get("/admin/users")
assert resp.status_code in (401, 403)
class TestAdminBan:
"""Test ban functionality via admin endpoints."""
def test_ban_user(self, client, logged_in_user, admin_user):
"""Admin bans a user."""
# Get user ID first
import sqlite3
from auth import AUTH_DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT id FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row is not None
resp = client.post("/admin/user/ban", json={
"user_id": row[0],
"days": 1,
"reason": "Test ban"
}, headers=auth_headers(admin_user["access_token"]))
assert resp.status_code == 200
# Verify ban in DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT banned_until FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row is not None
assert row[0] is not None
assert row[0] > time.time()
def test_ban_nonexistent_user(self, client, admin_user):
resp = client.post("/admin/user/ban", json={
"user_id": 99999,
"days": 1,
"reason": "Test ban"
}, headers=auth_headers(admin_user["access_token"]))
assert resp.status_code == 404
class TestAdminRole:
"""Test role change functionality."""
def test_change_role(self, client, logged_in_user, admin_user):
# Get user ID
import sqlite3
from auth import AUTH_DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT id FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row is not None
resp = client.put(f"/admin/users/{row[0]}/role", json={
"user_id": row[0],
"role": 2 # MODERATOR
}, headers=auth_headers(admin_user["access_token"]))
assert resp.status_code == 200
# Verify in DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT role FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row[0] == 2
def test_change_role_invalid(self, client, logged_in_user, admin_user):
import sqlite3
from auth import AUTH_DB
conn = sqlite3.connect(str(AUTH_DB))
row = conn.execute("SELECT id FROM users WHERE username = ?",
(logged_in_user["username"],)).fetchone()
conn.close()
assert row is not None
resp = client.put(f"/admin/users/{row[0]}/role", json={
"user_id": row[0],
"role": 99
}, headers=auth_headers(admin_user["access_token"]))
assert resp.status_code in (400, 422)
+187
View File
@@ -0,0 +1,187 @@
"""Tests for auth flow: register, login, refresh, validate, logout."""
import pytest
from tests.conftest import auth_headers
class TestRegister:
"""Test /auth/register endpoint."""
def test_register_success(self, client):
resp = client.post("/auth/register", json={
"username": "newuser",
"password": "SecurePass123"
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert "uuid" in data
assert "expires_in" in data
assert "role" in data
assert data["username"] == "newuser"
def test_register_duplicate(self, client, registered_user):
resp = client.post("/auth/register", json={
"username": registered_user["username"],
"password": "AnotherPass123"
})
assert resp.status_code == 409
def test_register_short_username(self, client):
resp = client.post("/auth/register", json={
"username": "ab",
"password": "SecurePass123"
})
assert resp.status_code == 422
def test_register_short_password(self, client):
resp = client.post("/auth/register", json={
"username": "validuser",
"password": "short"
})
assert resp.status_code == 422
def test_register_invalid_username(self, client):
resp = client.post("/auth/register", json={
"username": "user name!",
"password": "SecurePass123"
})
assert resp.status_code == 422
class TestLogin:
"""Test /auth/login endpoint."""
def test_login_success(self, client, registered_user):
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert "uuid" in data
assert data["username"] == registered_user["username"]
def test_login_wrong_password(self, client, registered_user):
resp = client.post("/auth/login", json={
"username": registered_user["username"],
"password": "WrongPassword"
})
assert resp.status_code == 401
def test_login_nonexistent_user(self, client):
resp = client.post("/auth/login", json={
"username": "ghost",
"password": "SomePass123"
})
assert resp.status_code == 401
def test_login_returns_role(self, client, registered_user):
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200
data = resp.json()
assert "role" in data
assert data["role"] == 0 # ROLE_USER
class TestRefresh:
"""Test /auth/refresh endpoint."""
def test_refresh_success(self, client, logged_in_user):
resp = client.post("/auth/refresh", json={
"refresh_token": logged_in_user["refresh_token"]
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["username"] == logged_in_user["username"]
def test_refresh_invalid_token(self, client):
resp = client.post("/auth/refresh", json={
"refresh_token": "invalid.token.here"
})
assert resp.status_code == 401
def test_refresh_reuses_token_fails(self, client, logged_in_user):
"""Refresh token should be invalidated after use."""
# First refresh
resp = client.post("/auth/refresh", json={
"refresh_token": logged_in_user["refresh_token"]
})
assert resp.status_code == 200
new_token = resp.json()["refresh_token"]
# Try with old token
resp = client.post("/auth/refresh", json={
"refresh_token": logged_in_user["refresh_token"]
})
assert resp.status_code == 401
class TestValidate:
"""Test /auth/validate endpoint."""
def test_validate_valid_token(self, client, logged_in_user):
resp = client.post("/auth/validate", json={
"username": logged_in_user["username"],
"uuid": logged_in_user["uuid"]
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is True
assert data["username"] == logged_in_user["username"]
assert "uuid" in data
def test_validate_invalid_token(self, client):
resp = client.post("/auth/validate", json={
"username": "test",
"uuid": "test"
}, headers=auth_headers("invalid.token.here"))
assert resp.status_code == 401 # Invalid token returns 401
def test_validate_no_token(self, client):
resp = client.post("/auth/validate", json={
"username": "test",
"uuid": "test"
})
assert resp.status_code in (401, 403)
def test_validate_banned_user(self, client, logged_in_user, admin_user):
"""Banned user should get valid=false."""
# Ban the user
import sqlite3
from auth import AUTH_DB
conn = sqlite3.connect(str(AUTH_DB))
import time
conn.execute("UPDATE users SET banned_until = ? WHERE username = ?",
(time.time() + 3600, logged_in_user["username"]))
conn.commit()
conn.close()
resp = client.post("/auth/validate", json={
"username": logged_in_user["username"],
"uuid": logged_in_user["uuid"]
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert "banned" in data["reason"].lower()
class TestLogout:
"""Test /auth/logout endpoint."""
def test_logout_success(self, client, logged_in_user):
resp = client.post("/auth/logout", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
# Refresh should fail after logout
resp = client.post("/auth/refresh", json={
"refresh_token": logged_in_user["refresh_token"]
})
assert resp.status_code == 401
def test_logout_invalid_token(self, client):
resp = client.post("/auth/logout", headers=auth_headers("invalid.token.here"))
assert resp.status_code == 401
assert resp.status_code == 401
+391
View File
@@ -0,0 +1,391 @@
"""Tests for client-facing endpoints — verifying server responses match what the Java launcher expects.
This tests the full client-server contract:
- AuthManager.java: login, register, refresh, logout, /admin/me for UserInfo
- PackDownloader.java: /packs, /pack/{name}, /pack/{name}/diff, /pack/{name}/file/{path}
- ZHttpClient.java: /launcher/version, /proxy/*
- ServerPack.java: pack list fields
- PackManifest inner class: manifest fields
- DiffResponse inner class: diff fields
- FileInfo inner class: file info fields
"""
import asyncio
import hashlib
import json
import secrets
import sqlite3
import time
from pathlib import Path
import pytest
from tests.conftest import auth_headers
import auth
from pack_manager import scan_pack, PACKS_DIR
def scan_pack_sync(pack_name):
"""Run scan_pack synchronously."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(scan_pack(pack_name))
finally:
loop.close()
@pytest.fixture
def pack_fixture(tmp_path, logged_in_user):
"""Create a test pack with a mod file and scan it."""
pack_name = f"testpack_{secrets.token_hex(4)}"
pack_dir = PACKS_DIR / pack_name
pack_dir.mkdir(parents=True, exist_ok=True)
mod_dir = pack_dir / "mods"
mod_dir.mkdir()
mod_content = b"fake mod content for testing"
mod_file = mod_dir / "test-mod.jar"
mod_file.write_bytes(mod_content)
# Scan to generate .meta
meta = scan_pack_sync(pack_name)
yield {
"name": pack_name,
"dir": pack_dir,
"mod_content": mod_content,
"mod_path": "mods/test-mod.jar",
"mod_hash": hashlib.sha256(mod_content).hexdigest(),
"meta": meta,
}
# Cleanup
import shutil
shutil.rmtree(pack_dir, ignore_errors=True)
meta_path = Path("data") / f"{pack_name}.meta"
if meta_path.exists():
meta_path.unlink()
class TestAuthFlowClient:
"""Test auth flow exactly as Java AuthManager.java does it."""
def test_full_auth_lifecycle(self, client):
"""Register → Login → Refresh → Logout, matching Java client behavior."""
username = f"lifecycle_{secrets.token_hex(4)}"
password = "LifeCyclePass123"
# 1. Register (AuthManager.authRequest)
resp = client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 200
reg = resp.json()
assert reg["access_token"]
assert reg["refresh_token"]
assert isinstance(reg["expires_in"], int)
assert reg["uuid"]
assert reg["username"] == username
assert isinstance(reg["role"], int)
# 2. Login (AuthManager.authRequest)
resp = client.post("/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200
login = resp.json()
assert login["access_token"]
assert login["refresh_token"]
assert isinstance(login["expires_in"], int)
assert login["uuid"]
assert login["username"] == username
assert isinstance(login["role"], int)
access_token = login["access_token"]
refresh_token = login["refresh_token"]
# 3. Refresh (AuthManager.tryRefresh)
resp = client.post("/auth/refresh", json={"refresh_token": refresh_token})
assert resp.status_code == 200
refresh = resp.json()
assert refresh["access_token"]
assert refresh["refresh_token"]
assert isinstance(refresh["expires_in"], int)
assert refresh["username"] == username
assert refresh["uuid"]
assert isinstance(refresh["role"], int)
# 4. Validate token (used by Minecraft server auth)
resp = client.post("/auth/validate", json={
"username": username,
"uuid": refresh["uuid"]
}, headers=auth_headers(refresh["access_token"]))
assert resp.status_code == 200
validate = resp.json()
assert validate["valid"] is True
assert validate["username"] == username
# 5. /admin/me (AuthManager.fetchUserInfo)
resp = client.get("/admin/me", headers=auth_headers(refresh["access_token"]))
assert resp.status_code == 200
me = resp.json()
assert isinstance(me["id"], int)
assert me["username"] == username
assert me["uuid"]
assert isinstance(me["role"], int)
assert isinstance(me["role_name"], str)
assert isinstance(me["has_pass"], bool)
assert isinstance(me["permissions"], list)
# 6. Logout
resp = client.post("/auth/logout", headers=auth_headers(refresh["access_token"]))
assert resp.status_code == 200
# 7. Refresh should fail after logout
resp = client.post("/auth/refresh", json={"refresh_token": refresh["refresh_token"]})
assert resp.status_code == 401
class TestPacksClientContract:
"""Test /packs response matches Java ServerPack.java parsing."""
def test_packs_empty_list(self, client, logged_in_user_with_pass):
"""Client parses {"packs": [...]} — empty list should work."""
resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "packs" in data
assert isinstance(data["packs"], list)
def test_packs_with_pack(self, client, logged_in_user_with_pass, pack_fixture):
"""Full pack with all fields that ServerPack.java expects."""
resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert len(data["packs"]) >= 1
# Find our pack
pack = next((p for p in data["packs"] if p["name"] == pack_fixture["name"]), None)
assert pack is not None
# ServerPack.java fields
assert "name" in pack
assert "version" in pack
assert isinstance(pack["version"], int)
assert "minecraft_version" in pack
assert isinstance(pack["minecraft_version"], str)
assert "loader_type" in pack
assert isinstance(pack["loader_type"], str)
assert "loader_version" in pack
assert pack["loader_version"] is None or isinstance(pack["loader_version"], str)
assert "files_count" in pack
assert isinstance(pack["files_count"], int)
assert "updated_at" in pack
class TestPackManifestClientContract:
"""Test /pack/{name} response matches Java PackDownloader.PackManifest."""
def test_pack_manifest_not_found(self, client, logged_in_user):
resp = client.get("/pack/nonexistent", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 404
def test_pack_manifest_fields(self, client, logged_in_user_with_pass, pack_fixture):
"""All fields that PackManifest.java expects."""
pack_name = pack_fixture["name"]
resp = client.get(f"/pack/{pack_name}", headers=auth_headers(logged_in_user_with_pass["access_token"]))
assert resp.status_code == 200
data = resp.json()
# PackManifest.java fields
assert "pack_name" in data
assert "version" in data
assert isinstance(data["version"], int)
assert "minecraft_version" in data
assert isinstance(data["minecraft_version"], str)
assert "loader_type" in data
assert "loader_version" in data or data.get("loader_version") is None
assert "asset_index" in data or data.get("asset_index") is None
assert "files" in data
assert isinstance(data["files"], dict)
# Files in manifest have path, hash, size, added_at, modified_at
# URL is only added in the diff response
for path, entry in data["files"].items():
assert "hash" in entry
assert isinstance(entry["hash"], str)
assert "size" in entry
assert isinstance(entry["size"], int)
def test_pack_manifest_no_auth_is_public(self, client, pack_fixture):
"""/pack/{name} is public — doesn't require auth."""
resp = client.get(f"/pack/{pack_fixture['name']}")
assert resp.status_code == 200
class TestPackDiffClientContract:
"""Test /pack/{name}/diff response matches Java PackDownloader.DiffResponse."""
def test_diff_all_files_new(self, client, logged_in_user_with_pass, pack_fixture):
"""Client sends empty file list — all files should be in to_download."""
pack_name = pack_fixture["name"]
resp = client.post(
f"/pack/{pack_name}/diff",
json={},
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
data = resp.json()
# DiffResponse.java fields
assert "version" in data
assert isinstance(data["version"], int)
assert "to_download" in data
assert isinstance(data["to_download"], list)
assert "to_delete" in data
assert isinstance(data["to_delete"], list)
assert "to_update" in data
assert isinstance(data["to_update"], list)
# All files should be new
assert len(data["to_download"]) >= 1
for file_info in data["to_download"]:
# FileInfo.java fields
assert "path" in file_info
assert "url" in file_info
assert "size" in file_info
assert isinstance(file_info["size"], int)
assert "hash" in file_info
assert isinstance(file_info["hash"], str)
def test_diff_no_changes(self, client, logged_in_user_with_pass, pack_fixture):
"""Client sends correct hashes — no downloads needed."""
pack_name = pack_fixture["name"]
local_files = {pack_fixture["mod_path"]: pack_fixture["mod_hash"]}
resp = client.post(
f"/pack/{pack_name}/diff",
json=local_files,
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
data = resp.json()
assert len(data["to_download"]) == 0
assert len(data["to_update"]) == 0
assert len(data["to_delete"]) == 0
def test_diff_with_outdated_file(self, client, logged_in_user_with_pass, pack_fixture):
"""Client sends wrong hash — file should be in to_download + to_update."""
pack_name = pack_fixture["name"]
local_files = {pack_fixture["mod_path"]: "old_wrong_hash"}
resp = client.post(
f"/pack/{pack_name}/diff",
json=local_files,
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
data = resp.json()
assert len(data["to_download"]) == 1
assert len(data["to_update"]) == 1
assert data["to_update"][0] == pack_fixture["mod_path"]
def test_diff_extra_local_file(self, client, logged_in_user_with_pass, pack_fixture):
"""Client has extra file — should be in to_delete."""
pack_name = pack_fixture["name"]
local_files = {"mods/removed-mod.jar": "some_hash"}
resp = client.post(
f"/pack/{pack_name}/diff",
json=local_files,
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
data = resp.json()
assert "mods/removed-mod.jar" in data["to_delete"]
class TestPackFileDownload:
"""Test /pack/{name}/file/{path} — file serving."""
def test_pack_file_download(self, client, logged_in_user_with_pass, pack_fixture):
"""Download a file from a pack."""
pack_name = pack_fixture["name"]
resp = client.get(
f"/pack/{pack_name}/file/{pack_fixture['mod_path']}",
headers=auth_headers(logged_in_user_with_pass["access_token"])
)
assert resp.status_code == 200
assert resp.content == pack_fixture["mod_content"]
def test_pack_file_not_found(self, client, logged_in_user):
resp = client.get(
"/pack/nonexistent/file/mods/mod.jar",
headers=auth_headers(logged_in_user["access_token"])
)
assert resp.status_code == 404
def test_pack_file_path_traversal_blocked(self, client, logged_in_user):
"""Path traversal should be blocked."""
resp = client.get(
"/pack/somepack/file/../../../etc/passwd",
headers=auth_headers(logged_in_user["access_token"])
)
assert resp.status_code in (403, 404)
class TestPackPermissions:
"""Test that packs require proper permissions (pass/role)."""
def test_packs_no_auth(self, client):
resp = client.get("/packs")
assert resp.status_code in (401, 403)
def test_pack_diff_no_auth(self, client):
resp = client.post("/pack/test/diff", json={})
assert resp.status_code in (401, 403)
def test_packs_user_without_pass(self, client, logged_in_user):
"""User without pass should get 403 on /packs."""
resp = client.get("/packs", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 403
def test_pack_diff_user_without_pass(self, client, logged_in_user):
"""User without pass should get 403 on /pack/{name}/diff."""
resp = client.post(
"/pack/test/diff",
json={},
headers=auth_headers(logged_in_user["access_token"])
)
assert resp.status_code == 403
class TestLauncherVersion:
"""Test /launcher/version endpoint."""
def test_launcher_version(self, client):
"""Should return version info."""
resp = client.get("/launcher/version")
assert resp.status_code == 200
data = resp.json()
assert "version" in data or "latest" in data
class TestProxyEndpoints:
"""Test /proxy/* endpoints that ZHttpClient uses."""
def test_proxy_status(self, client):
"""Proxy status works without proxy_client."""
resp = client.get("/proxy/status")
# May be 200 or 500 if proxy_client is None
assert resp.status_code in (200, 500)
def test_proxy_fabric_versions(self, client):
"""ZHttpClient uses this for Fabric loader versions."""
resp = client.get("/proxy/fabric/versions/loader")
# Works if proxy_client is set up, fails otherwise
assert resp.status_code in (200, 500, 502, 504)
+142
View File
@@ -0,0 +1,142 @@
"""Tests verifying server responses match client (AuthManager.java) expectations."""
import pytest
from tests.conftest import auth_headers
class TestAuthResponseContract:
"""Verify /auth/register and /auth/login response fields match AuthSession.java."""
def test_register_has_all_session_fields(self, client):
"""Client expects: access_token, refresh_token, expires_in, uuid, username, role."""
resp = client.post("/auth/register", json={
"username": "contracttest",
"password": "ContractPass123"
})
assert resp.status_code == 200
data = resp.json()
# AuthManager.AuthSession fields
assert "access_token" in data, "Client needs access_token"
assert "refresh_token" in data, "Client needs refresh_token"
assert "expires_in" in data, "Client needs expires_in (int)"
assert "uuid" in data, "Client needs uuid"
assert "username" in data, "Client needs username"
assert "role" in data, "Client needs role (int)"
# Type checks
assert isinstance(data["access_token"], str)
assert isinstance(data["refresh_token"], str)
assert isinstance(data["expires_in"], int)
assert isinstance(data["uuid"], str)
assert isinstance(data["role"], int)
assert data["expires_in"] > 0 # Must be positive seconds
def test_login_has_all_session_fields(self, client, registered_user):
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert "expires_in" in data
assert "uuid" in data
assert "username" in data
assert "role" in data
assert isinstance(data["expires_in"], int)
assert isinstance(data["role"], int)
class TestValidateResponseContract:
"""Verify /auth/validate response matches client expectations."""
def test_validate_valid_response_fields(self, client, logged_in_user):
"""Client checks: valid (bool), username, uuid, role."""
resp = client.post("/auth/validate", json={
"username": logged_in_user["username"],
"uuid": logged_in_user["uuid"]
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "valid" in data
assert isinstance(data["valid"], bool)
assert data["valid"] is True
assert "username" in data
assert "uuid" in data
def test_validate_invalid_response_fields(self, client):
resp = client.post("/auth/validate", json={
"username": "test",
"uuid": "test"
}, headers=auth_headers("bad.token"))
assert resp.status_code == 401 # Invalid token returns 401
class TestAdminMeResponseContract:
"""Verify /admin/me response matches UserInfo.java expectations."""
def test_admin_me_has_all_userinfo_fields(self, client, logged_in_user):
"""
Client UserInfo.java expects:
id (int), username, uuid, role (int), role_name, has_pass (bool), permissions (list)
"""
resp = client.get("/admin/me", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "id" in data, "UserInfo needs id"
assert "username" in data
assert "uuid" in data
assert "role" in data, "UserInfo needs role"
assert "role_name" in data, "UserInfo needs role_name"
assert "has_pass" in data, "UserInfo needs has_pass"
assert "permissions" in data, "UserInfo needs permissions"
# Type checks
assert isinstance(data["id"], int)
assert isinstance(data["role"], int)
assert isinstance(data["has_pass"], bool)
assert isinstance(data["permissions"], list)
assert isinstance(data["role_name"], str)
class TestErrorResponseContract:
"""Verify error responses match client extractError() parsing."""
def test_error_has_detail_field(self, client):
"""Client parses json.detail (string or array with msg)."""
resp = client.post("/auth/login", json={
"username": "nonexistent",
"password": "wrong"
})
# FastAPI returns 422 for validation errors, auth errors return 401
assert resp.status_code in (401, 422)
data = resp.json()
assert "detail" in data, "Client expects 'detail' field in errors"
assert isinstance(data["detail"], (str, list))
def test_validation_error_has_detail_array(self, client):
"""FastAPI 422 returns detail as array of {loc, msg, type}."""
resp = client.post("/auth/register", json={
"username": "ab",
"password": "x"
})
assert resp.status_code == 422
data = resp.json()
assert "detail" in data
assert isinstance(data["detail"], list)
assert "msg" in data["detail"][0]
class TestPackResponseContract:
"""Verify /packs response matches client expectations."""
def test_packs_response_structure(self, client, logged_in_user):
resp = client.get("/packs", headers=auth_headers(logged_in_user["access_token"]))
# May return 200 or 401/403 depending on auth setup
assert resp.status_code in (200, 401, 403)
if resp.status_code == 200:
data = resp.json()
assert "packs" in data
assert isinstance(data["packs"], list)
+81
View File
@@ -0,0 +1,81 @@
"""Tests for pass (проходка) management."""
import pytest
import sqlite3
import time
import secrets
from tests.conftest import auth_headers
import auth
class TestPassActivate:
"""Test /auth/pass/activate endpoint."""
def test_activate_valid_pass(self, client, logged_in_user):
"""Create a pass code and activate it."""
pass_code = f"TEST-PASS-{secrets.token_hex(4)}"
# Create a pass in DB (use auth.AUTH_DB which is patched by conftest)
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute(
"INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)",
(pass_code,)
)
conn.commit()
conn.close()
resp = client.post("/auth/pass/activate", json={
"pass_code": pass_code
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert "message" in data
assert "success" in data and data["success"] is True
# Verify pass is now used
conn = sqlite3.connect(str(auth.AUTH_DB))
row = conn.execute("SELECT uses, activated_by FROM passes WHERE code = ?", (pass_code,)).fetchone()
conn.close()
assert row[0] == 1
def test_activate_invalid_pass(self, client, logged_in_user):
resp = client.post("/auth/pass/activate", json={
"pass_code": "NONEXISTENT-CODE"
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 404
def test_activate_already_used_pass(self, client, logged_in_user):
"""Create an already-used pass."""
pass_code = f"USED-PASS-{secrets.token_hex(4)}"
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute(
"INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 1)",
(pass_code,)
)
conn.commit()
conn.close()
resp = client.post("/auth/pass/activate", json={
"pass_code": pass_code
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code in (400, 404) # 400 for max uses reached, 404 for not found
def test_activate_pass_empty_code(self, client, logged_in_user):
resp = client.post("/auth/pass/activate", json={
"pass_code": ""
}, headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 422
class TestPassMyStatus:
"""Test /auth/pass/my endpoint."""
def test_my_pass_no_pass(self, client, logged_in_user):
# Route may not exist
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code in (200, 404)
if resp.status_code == 200:
data = resp.json()
assert "has_active" in data
assert data["has_active"] is False
+12
View File
@@ -0,0 +1,12 @@
"""Tests for proxy endpoints."""
import pytest
class TestProxyEndpoints:
"""Test /proxy/* endpoints."""
def test_proxy_status(self, client):
"""Proxy status should be accessible."""
resp = client.get("/proxy/status")
# May return 200 or 500 if proxy_client is None (no lifespan in tests)
assert resp.status_code in (200, 500)
+70
View File
@@ -0,0 +1,70 @@
"""Tests for rate limiting (TTLCache-based)."""
import pytest
from auth import check_rate_limit, record_login_attempt, MAX_LOGIN_ATTEMPTS, LOGIN_BLOCK_MINUTES
class TestRateLimit:
"""Test rate limiting functions."""
def test_no_attempts_allowed(self):
"""Fresh IP should be allowed."""
allowed, wait = check_rate_limit("fresh-ip")
assert allowed is True
assert wait is None
def test_single_attempt_allowed(self):
"""One failed attempt should still be allowed."""
ip = "single-attempt-ip"
record_login_attempt(ip, False)
allowed, wait = check_rate_limit(ip)
assert allowed is True
def test_max_attempts_blocks(self):
"""MAX_LOGIN_ATTEMPTS failed attempts should block."""
ip = "blocked-ip"
for _ in range(MAX_LOGIN_ATTEMPTS):
record_login_attempt(ip, False)
allowed, wait = check_rate_limit(ip)
assert allowed is False
assert wait is not None
assert wait > 0
# Wait should be approximately LOGIN_BLOCK_MINUTES * 60
assert wait <= LOGIN_BLOCK_MINUTES * 60
def test_success_resets_attempts(self):
"""Successful login should reset rate limit."""
ip = "reset-ip"
for _ in range(MAX_LOGIN_ATTEMPTS - 1):
record_login_attempt(ip, False)
# One success should reset
record_login_attempt(ip, True)
allowed, wait = check_rate_limit(ip)
assert allowed is True
assert wait is None
def test_success_then_fail_starts_fresh(self):
"""After success reset, failing again should start from 1."""
ip = "fresh-start-ip"
record_login_attempt(ip, False)
record_login_attempt(ip, True)
record_login_attempt(ip, False)
allowed, wait = check_rate_limit(ip)
assert allowed is True # Only 1 attempt after reset
def test_separate_ips_independent(self):
"""Rate limit should be per-IP."""
ip1 = "ip-one"
ip2 = "ip-two"
for _ in range(MAX_LOGIN_ATTEMPTS):
record_login_attempt(ip1, False)
allowed1, _ = check_rate_limit(ip1)
allowed2, _ = check_rate_limit(ip2)
assert allowed1 is False
assert allowed2 is True