Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b493b3278b | |||
| ec7ef01760 | |||
| 166dbf8935 | |||
| 7014c4a455 | |||
| d956bce921 | |||
| a765d064c4 | |||
| 1d5241075b | |||
| 2c670b1103 | |||
| 389280f7f1 | |||
| ee1e4fa8d2 | |||
| e17b1d073a | |||
| a8f3ca5049 | |||
| 59480217aa | |||
| 4697b16ab4 | |||
| 099df80cc6 | |||
| 74cd5ffdf3 | |||
| 01668dd3bf | |||
| b2dbbac6ca | |||
| e32a057684 | |||
| d4dc35aac3 | |||
| 1e7231af57 | |||
| fd6e292d6e | |||
| 1e876ffe28 | |||
| 2d515108f0 | |||
| 13c9f67f6e | |||
| 659265c2f0 | |||
| d8f189558a | |||
| 6f56012e3a | |||
| 3a0570e7da | |||
| 985abf7440 | |||
| ec551ab2e3 | |||
| e5948b5337 | |||
| 5a826c8511 | |||
| ce12854e1b | |||
| e566703332 | |||
| aaa19df5e4 | |||
| 0ee8077787 | |||
| fba944b4b8 | |||
| d39b40053a | |||
| 1199ca9e21 | |||
| 50080d890f | |||
| f6fbb66cdc | |||
| d7a928cce4 | |||
| 3bd3d1d0e8 | |||
| df9fa7b867 | |||
| 81fbe028e8 | |||
| 513c07666b | |||
| 04f97c3c80 | |||
| f40cf7afed | |||
| 0cef411125 | |||
| 523f659269 | |||
| 04620d76c4 | |||
| d0b4e187c8 | |||
| f2d3de82f7 | |||
| b4431702dc | |||
| cd2cf44d9c | |||
| 8939e24e69 | |||
| c0310ed573 | |||
| c96b502ad4 | |||
| bfcffdd88d | |||
| 331fc9a863 | |||
| e347c042d5 | |||
| bb564e6e9b | |||
| 6f53002266 | |||
| 9688509df5 | |||
| efc4b086d1 | |||
| 2cdc438411 | |||
| b60e414d37 | |||
| 10ec8625b9 | |||
| f24cc078c5 | |||
| adde40d921 | |||
| 6bf6c1634a | |||
| 98462ba4a3 | |||
| 11ec84fe24 | |||
| 8b56652a73 | |||
| d7a6eb760e | |||
| c6dd215e9b |
@@ -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.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
+11
@@ -2,6 +2,8 @@ logs/
|
||||
__pycache__/
|
||||
./.venv/
|
||||
launcher/target
|
||||
bootstrap/target
|
||||
src/target
|
||||
server/builds
|
||||
server/packs
|
||||
server/data
|
||||
@@ -9,3 +11,12 @@ jre
|
||||
.vscode
|
||||
dependency-reduced-pom.xml
|
||||
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
|
||||
telegram-bot/
|
||||
builds/
|
||||
server/news/
|
||||
data/
|
||||
packs/
|
||||
.__pycache__
|
||||
.pytest_cache
|
||||
.venv
|
||||
resources
|
||||
@@ -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,21 +17,34 @@
|
||||
|
||||
## Чего пока нет в лаунчере
|
||||
|
||||
- Графического интерфейса (GUI) — только TUI
|
||||
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
|
||||
- Поддержки **Forge** (в разработке)
|
||||
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
|
||||
- Раздела новостей об обновлениях Minecraft и лаунчера
|
||||
- Выбора готовых пресетов оптимизации JVM
|
||||
- Кастомных модов (UI, спавнеры, DPI, карточки)
|
||||
- Сайта для лаунчера и сервера
|
||||
- Трекинга наигранного времени
|
||||
|
||||
## Что планируется доработать в ближайшее время
|
||||
|
||||
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
|
||||
- **GUI мод** — привести в единый стиль с главным меню
|
||||
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
|
||||
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
|
||||
- **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
|
||||
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
|
||||
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
|
||||
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
|
||||
- Генерацию команды запуска Minecraft
|
||||
- Стабильную работу автообновления лаунчера
|
||||
- Полноценные настройки
|
||||
- Стабильность и производительность серверной части
|
||||
- **Улучшенный античит / ClientChecker** — проверка подлинности клиента при подключении к серверу, без нужного клиента не пустит; поставляется вместе с лаунчером, не общедоступный. Хеш-проверка всех папок и файлов сборки при каждом запуске — при несовпадении одного хеша все моды переустанавливаются. Игнорируются только: логи, ресурспаки, шейдеры, сейвы, личные файлы. Защита от подмены libs и лоадеров (Meteor и аналоги), проверка целостности модов через хеши. В перспективе — защита от Mixin-атак (перехват логики других модов), сбор отчёта о текущей сборке и сравнение с базовой
|
||||
- **Баг-фиксы сервера:** подключить `admin_router` в `main.py`, исправить импорты ролей (`ROLE_USER` и др. не существуют в `roles.py`), добавить эндпоинт `/auth/pass/activate`, убрать дубли импортов (`TTLCache`, `Response`)
|
||||
- Улучшение прокси-режима
|
||||
- Стабильность и производительность серверной части
|
||||
- Общую надёжность загрузки файлов с сервера
|
||||
- аккаунты, проходки
|
||||
|
||||
## Важная информация перед использованием
|
||||
|
||||
@@ -39,12 +52,10 @@
|
||||
|
||||
Лаунчер использует **текстовый интерфейс (TUI)**:
|
||||
|
||||
- `W` / `S` (или `Ц` / `Ы`) — перемещение по меню
|
||||
- `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
|
||||
- `ENTER` — выбор пункта
|
||||
- `ESC` или пункт «Назад» — возврат назад
|
||||
|
||||
> **Важно:** Стрелки ↑/↓ могут вызывать баги и краши. Используйте только `W`/`S`.
|
||||
|
||||
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
|
||||
|
||||
### Расположение сборок
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Maven
|
||||
target/
|
||||
pom.xml.tag
|
||||
pom.xml.releaseBackup
|
||||
pom.xml.versionsBackup
|
||||
dependency-reduced-pom.xml
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build outputs
|
||||
server/builds/
|
||||
server/logs/
|
||||
|
||||
# Colab
|
||||
colab/
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>me.sashegdev</groupId>
|
||||
<artifactId>ZernMCLauncher</artifactId>
|
||||
<version>1.0.9</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>zernmc-bootstrap</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>ZernMC Bootstrap</name>
|
||||
<description>Bootstrap module - handles updates and Java launching</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- Minimal dependencies for Bootstrap -->
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputFile>../../server/builds/zernmc-bootstrap.jar</outputFile>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,715 @@
|
||||
package me.sashegdev.zernmc.launcher;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.swing.*;
|
||||
import javax.swing.plaf.basic.BasicProgressBarUI;
|
||||
import java.awt.*;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.jar.Manifest;
|
||||
|
||||
public class Bootstrap {
|
||||
private static final String JAR_NAME = "zernmclauncher.jar";
|
||||
private static final String BASE_URL = "http://87.120.187.36:1582";
|
||||
private static List<String> MIRRORS = new ArrayList<>();
|
||||
private static volatile boolean jfxChildExiting = false;
|
||||
|
||||
private static Path baseDir;
|
||||
private static Path binDir;
|
||||
private static Path logDir;
|
||||
private static Path javafxPath;
|
||||
private static boolean isCliMode;
|
||||
private static boolean isJfxMode;
|
||||
private static BootstrapUI ui;
|
||||
|
||||
private static Path getLauncherJar() {
|
||||
return binDir.resolve(JAR_NAME);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
baseDir = Paths.get("").toAbsolutePath();
|
||||
binDir = baseDir.resolve("bin");
|
||||
Files.createDirectories(binDir);
|
||||
logDir = baseDir.resolve("logs");
|
||||
Files.createDirectories(logDir);
|
||||
javafxPath = baseDir.resolve("lib").resolve("javafx");
|
||||
|
||||
log("=== ZernMC Launcher ===");
|
||||
|
||||
List<String> argList = Arrays.asList(args);
|
||||
isCliMode = argList.contains("--cli");
|
||||
isJfxMode = !isCliMode;
|
||||
|
||||
log("Mode: " + (isCliMode ? "CLI" : "JFX"));
|
||||
|
||||
if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
|
||||
ui = new BootstrapUI();
|
||||
SwingUtilities.invokeLater(() -> ui.show());
|
||||
}
|
||||
|
||||
String currentVersion = readCurrentVersion();
|
||||
String serverVersion = getServerVersion();
|
||||
|
||||
log("Local version: " + currentVersion);
|
||||
log("Server version: " + serverVersion);
|
||||
setVersionInfo(currentVersion, serverVersion);
|
||||
|
||||
loadMirrors();
|
||||
log("Primary server: " + BASE_URL);
|
||||
log("Mirrors available: " + (MIRRORS.size() + 1));
|
||||
|
||||
if (isNewer(serverVersion, currentVersion)) {
|
||||
log("Update available!");
|
||||
downloadUpdate(serverVersion);
|
||||
} else {
|
||||
log("Version is up to date");
|
||||
}
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
log("Shutdown signal received...");
|
||||
}));
|
||||
|
||||
if (ui != null) {
|
||||
setTitle("Launching...");
|
||||
setProgress(100, 100);
|
||||
log("Starting launcher...");
|
||||
try { Thread.sleep(400); } catch (InterruptedException ignored) {}
|
||||
ui.close();
|
||||
}
|
||||
|
||||
launchMain(args);
|
||||
}
|
||||
|
||||
private static void launchMain(String[] args) throws Exception {
|
||||
log("Loading launcher: " + getLauncherJar());
|
||||
|
||||
if (isCliMode) {
|
||||
launchInProcess(args);
|
||||
} else {
|
||||
launchInNewProcess(args);
|
||||
}
|
||||
}
|
||||
|
||||
private static void launchInProcess(String[] args) throws Exception {
|
||||
ClassLoader parent = Bootstrap.class.getClassLoader();
|
||||
URL[] urls = { getLauncherJar().toUri().toURL() };
|
||||
URLClassLoader cl = new URLClassLoader(urls, parent);
|
||||
|
||||
Thread.currentThread().setContextClassLoader(cl);
|
||||
|
||||
try {
|
||||
Class<?> mainClass = cl.loadClass("me.sashegdev.zernmc.launcher.Main");
|
||||
java.lang.reflect.Method mainMethod = mainClass.getMethod("main", String[].class);
|
||||
mainMethod.invoke(null, (Object) args);
|
||||
} finally {
|
||||
cl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void launchInNewProcess(String[] args) throws Exception {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
|
||||
Path javaBin = findJava(false);
|
||||
// On Windows, use javaw.exe to hide console in JFX mode
|
||||
if (os.contains("windows")) {
|
||||
Path javawPath = javaBin.resolveSibling("javaw.exe");
|
||||
if (Files.exists(javawPath)) {
|
||||
javaBin = javawPath;
|
||||
}
|
||||
}
|
||||
Path javafxPath = baseDir.resolve("lib").resolve("javafx");
|
||||
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(javaBin.toAbsolutePath().toString());
|
||||
cmd.add("-Dfile.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stdout.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stderr.encoding=UTF-8");
|
||||
cmd.add("-Dlauncher.server=" + BASE_URL);
|
||||
|
||||
if (Files.exists(javafxPath)) {
|
||||
cmd.add("--module-path");
|
||||
cmd.add(javafxPath.toAbsolutePath().toString());
|
||||
cmd.add("--add-modules");
|
||||
cmd.add("javafx.controls,javafx.web");
|
||||
}
|
||||
|
||||
cmd.add("-jar");
|
||||
cmd.add(getLauncherJar().toAbsolutePath().toString());
|
||||
cmd.add("--jfx");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.directory(baseDir.toFile());
|
||||
pb.inheritIO();
|
||||
|
||||
log("Starting process: " + String.join(" ", cmd));
|
||||
|
||||
Process p = pb.start();
|
||||
int code = p.waitFor();
|
||||
|
||||
log("JFX process exited with code: " + code);
|
||||
System.exit(code);
|
||||
}
|
||||
|
||||
private static Path findJava(boolean preferConsole) {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
String javaExe = "java.exe";
|
||||
|
||||
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
|
||||
if (!Files.exists(javaBin)) {
|
||||
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
|
||||
}
|
||||
if (!Files.exists(javaBin)) {
|
||||
try {
|
||||
Process p = new ProcessBuilder("where", javaExe).redirectErrorStream(true).start();
|
||||
if (p.waitFor() == 0) {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
||||
String path = br.readLine();
|
||||
if (path != null) javaBin = Paths.get(path.trim());
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
if (!Files.exists(javaBin)) {
|
||||
throw new RuntimeException("Java not found");
|
||||
}
|
||||
return javaBin;
|
||||
}
|
||||
|
||||
private static void log(String msg) {
|
||||
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
|
||||
System.out.println(entry);
|
||||
try {
|
||||
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (Exception ignored) {}
|
||||
if (ui != null) ui.setStatus(msg);
|
||||
}
|
||||
|
||||
private static void setProgress(int current, int total) {
|
||||
if (ui != null) ui.setProgress(current, total);
|
||||
}
|
||||
|
||||
private static void setVersionInfo(String localVer, String serverVer) {
|
||||
if (ui != null) ui.setVersionInfo(localVer, serverVer);
|
||||
}
|
||||
|
||||
private static void setTitle(String text) {
|
||||
if (ui != null) ui.setTitleText(text);
|
||||
}
|
||||
|
||||
private static String readCurrentVersion() {
|
||||
Path jar = getLauncherJar();
|
||||
if (Files.exists(jar)) {
|
||||
try (JarFile jarFile = new JarFile(jar.toFile())) {
|
||||
Manifest manifest = jarFile.getManifest();
|
||||
if (manifest != null) {
|
||||
String v = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
|
||||
if (v != null && !v.isBlank()) return v;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Error reading manifest: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
private static String getServerVersion() {
|
||||
try {
|
||||
URL url = new URL(BASE_URL + "/launcher/version");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
if (conn.getResponseCode() == 200) {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
String response = sb.toString();
|
||||
|
||||
int versionStart = response.indexOf("\"version\":\"");
|
||||
if (versionStart >= 0) {
|
||||
int afterVersion = versionStart + 11;
|
||||
int versionEnd = response.indexOf("\"", afterVersion);
|
||||
if (versionEnd > afterVersion) {
|
||||
return response.substring(afterVersion, versionEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Error fetching version: " + e.getMessage());
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static boolean isNewer(String server, String current) {
|
||||
try {
|
||||
String[] sa = server.split("\\.");
|
||||
String[] ca = current.split("\\.");
|
||||
for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
|
||||
int sv = Integer.parseInt(sa[i]);
|
||||
int cv = Integer.parseInt(ca[i]);
|
||||
if (sv > cv) return true;
|
||||
if (sv < cv) return false;
|
||||
}
|
||||
return sa.length > ca.length;
|
||||
} catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void downloadUpdate(String newVersion) throws Exception {
|
||||
log("Checking for updates...");
|
||||
|
||||
Map<String, FileMeta> serverFiles = fetchServerMeta(newVersion);
|
||||
if (serverFiles.isEmpty()) {
|
||||
log("Failed to get server meta");
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> localFiles = scanLocalFiles();
|
||||
log("Local files: " + localFiles.size());
|
||||
log("Server files: " + serverFiles.size());
|
||||
|
||||
int downloaded = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
|
||||
String selfName = getSelfFileName();
|
||||
|
||||
for (Map.Entry<String, FileMeta> entry : serverFiles.entrySet()) {
|
||||
String filePath = entry.getKey();
|
||||
FileMeta serverMeta = entry.getValue();
|
||||
|
||||
String localHash = localFiles.get(filePath);
|
||||
String serverHash = serverMeta.hash.replace("sha256:", "");
|
||||
|
||||
if (localHash != null && localHash.equals(serverHash)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip self-update (can't overwrite running executable)
|
||||
if (selfName != null && (filePath.equalsIgnoreCase(selfName) || filePath.endsWith("/" + selfName))) {
|
||||
log("Skipping self-update: " + filePath + " (file in use)");
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (localHash != null) {
|
||||
log("Updating: " + filePath);
|
||||
} else {
|
||||
log("Downloading: " + filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
downloadFile(newVersion, filePath, serverMeta.size);
|
||||
downloaded++;
|
||||
} catch (Exception e) {
|
||||
log("Warning: Could not update " + filePath + " - " + e.getMessage());
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
log("Updated files: " + downloaded + ", skipped: " + skipped + ", failed: " + failed);
|
||||
log("Updated to v" + newVersion);
|
||||
}
|
||||
|
||||
private static String getSelfFileName() {
|
||||
try {
|
||||
String classPath = Bootstrap.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
|
||||
if (classPath != null) {
|
||||
String fn = Paths.get(classPath).getFileName().toString();
|
||||
// If running from a JAR, the exe has the same stem
|
||||
if (fn.endsWith(".jar")) {
|
||||
return fn.replace(".jar", ".exe");
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, FileMeta> fetchServerMeta(String version) {
|
||||
Map<String, FileMeta> files = new HashMap<>();
|
||||
try {
|
||||
URL url = new URL(BASE_URL + "/launcher/meta/" + version);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(10000);
|
||||
|
||||
if (conn.getResponseCode() == 200) {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
|
||||
com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(sb.toString()).getAsJsonObject();
|
||||
com.google.gson.JsonArray filesArray = json.getAsJsonArray("files");
|
||||
|
||||
for (com.google.gson.JsonElement fileElem : filesArray) {
|
||||
com.google.gson.JsonObject file = fileElem.getAsJsonObject();
|
||||
files.put(file.get("path").getAsString(), new FileMeta(
|
||||
file.get("hash").getAsString(),
|
||||
file.get("size").getAsLong()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Error fetching meta: " + e.getMessage());
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private static Map<String, String> scanLocalFiles() {
|
||||
Map<String, String> files = new HashMap<>();
|
||||
try {
|
||||
Files.walk(baseDir)
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(p -> !p.toString().contains(".git"))
|
||||
.forEach(path -> {
|
||||
try {
|
||||
String relativePath = baseDir.relativize(path).toString().replace("\\", "/");
|
||||
String hash = calculateFileHash(path);
|
||||
files.put(relativePath, hash);
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
} catch (Exception ignored) {}
|
||||
return files;
|
||||
}
|
||||
|
||||
private static String calculateFileHash(Path path) throws Exception {
|
||||
try (InputStream is = Files.newInputStream(path)) {
|
||||
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||
byte[] buf = new byte[8192];
|
||||
int len;
|
||||
while ((len = is.read(buf)) > 0) {
|
||||
digest.update(buf, 0, len);
|
||||
}
|
||||
byte[] hash = digest.digest();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hash) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception {
|
||||
List<String> servers = new ArrayList<>();
|
||||
if (isServerReachable(BASE_URL)) servers.add(BASE_URL);
|
||||
servers.addAll(MIRRORS);
|
||||
java.util.Collections.shuffle(servers);
|
||||
|
||||
Exception lastError = null;
|
||||
for (String server : servers) {
|
||||
try {
|
||||
downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
|
||||
downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
|
||||
}
|
||||
|
||||
private static void downloadFileFromServer(String urlStr, long expectedSize, String fileName) throws Exception {
|
||||
URL url = new URL(urlStr);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(60000);
|
||||
|
||||
if (conn.getResponseCode() != 200) {
|
||||
throw new IOException("HTTP " + conn.getResponseCode());
|
||||
}
|
||||
|
||||
if (expectedSize <= 0) {
|
||||
expectedSize = conn.getContentLengthLong();
|
||||
}
|
||||
|
||||
Path outPath = baseDir.resolve(fileName);
|
||||
Files.createDirectories(outPath.getParent());
|
||||
|
||||
long downloaded = 0;
|
||||
long lastUpdate = 0;
|
||||
long startTime = System.currentTimeMillis();
|
||||
setTitle("Downloading " + fileName);
|
||||
|
||||
try (InputStream in = conn.getInputStream();
|
||||
OutputStream out = new FileOutputStream(outPath.toFile())) {
|
||||
byte[] buf = new byte[65536];
|
||||
int len;
|
||||
while ((len = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, len);
|
||||
downloaded += len;
|
||||
|
||||
if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) {
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
|
||||
double downloadedMB = downloaded / 1024.0 / 1024.0;
|
||||
double totalMB = expectedSize / 1024.0 / 1024.0;
|
||||
|
||||
String progressStr = String.format("%.1f/%.1f MB (%.1f MB/s)", downloadedMB, totalMB, speed);
|
||||
log(progressStr);
|
||||
setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
|
||||
lastUpdate = downloaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
|
||||
log(String.format("Downloaded %.1f MB (%.1f MB/s) - Done!", downloaded / 1024.0 / 1024.0, speed));
|
||||
setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
|
||||
}
|
||||
|
||||
private static String getProgressBar(long current, long total) {
|
||||
if (total <= 0) return "====";
|
||||
int filled = (int) ((current * 20) / total);
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
for (int i = 0; i < 20; i++) {
|
||||
sb.append(i < filled ? "=" : " ");
|
||||
}
|
||||
sb.append("]");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static class FileMeta {
|
||||
String hash;
|
||||
long size;
|
||||
FileMeta(String hash, long size) {
|
||||
this.hash = hash;
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
private static void loadMirrors() {
|
||||
try {
|
||||
URL url = new URL(BASE_URL + "/launcher/mirrors");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
|
||||
if (conn.getResponseCode() == 200) {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
|
||||
com.google.gson.JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject();
|
||||
com.google.gson.JsonArray mirrorsArray = json.getAsJsonArray("mirrors");
|
||||
|
||||
for (com.google.gson.JsonElement elem : mirrorsArray) {
|
||||
com.google.gson.JsonObject mirror = elem.getAsJsonObject();
|
||||
String mirrorUrl = mirror.get("url").getAsString();
|
||||
if (!MIRRORS.contains(mirrorUrl)) {
|
||||
MIRRORS.add(mirrorUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Mirrors unavailable: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isServerReachable(String serverUrl) {
|
||||
try {
|
||||
URL url = new URL(serverUrl + "/launcher/version");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(2000);
|
||||
conn.setReadTimeout(2000);
|
||||
return conn.getResponseCode() == 200;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== SWING UI ======================
|
||||
|
||||
private static class BootstrapUI {
|
||||
private final JFrame frame;
|
||||
private final JLabel statusLabel;
|
||||
private final JProgressBar progressBar;
|
||||
private final JLabel titleLabel;
|
||||
private final JLabel versionLabel;
|
||||
private final JLabel speedLabel;
|
||||
private final Color bgColor = new Color(0x0c, 0x0c, 0x12);
|
||||
private final Color surfaceColor = new Color(0x16, 0x16, 0x1f);
|
||||
private final Color accentColor = new Color(0xe9, 0x45, 0x60);
|
||||
private final Color textColor = new Color(0xee, 0xee, 0xf0);
|
||||
private final Color mutedColor = new Color(0x88, 0x88, 0x9a);
|
||||
|
||||
BootstrapUI() {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
frame = new JFrame("ZernMC Launcher");
|
||||
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||
frame.setSize(480, 280);
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.setResizable(false);
|
||||
frame.setBackground(bgColor);
|
||||
frame.setUndecorated(true);
|
||||
|
||||
JPanel root = new JPanel(new BorderLayout());
|
||||
root.setBackground(bgColor);
|
||||
root.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(new Color(0x2a, 0x2a, 0x3a), 1),
|
||||
BorderFactory.createEmptyBorder(20, 24, 20, 24)
|
||||
));
|
||||
|
||||
// Title bar
|
||||
JPanel titleBar = new JPanel(new BorderLayout());
|
||||
titleBar.setOpaque(false);
|
||||
|
||||
JLabel brandLabel = new JLabel("ZernMC Launcher");
|
||||
brandLabel.setFont(new Font("Segoe UI", Font.BOLD, 18));
|
||||
brandLabel.setForeground(textColor);
|
||||
|
||||
JPanel titleControls = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
|
||||
titleControls.setOpaque(false);
|
||||
JButton closeBtn = createTitleButton("\u2715");
|
||||
closeBtn.addActionListener(e -> System.exit(0));
|
||||
titleControls.add(closeBtn);
|
||||
|
||||
titleBar.add(brandLabel, BorderLayout.WEST);
|
||||
titleBar.add(titleControls, BorderLayout.EAST);
|
||||
root.add(titleBar, BorderLayout.NORTH);
|
||||
|
||||
// Center content
|
||||
JPanel center = new JPanel();
|
||||
center.setOpaque(false);
|
||||
center.setLayout(new BoxLayout(center, BoxLayout.Y_AXIS));
|
||||
center.add(Box.createVerticalStrut(16));
|
||||
|
||||
titleLabel = new JLabel("Initializing...");
|
||||
titleLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
|
||||
titleLabel.setForeground(mutedColor);
|
||||
titleLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
center.add(titleLabel);
|
||||
center.add(Box.createVerticalStrut(8));
|
||||
|
||||
versionLabel = new JLabel(" ");
|
||||
versionLabel.setFont(new Font("Segoe UI", Font.PLAIN, 12));
|
||||
versionLabel.setForeground(mutedColor);
|
||||
versionLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
center.add(versionLabel);
|
||||
center.add(Box.createVerticalStrut(16));
|
||||
|
||||
statusLabel = new JLabel("Starting...");
|
||||
statusLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
|
||||
statusLabel.setForeground(textColor);
|
||||
statusLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
center.add(statusLabel);
|
||||
center.add(Box.createVerticalStrut(12));
|
||||
|
||||
progressBar = new JProgressBar(0, 100);
|
||||
progressBar.setPreferredSize(new Dimension(400, 6));
|
||||
progressBar.setMaximumSize(new Dimension(400, 6));
|
||||
progressBar.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
progressBar.setBackground(new Color(0x2a, 0x2a, 0x3a));
|
||||
progressBar.setForeground(accentColor);
|
||||
progressBar.setBorderPainted(false);
|
||||
progressBar.setValue(0);
|
||||
progressBar.setUI(new BasicProgressBarUI() {
|
||||
protected Color getSelectionBackground() { return accentColor; }
|
||||
protected Color getSelectionForeground() { return accentColor; }
|
||||
});
|
||||
center.add(progressBar);
|
||||
center.add(Box.createVerticalStrut(6));
|
||||
|
||||
speedLabel = new JLabel(" ");
|
||||
speedLabel.setFont(new Font("Segoe UI", Font.PLAIN, 11));
|
||||
speedLabel.setForeground(mutedColor);
|
||||
speedLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
center.add(speedLabel);
|
||||
|
||||
root.add(center, BorderLayout.CENTER);
|
||||
|
||||
// Draggable frame
|
||||
MouseAdapter dragAdapter = new MouseAdapter() {
|
||||
private int x, y;
|
||||
public void mousePressed(MouseEvent e) { x = e.getX(); y = e.getY(); }
|
||||
public void mouseDragged(MouseEvent e) {
|
||||
frame.setLocation(e.getXOnScreen() - x, e.getYOnScreen() - y);
|
||||
}
|
||||
};
|
||||
root.addMouseListener(dragAdapter);
|
||||
root.addMouseMotionListener(dragAdapter);
|
||||
|
||||
frame.setContentPane(root);
|
||||
}
|
||||
|
||||
private JButton createTitleButton(String text) {
|
||||
JButton btn = new JButton(text);
|
||||
btn.setFont(new Font("Segoe UI", Font.PLAIN, 14));
|
||||
btn.setForeground(mutedColor);
|
||||
btn.setBackground(bgColor);
|
||||
btn.setBorderPainted(false);
|
||||
btn.setFocusPainted(false);
|
||||
btn.setContentAreaFilled(false);
|
||||
btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
btn.addMouseListener(new MouseAdapter() {
|
||||
public void mouseEntered(MouseEvent e) { btn.setForeground(accentColor); }
|
||||
public void mouseExited(MouseEvent e) { btn.setForeground(mutedColor); }
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
void show() {
|
||||
frame.setVisible(true);
|
||||
frame.toFront();
|
||||
}
|
||||
|
||||
void close() {
|
||||
frame.dispose();
|
||||
}
|
||||
|
||||
void setStatus(final String text) {
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText(text));
|
||||
}
|
||||
|
||||
void setProgress(final int current, final int total) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
if (total > 0) {
|
||||
int pct = (int) ((long) current * 100 / total);
|
||||
progressBar.setValue(Math.min(pct, 100));
|
||||
speedLabel.setText(String.format("%.1f / %.1f MB",
|
||||
current / 1024.0 / 1024.0, total / 1024.0 / 1024.0));
|
||||
} else {
|
||||
progressBar.setIndeterminate(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setVersionInfo(final String local, final String server) {
|
||||
SwingUtilities.invokeLater(() ->
|
||||
versionLabel.setText("v" + local + " \u2192 v" + server));
|
||||
}
|
||||
|
||||
void setTitleText(final String text) {
|
||||
SwingUtilities.invokeLater(() -> titleLabel.setText(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>me.sashegdev</groupId>
|
||||
<artifactId>ZernMCLauncher</artifactId>
|
||||
<version>1.0.7</version>
|
||||
<version>1.0.8</version>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.3</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
@@ -24,7 +28,7 @@
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<Implementation-Title>ZernMC Launcher</Implementation-Title>
|
||||
<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>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
@@ -45,10 +49,11 @@
|
||||
<goal>launch4j</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outfile>../server/builds/ZernMCLauncher.exe</outfile>
|
||||
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
|
||||
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
||||
<headerType>console</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<jre>
|
||||
<path>jre21</path>
|
||||
<minVersion>21</minVersion>
|
||||
@@ -56,13 +61,13 @@
|
||||
<versionInfo>
|
||||
<fileVersion>${project.version}.0</fileVersion>
|
||||
<txtFileVersion>${project.version}</txtFileVersion>
|
||||
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
|
||||
<fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription>
|
||||
<productVersion>${project.version}.0</productVersion>
|
||||
<txtProductVersion>${project.version}</txtProductVersion>
|
||||
<productName>ZernMC Launcher</productName>
|
||||
<companyName>ZernMC(SashegDev)</companyName>
|
||||
<internalName>ZernMCLauncher</internalName>
|
||||
<originalFilename>ZernMCLauncher.exe</originalFilename>
|
||||
<originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
|
||||
</versionInfo>
|
||||
</configuration>
|
||||
</execution>
|
||||
@@ -80,9 +85,15 @@
|
||||
<configuration>
|
||||
<target>
|
||||
<echo>${project.version}</echo>
|
||||
<delete />
|
||||
<mkdir />
|
||||
<copy>
|
||||
<fileset />
|
||||
<fileset>
|
||||
<include />
|
||||
<include />
|
||||
</fileset>
|
||||
</copy>
|
||||
<move />
|
||||
<zip />
|
||||
</target>
|
||||
</configuration>
|
||||
@@ -91,10 +102,53 @@
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>global</id>
|
||||
<properties>
|
||||
<launcher.title>ZernMC Launcher</launcher.title>
|
||||
<build.profile>global</build.profile>
|
||||
<server.url>http://87.120.187.36:1582</server.url>
|
||||
</properties>
|
||||
</profile>
|
||||
<profile>
|
||||
<id>zernmc</id>
|
||||
<properties>
|
||||
<launcher.title>ZernMC Private Launcher</launcher.title>
|
||||
<build.profile>zernmc</build.profile>
|
||||
<server.url>http://87.120.187.36:1582</server.url>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.1</version>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<artifactId>junit-jupiter-params</artifactId>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<artifactId>junit-jupiter-engine</artifactId>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<properties>
|
||||
<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>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<project.organization.name>ZernMC</project.organization.name>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.inceptionYear>2026</project.inceptionYear>
|
||||
</properties>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>me.sashegdev</groupId>
|
||||
<artifactId>ZernMCLauncher</artifactId>
|
||||
<version>1.0.9</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>zernmclauncher</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>ZernMC Launcher</name>
|
||||
<description>Main launcher module with JFX UI</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- HTTP Client -->
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Console/Terminal -->
|
||||
<dependency>
|
||||
<groupId>org.fusesource.jansi</groupId>
|
||||
<artifactId>jansi</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jline</groupId>
|
||||
<artifactId>jline</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.tongfei</groupId>
|
||||
<artifactId>progressbar</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- IO -->
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JavaFX - Windows -->
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-web</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-graphics</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-base</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-media</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputFile>../../server/builds/zernmclauncher.jar</outputFile>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||
<manifestEntries>
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<Implementation-Title>ZernMC Launcher</Implementation-Title>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Launch4j для создания .exe из bootstrap JAR -->
|
||||
<plugin>
|
||||
<groupId>com.akathist.maven.plugins.launch4j</groupId>
|
||||
<artifactId>launch4j-maven-plugin</artifactId>
|
||||
<version>2.5.0</version>
|
||||
<executions>
|
||||
<!-- GUI версия (основная) - без консоли -->
|
||||
<execution>
|
||||
<id>l4j-gui</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>launch4j</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outfile>../../server/builds/zernmc.exe</outfile>
|
||||
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
|
||||
<headerType>gui</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<jre>
|
||||
<path>lib/jre21</path>
|
||||
<minVersion>21</minVersion>
|
||||
</jre>
|
||||
<versionInfo>
|
||||
<fileVersion>${project.version}.0</fileVersion>
|
||||
<txtFileVersion>${project.version}</txtFileVersion>
|
||||
<fileDescription>ZernMC Launcher</fileDescription>
|
||||
<productVersion>${project.version}.0</productVersion>
|
||||
<txtProductVersion>${project.version}</txtProductVersion>
|
||||
<productName>ZernMC</productName>
|
||||
<companyName>ZernMC</companyName>
|
||||
<internalName>zernmc</internalName>
|
||||
<originalFilename>zernmc.exe</originalFilename>
|
||||
</versionInfo>
|
||||
</configuration>
|
||||
</execution>
|
||||
|
||||
<!-- CLI версия - с консолью -->
|
||||
<execution>
|
||||
<id>l4j-cli</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>launch4j</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outfile>../../server/builds/zernmc-cli.exe</outfile>
|
||||
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
|
||||
<headerType>console</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<jre>
|
||||
<path>lib/jre21</path>
|
||||
<minVersion>21</minVersion>
|
||||
</jre>
|
||||
<versionInfo>
|
||||
<fileVersion>${project.version}.0</fileVersion>
|
||||
<txtFileVersion>${project.version}</txtFileVersion>
|
||||
<fileDescription>ZernMC Launcher CLI</fileDescription>
|
||||
<productVersion>${project.version}.0</productVersion>
|
||||
<txtProductVersion>${project.version}</txtProductVersion>
|
||||
<productName>ZernMC CLI</productName>
|
||||
<companyName>ZernMC</companyName>
|
||||
<internalName>zernmc-cli</internalName>
|
||||
<originalFilename>zernmc-cli.exe</originalFilename>
|
||||
</versionInfo>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Post-build: копирование JRE и создание ZIP -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-antrun-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>run</goal></goals>
|
||||
<configuration>
|
||||
<target>
|
||||
<echo file="../../server/builds/build.version">${project.version}</echo>
|
||||
|
||||
<!-- Удаляем старую папку lib если есть -->
|
||||
<delete dir="../../server/builds/lib"/>
|
||||
|
||||
<!-- Создаем папку lib -->
|
||||
<mkdir dir="../../server/builds/lib"/>
|
||||
|
||||
<!-- Копируем JRE в lib/jre21 -->
|
||||
<copy todir="../../server/builds/lib/jre21" overwrite="true">
|
||||
<fileset dir="${user.home}/launcher/jre/jre21">
|
||||
<include name="*"/>
|
||||
<include name="**/*"/>
|
||||
</fileset>
|
||||
</copy>
|
||||
|
||||
<!-- Копируем JavaFX модули в lib/javafx -->
|
||||
<mkdir dir="../../server/builds/lib/javafx"/>
|
||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
</copy>
|
||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
</copy>
|
||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
</copy>
|
||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
</copy>
|
||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
</copy>
|
||||
|
||||
<!-- Создаем папку bin и копируем JAR -->
|
||||
<mkdir dir="../../server/builds/bin"/>
|
||||
<copy file="../../server/builds/zernmclauncher.jar"
|
||||
tofile="../../server/builds/bin/zernmclauncher.jar" overwrite="true"/>
|
||||
|
||||
<!-- Копируем UI в assets -->
|
||||
<mkdir dir="../../server/builds/assets"/>
|
||||
<copy todir="../../server/builds/assets/ui" overwrite="true">
|
||||
<fileset dir="${project.basedir}/src/resources/ui">
|
||||
<include name="**/*"/>
|
||||
</fileset>
|
||||
</copy>
|
||||
|
||||
<!-- Создаём README -->
|
||||
<echo file="../../server/builds/README.txt">
|
||||
ZernMC Launcher
|
||||
|
||||
Files:
|
||||
- zernmc.exe - Main launcher with GUI (no console window)
|
||||
- zernmc-cli.exe - CLI version for servers/advanced users (with console)
|
||||
|
||||
How to use GUI:
|
||||
Just run zernmc.exe
|
||||
|
||||
How to use CLI:
|
||||
Run from command line: zernmc-cli.exe --cli
|
||||
</echo>
|
||||
|
||||
<!-- Создаём один архив со всем -->
|
||||
<zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
|
||||
basedir="../../server/builds"
|
||||
includes="zernmc.exe,zernmc-cli.exe,bin/**,assets/**,lib/**,README.txt"
|
||||
excludes="build.version,*.jar"/>
|
||||
</target>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,255 @@
|
||||
package me.sashegdev.zernmc.launcher;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class Bootstrap {
|
||||
private static final String VERSION_FILE = "build.version";
|
||||
private static final String JAR_NAME = "ZernMCLauncher.jar";
|
||||
private static final String BASE_URL = "http://87.120.187.36:1582";
|
||||
|
||||
private static Path baseDir;
|
||||
private static Path logDir;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
baseDir = Paths.get("").toAbsolutePath();
|
||||
logDir = baseDir.resolve("logs");
|
||||
Files.createDirectories(logDir);
|
||||
|
||||
log("=== ZernMC Launcher ===");
|
||||
|
||||
List<String> argList = Arrays.asList(args);
|
||||
boolean cliMode = argList.contains("--cli");
|
||||
boolean jfxMode = !cliMode;
|
||||
|
||||
String currentVersion = readCurrentVersion();
|
||||
String serverVersion = getServerVersion();
|
||||
|
||||
log("Local version: " + currentVersion);
|
||||
log("Server version: " + serverVersion);
|
||||
|
||||
if (isNewer(serverVersion, currentVersion)) {
|
||||
log("Update available!");
|
||||
downloadUpdate(serverVersion);
|
||||
} else {
|
||||
log("Version is up to date");
|
||||
}
|
||||
|
||||
if (jfxMode) {
|
||||
launchJFX();
|
||||
} else {
|
||||
launchCLI();
|
||||
}
|
||||
}
|
||||
|
||||
private static void log(String msg) {
|
||||
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
|
||||
System.out.println(entry);
|
||||
try {
|
||||
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
private static String readCurrentVersion() {
|
||||
Path f = baseDir.resolve(VERSION_FILE);
|
||||
try {
|
||||
if (Files.exists(f)) return Files.readString(f).trim();
|
||||
} catch (Exception ignored) {}
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
private static String getServerVersion() {
|
||||
try {
|
||||
URL url = new URL(BASE_URL + "/launcher/version");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
if (conn.getResponseCode() == 200) {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||
String line = br.readLine();
|
||||
if (line != null && line.contains("version")) {
|
||||
return line.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static boolean isNewer(String server, String current) {
|
||||
try {
|
||||
String[] sa = server.split("\\.");
|
||||
String[] ca = current.split("\\.");
|
||||
for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
|
||||
int sv = Integer.parseInt(sa[i]);
|
||||
int cv = Integer.parseInt(ca[i]);
|
||||
if (sv > cv) return true;
|
||||
if (sv < cv) return false;
|
||||
}
|
||||
return sa.length > ca.length;
|
||||
} catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void downloadUpdate(String newVersion) throws Exception {
|
||||
URL url = new URL(BASE_URL + "/launcher/download/jar");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
|
||||
if (conn.getResponseCode() == 200) {
|
||||
Path jarFile = baseDir.resolve(JAR_NAME);
|
||||
Path tmp = jarFile.resolveSibling("zernmc-launcher-new.jar");
|
||||
|
||||
try (InputStream in = conn.getInputStream();
|
||||
OutputStream out = new FileOutputStream(tmp.toFile())) {
|
||||
byte[] buf = new byte[8192];
|
||||
int len;
|
||||
long total = 0;
|
||||
while ((len = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, len);
|
||||
total += len;
|
||||
System.out.print("\rDownloaded: " + (total/1024/1024) + " MB");
|
||||
}
|
||||
}
|
||||
log("Downloaded");
|
||||
|
||||
Path backup = jarFile.resolveSibling(JAR_NAME + ".old");
|
||||
|
||||
if (Files.exists(jarFile)) Files.move(jarFile, backup, StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.move(tmp, jarFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
if (Files.exists(backup)) Files.delete(backup);
|
||||
|
||||
Files.writeString(baseDir.resolve(VERSION_FILE), newVersion);
|
||||
log("Updated to v" + newVersion);
|
||||
} else {
|
||||
throw new IOException("Server returned code: " + conn.getResponseCode());
|
||||
}
|
||||
}
|
||||
|
||||
private static void launchJFX() throws Exception {
|
||||
Path javaBin = findJava();
|
||||
Path jarPath = baseDir.resolve(JAR_NAME);
|
||||
|
||||
log("Starting JFX mode...");
|
||||
log("Java: " + javaBin);
|
||||
log("JAR: " + jarPath);
|
||||
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(javaBin.toAbsolutePath().toString());
|
||||
cmd.add("-Dfile.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stdout.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stderr.encoding=UTF-8");
|
||||
cmd.add("-jar");
|
||||
cmd.add(jarPath.toAbsolutePath().toString());
|
||||
cmd.add("--jfx");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.directory(baseDir.toFile());
|
||||
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
|
||||
}
|
||||
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
|
||||
Thread outputThread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
System.out.println(line);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
outputThread.start();
|
||||
|
||||
int code = p.waitFor();
|
||||
try { outputThread.interrupt(); } catch (Exception ignored) {}
|
||||
log("Exited with code: " + code);
|
||||
System.exit(code);
|
||||
}
|
||||
|
||||
private static void launchCLI() throws Exception {
|
||||
Path javaBin = findJava();
|
||||
Path jarPath = baseDir.resolve(JAR_NAME);
|
||||
|
||||
log("Starting CLI mode...");
|
||||
log("Java: " + javaBin);
|
||||
log("JAR: " + jarPath);
|
||||
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(javaBin.toAbsolutePath().toString());
|
||||
cmd.add("-Dfile.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stdout.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stderr.encoding=UTF-8");
|
||||
cmd.add("-jar");
|
||||
cmd.add(jarPath.toAbsolutePath().toString());
|
||||
cmd.add("--cli");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.directory(baseDir.toFile());
|
||||
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
|
||||
}
|
||||
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
|
||||
Thread outputThread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
System.out.println(line);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
outputThread.start();
|
||||
|
||||
int code = p.waitFor();
|
||||
try { outputThread.interrupt(); } catch (Exception ignored) {}
|
||||
log("Exited with code: " + code);
|
||||
System.exit(code);
|
||||
}
|
||||
|
||||
private static Path findJava() {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
String javaExe = os.contains("windows") ? "java.exe" : "java";
|
||||
|
||||
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
|
||||
|
||||
if (!Files.exists(javaBin)) {
|
||||
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
|
||||
}
|
||||
|
||||
if (!Files.exists(javaBin)) {
|
||||
try {
|
||||
Process p = new ProcessBuilder("which", javaExe).start();
|
||||
if (p.waitFor() == 0) {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
||||
String path = br.readLine();
|
||||
if (path != null) {
|
||||
javaBin = Paths.get(path.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
if (!Files.exists(javaBin)) {
|
||||
throw new RuntimeException("Java not found. Make sure jre21 is present in the launcher folder or Java is installed on the system");
|
||||
}
|
||||
|
||||
return javaBin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package me.sashegdev.zernmc.launcher;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.menu.*;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||
import me.sashegdev.zernmc.launcher.utils.*;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class Main {
|
||||
|
||||
private static final String CURRENT_VERSION = Version.getCurrentVersion();
|
||||
private static final LauncherAPI api = new LauncherAPI();
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
System.setProperty("file.encoding", "UTF-8");
|
||||
System.setProperty("sun.stderr.encoding", "UTF-8");
|
||||
System.setProperty("sun.stdout.encoding", "UTF-8");
|
||||
System.setProperty("java.stdout.encoding", "UTF-8");
|
||||
System.setProperty("java.stderr.encoding", "UTF-8");
|
||||
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
||||
|
||||
LauncherLogger.init();
|
||||
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
try {
|
||||
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
ZAnsi.install();
|
||||
LauncherLogger.info("Starting ZernMC Launcher " + CURRENT_VERSION);
|
||||
|
||||
List<String> argList = List.of(args);
|
||||
boolean jfxMode = argList.contains("--jfx");
|
||||
boolean cliMode = argList.contains("--cli");
|
||||
|
||||
if (jfxMode) {
|
||||
launchJFX();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.print("\033[H\033[2J");
|
||||
System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
|
||||
|
||||
startCLI();
|
||||
}
|
||||
|
||||
private static void launchJFX() {
|
||||
try {
|
||||
System.setProperty("javafx.runtime.version", "21");
|
||||
|
||||
JFXLauncher.main(new String[]{});
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage()));
|
||||
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
|
||||
System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing."));
|
||||
System.err.println(ZAnsi.yellow("Try CLI mode: --cli"));
|
||||
}
|
||||
e.printStackTrace();
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void startCLI() throws IOException {
|
||||
ZHttpClient.checkAllServicesOnStartup(true);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Checking authorization..."));
|
||||
var sessionResponse = api.checkSession();
|
||||
|
||||
if (!sessionResponse.isSuccess()) {
|
||||
LoginMenu loginMenu = new LoginMenu();
|
||||
boolean loggedIn = loginMenu.show();
|
||||
if (!loggedIn) {
|
||||
System.out.println(ZAnsi.yellow("Goodbye!"));
|
||||
ZAnsi.uninstall();
|
||||
System.exit(0);
|
||||
}
|
||||
} else {
|
||||
var sessionInfo = sessionResponse.getData();
|
||||
System.out.println(ZAnsi.brightGreen("Welcome back, " + sessionInfo.getUsername() + "!"));
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.cyan("Starting CLI mode..."));
|
||||
|
||||
try {
|
||||
mainLoop();
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Critical error: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
ZAnsi.uninstall();
|
||||
}
|
||||
}
|
||||
|
||||
private static void mainLoop() throws Exception {
|
||||
if (Config.isZernMCBuild()) {
|
||||
zernMCFlow();
|
||||
} else {
|
||||
globalFlow();
|
||||
}
|
||||
}
|
||||
|
||||
private static void zernMCFlow() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
|
||||
|
||||
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
|
||||
try {
|
||||
String response = ZHttpClient.get("/health");
|
||||
System.out.println(ZAnsi.brightGreen("✓ Server is available"));
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("✗ Could not connect to ZernMC server"));
|
||||
System.out.println(ZAnsi.white("Error: " + e.getMessage()));
|
||||
ConsoleUtils.pause();
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
boolean sessionRestored = AuthManager.loadSavedSession();
|
||||
if (!sessionRestored) {
|
||||
LoginMenu loginMenu = new LoginMenu();
|
||||
boolean loggedIn = loginMenu.show();
|
||||
if (!loggedIn) {
|
||||
System.exit(0);
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Welcome back, " + AuthManager.getUsername() + "!"));
|
||||
}
|
||||
|
||||
LaunchMenu launchMenu = new LaunchMenu();
|
||||
launchMenu.show();
|
||||
}
|
||||
|
||||
private static void globalFlow() throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
|
||||
|
||||
List<String> options = List.of(
|
||||
"Launch Game",
|
||||
"Check Updates",
|
||||
"Settings",
|
||||
"Server Connection Check",
|
||||
"Exit"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Main Menu", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 4) {
|
||||
System.out.println(ZAnsi.yellow("Goodbye!"));
|
||||
break;
|
||||
}
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> new LaunchMenu().show();
|
||||
case 1 -> new UpdateMenu().show();
|
||||
case 2 -> new SettingsMenu().show();
|
||||
case 3 -> new ServerCheckMenu().show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package me.sashegdev.zernmc.launcher.api;
|
||||
|
||||
public class ApiResponse<T> {
|
||||
private boolean success;
|
||||
private T data;
|
||||
private String error;
|
||||
|
||||
public ApiResponse(boolean success, T data, String error) {
|
||||
this.success = success;
|
||||
this.data = data;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(true, data, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(String error) {
|
||||
return new ApiResponse<>(false, null, error);
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package me.sashegdev.zernmc.launcher.api;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
|
||||
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
||||
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class LauncherAPI {
|
||||
|
||||
private final AuthService authService;
|
||||
private final InstanceService instanceService;
|
||||
private final LaunchService launchService;
|
||||
|
||||
public LauncherAPI() {
|
||||
this.authService = new AuthService();
|
||||
this.instanceService = new InstanceService();
|
||||
this.launchService = new LaunchService();
|
||||
}
|
||||
|
||||
public AuthService auth() {
|
||||
return authService;
|
||||
}
|
||||
|
||||
public InstanceService instances() {
|
||||
return instanceService;
|
||||
}
|
||||
|
||||
public LaunchService launch() {
|
||||
return launchService;
|
||||
}
|
||||
|
||||
public boolean isLoggedIn() {
|
||||
return authService.isLoggedIn();
|
||||
}
|
||||
|
||||
public String getCurrentUsername() {
|
||||
return authService.getCurrentUsername();
|
||||
}
|
||||
|
||||
public ApiResponse<AuthService.SessionInfo> checkSession() {
|
||||
return authService.checkSession();
|
||||
}
|
||||
|
||||
public ApiResponse<AuthService.LoginResult> login(String username, String password) {
|
||||
return authService.login(username, password);
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> logout() {
|
||||
return authService.logout();
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> activatePass(String passCode) {
|
||||
return authService.activatePass(passCode);
|
||||
}
|
||||
|
||||
public ApiResponse<AuthService.LoginResult> register(String username, String password) {
|
||||
return authService.register(username, password);
|
||||
}
|
||||
|
||||
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
|
||||
return instanceService.getAllInstances();
|
||||
}
|
||||
|
||||
public ApiResponse<LaunchService.InstanceInfo> getLaunchInfo(String instanceName) {
|
||||
return launchService.getLaunchInfo(instanceName);
|
||||
}
|
||||
|
||||
public ApiResponse<LaunchService.LaunchInfo> prepareLaunch(String instanceName) {
|
||||
return launchService.prepareLaunch(instanceName);
|
||||
}
|
||||
|
||||
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
|
||||
return launchService.launch(instanceName);
|
||||
}
|
||||
|
||||
public ApiResponse<List<String>> getMCVersions() {
|
||||
try {
|
||||
org.json.JSONObject manifest = ZHttpClient.getMojangVersionManifest();
|
||||
org.json.JSONArray versions = manifest.getJSONArray("versions");
|
||||
List<String> mcVersions = new ArrayList<>();
|
||||
for (int i = 0; i < versions.length(); i++) {
|
||||
mcVersions.add(versions.getJSONObject(i).getString("id"));
|
||||
}
|
||||
return ApiResponse.success(mcVersions);
|
||||
} catch (Exception e) {
|
||||
System.out.println("[API] MC versions fetch failed: " + e.getMessage());
|
||||
}
|
||||
return ApiResponse.error("Failed to load Minecraft versions");
|
||||
}
|
||||
|
||||
public ApiResponse<List<String>> getLoaderVersions(String mcVersion, String loader) {
|
||||
try {
|
||||
List<String> versions = new ArrayList<>();
|
||||
|
||||
switch (loader.toLowerCase()) {
|
||||
case "fabric":
|
||||
versions = ZHttpClient.getFabricLoaderVersions();
|
||||
break;
|
||||
case "forge":
|
||||
String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
|
||||
int idx = 0;
|
||||
while ((idx = xml.indexOf("<version>", idx)) != -1) {
|
||||
int start = idx + 9;
|
||||
int end = xml.indexOf("</version>", start);
|
||||
if (end == -1) break;
|
||||
String fullVersion = xml.substring(start, end).trim();
|
||||
if (fullVersion.startsWith(mcVersion + "-")) {
|
||||
versions.add(fullVersion.substring(mcVersion.length() + 1));
|
||||
}
|
||||
idx = end;
|
||||
}
|
||||
versions.sort(LauncherAPI::compareVersions);
|
||||
break;
|
||||
case "neoforge":
|
||||
String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
|
||||
int neoidx = 0;
|
||||
while ((neoidx = neoforgeXml.indexOf("<version>", neoidx)) != -1) {
|
||||
int start = neoidx + 9;
|
||||
int end = neoforgeXml.indexOf("</version>", start);
|
||||
if (end == -1) break;
|
||||
String fullVersion = neoforgeXml.substring(start, end).trim();
|
||||
if (isNeoForgeCompatible(fullVersion, mcVersion)) {
|
||||
versions.add(fullVersion);
|
||||
}
|
||||
neoidx = end;
|
||||
}
|
||||
versions.sort(LauncherAPI::compareVersions);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return ApiResponse.success(versions);
|
||||
} catch (Exception e) {
|
||||
System.out.println("[API] Loader versions fetch failed: " + e.getMessage());
|
||||
return ApiResponse.error("Failed to load loader versions");
|
||||
}
|
||||
}
|
||||
|
||||
private static int compareVersions(String a, String b) {
|
||||
String[] partsA = a.split("\\.");
|
||||
String[] partsB = b.split("\\.");
|
||||
int len = Math.min(partsA.length, partsB.length);
|
||||
for (int i = 0; i < len; i++) {
|
||||
try {
|
||||
int numA = Integer.parseInt(partsA[i]);
|
||||
int numB = Integer.parseInt(partsB[i]);
|
||||
if (numA != numB) return Integer.compare(numB, numA);
|
||||
} catch (NumberFormatException e) {
|
||||
int cmp = partsA[i].compareTo(partsB[i]);
|
||||
if (cmp != 0) return cmp;
|
||||
}
|
||||
}
|
||||
return Integer.compare(partsB.length, partsA.length);
|
||||
}
|
||||
|
||||
private boolean isNeoForgeCompatible(String version, String mcVersion) {
|
||||
if (mcVersion.startsWith("1.21")) {
|
||||
return version.contains("1.21") && !version.contains("1.20");
|
||||
} else if (mcVersion.startsWith("1.20") && !mcVersion.equals("1.20")) {
|
||||
return version.contains("1.20.4") || version.contains("1.20.5") || version.contains("1.20.6");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public ApiResponse<List<Map<String, String>>> getZernMCPacks() {
|
||||
try {
|
||||
String token = authService.getCurrentToken();
|
||||
if (token == null) {
|
||||
LauncherLogger.warn("getZernMCPacks: not logged in");
|
||||
return ApiResponse.error("Not logged in");
|
||||
}
|
||||
|
||||
String response = ZHttpClient.get("/packs");
|
||||
org.json.JSONObject root = new org.json.JSONObject(response);
|
||||
org.json.JSONArray arr = root.optJSONArray("packs");
|
||||
List<Map<String, String>> packs = new ArrayList<>();
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
org.json.JSONObject pack = arr.getJSONObject(i);
|
||||
Map<String, String> packInfo = new java.util.HashMap<>();
|
||||
packInfo.put("name", pack.optString("name", ""));
|
||||
packInfo.put("displayName", pack.optString("displayName", pack.optString("name", "")));
|
||||
packInfo.put("version", pack.optString("version", ""));
|
||||
packInfo.put("mcVersion", pack.optString("minecraft_version", ""));
|
||||
packInfo.put("loader", pack.optString("loader_type", "vanilla"));
|
||||
packInfo.put("description", pack.optString("description", ""));
|
||||
packs.add(packInfo);
|
||||
}
|
||||
}
|
||||
LauncherLogger.info("getZernMCPacks: loaded " + packs.size() + " packs");
|
||||
return ApiResponse.success(packs);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("getZernMCPacks failed: " + e.getMessage());
|
||||
return ApiResponse.error("Failed to load packs: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package me.sashegdev.zernmc.launcher.api.auth;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class AuthService {
|
||||
|
||||
public ApiResponse<LoginResult> register(String username, String password) {
|
||||
try {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("username", username);
|
||||
json.addProperty("password", password);
|
||||
String response = post("/auth/register", json.toString());
|
||||
|
||||
// If registration succeeds, auto-login
|
||||
AuthManager.AuthResult result = AuthManager.login(username, password);
|
||||
if (result.success) {
|
||||
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
|
||||
return ApiResponse.success(loginResult);
|
||||
}
|
||||
return ApiResponse.error(result.error != null ? result.error : "Registration failed");
|
||||
} catch (Exception e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && msg.contains("HTTP 409")) {
|
||||
return ApiResponse.error("Username already taken");
|
||||
}
|
||||
return ApiResponse.error("Registration error: " + msg);
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<LoginResult> login(String username, String password) {
|
||||
try {
|
||||
AuthManager.AuthResult result = AuthManager.login(username, password);
|
||||
if (result.success) {
|
||||
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
|
||||
return ApiResponse.success(loginResult);
|
||||
}
|
||||
return ApiResponse.error(result.error != null ? result.error : "Invalid login or password");
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Auth error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> logout() {
|
||||
try {
|
||||
AuthManager.logout();
|
||||
return ApiResponse.success(true);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Logout error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<SessionInfo> checkSession() {
|
||||
try {
|
||||
boolean restored = AuthManager.loadSavedSession();
|
||||
if (restored) {
|
||||
SessionInfo info = new SessionInfo(
|
||||
AuthManager.getUsername(),
|
||||
AuthManager.getAccessToken(),
|
||||
AuthManager.hasActivePass(),
|
||||
AuthManager.getRole(),
|
||||
AuthManager.getRoleName()
|
||||
);
|
||||
return ApiResponse.success(info);
|
||||
}
|
||||
return ApiResponse.error("Session not found");
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Session check error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> activatePass(String passCode) {
|
||||
try {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("pass_code", passCode);
|
||||
String response = post("/auth/pass/activate", json.toString());
|
||||
AuthManager.refreshUserInfo();
|
||||
return ApiResponse.success(true);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Pass activation error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String post(String endpoint, String jsonBody) throws Exception {
|
||||
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
||||
java.net.URL url = new java.net.URL(fullUrl);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
|
||||
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
|
||||
|
||||
if (AuthManager.getAccessToken() != null && !AuthManager.getAccessToken().equals("0")) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + AuthManager.getAccessToken());
|
||||
}
|
||||
|
||||
conn.setDoOutput(true);
|
||||
|
||||
try (var os = conn.getOutputStream()) {
|
||||
byte[] input = jsonBody.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
os.write(input);
|
||||
}
|
||||
|
||||
int statusCode = conn.getResponseCode();
|
||||
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||
|
||||
String responseBody;
|
||||
try (var scanner = new java.util.Scanner(is, java.nio.charset.StandardCharsets.UTF_8.name())) {
|
||||
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||
}
|
||||
|
||||
conn.disconnect();
|
||||
|
||||
if (statusCode != 200) {
|
||||
throw new IOException("HTTP " + statusCode + ": " + responseBody);
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
public boolean isLoggedIn() {
|
||||
return AuthManager.isLoggedIn();
|
||||
}
|
||||
|
||||
public String getCurrentUsername() {
|
||||
return AuthManager.getUsername();
|
||||
}
|
||||
|
||||
public String getCurrentToken() {
|
||||
return AuthManager.getAccessToken();
|
||||
}
|
||||
|
||||
public static class LoginResult {
|
||||
private String username;
|
||||
private String token;
|
||||
|
||||
public LoginResult(String username, String token) {
|
||||
this.username = username;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public String getUsername() { return username; }
|
||||
public String getToken() { return token; }
|
||||
}
|
||||
|
||||
public static class SessionInfo {
|
||||
private String username;
|
||||
private String token;
|
||||
private boolean passActive;
|
||||
private int role;
|
||||
private String roleName;
|
||||
|
||||
public SessionInfo(String username, String token, boolean passActive, int role, String roleName) {
|
||||
this.username = username;
|
||||
this.token = token;
|
||||
this.passActive = passActive;
|
||||
this.role = role;
|
||||
this.roleName = roleName;
|
||||
}
|
||||
|
||||
public String getUsername() { return username; }
|
||||
public String getToken() { return token; }
|
||||
public boolean isPassActive() { return passActive; }
|
||||
public int getRole() { return role; }
|
||||
public String getRoleName() { return roleName; }
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package me.sashegdev.zernmc.launcher.api.instance;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class InstanceService {
|
||||
|
||||
public ApiResponse<List<InstanceInfo>> getAllInstances() {
|
||||
try {
|
||||
List<Instance> instances = InstanceManager.getAllInstances();
|
||||
List<InstanceInfo> infoList = instances.stream()
|
||||
.map(this::toInstanceInfo)
|
||||
.collect(Collectors.toList());
|
||||
return ApiResponse.success(infoList);
|
||||
} catch (IOException e) {
|
||||
return ApiResponse.error("Error getting instances list: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<InstanceInfo> getInstance(String name) {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Pack not found: " + name);
|
||||
}
|
||||
return ApiResponse.success(toInstanceInfo(instance));
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Error getting pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<InstanceInfo> createInstance(String name) {
|
||||
try {
|
||||
boolean created = InstanceManager.createInstanceFolder(name);
|
||||
if (!created) {
|
||||
return ApiResponse.error("A pack with this name already exists: " + name);
|
||||
}
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
return ApiResponse.success(toInstanceInfo(instance));
|
||||
} catch (IOException e) {
|
||||
return ApiResponse.error("Error creating pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> deleteInstance(String name) {
|
||||
try {
|
||||
boolean deleted = InstanceManager.deleteInstance(name);
|
||||
if (!deleted) {
|
||||
return ApiResponse.error("Failed to delete pack: " + name);
|
||||
}
|
||||
return ApiResponse.success(true);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Error deleting pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> isInstanceExists(String name) {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
return ApiResponse.success(instance != null);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Error checking pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private InstanceInfo toInstanceInfo(Instance instance) {
|
||||
String name = instance.getName().toLowerCase();
|
||||
String category = instance.isServerPack() ? "zernmc" : "local";
|
||||
|
||||
return new InstanceInfo(
|
||||
instance.getName(),
|
||||
instance.getPath().toString(),
|
||||
instance.getMinecraftVersion(),
|
||||
instance.getLoaderType(),
|
||||
category,
|
||||
instance.isServerPack(),
|
||||
instance.getServerVersion(),
|
||||
instance.getLoaderVersion(),
|
||||
instance.getServerPackName()
|
||||
);
|
||||
}
|
||||
|
||||
public static class InstanceInfo {
|
||||
private String name;
|
||||
private String path;
|
||||
private String version;
|
||||
private String loaderType;
|
||||
private String category;
|
||||
private boolean isServerPack;
|
||||
private int serverVersion;
|
||||
private String loaderVersion;
|
||||
private String serverPackName;
|
||||
|
||||
public InstanceInfo(String name, String path, String version, String loaderType, String category,
|
||||
boolean isServerPack, int serverVersion, String loaderVersion, String serverPackName) {
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
this.version = version;
|
||||
this.loaderType = loaderType;
|
||||
this.category = category;
|
||||
this.isServerPack = isServerPack;
|
||||
this.serverVersion = serverVersion;
|
||||
this.loaderVersion = loaderVersion;
|
||||
this.serverPackName = serverPackName;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public String getPath() { return path; }
|
||||
public String getVersion() { return version; }
|
||||
public String getLoaderType() { return loaderType; }
|
||||
public String getCategory() { return category; }
|
||||
public boolean isServerPack() { return isServerPack; }
|
||||
public int getServerVersion() { return serverVersion; }
|
||||
public String getLoaderVersion() { return loaderVersion; }
|
||||
public String getServerPackName() { return serverPackName; }
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package me.sashegdev.zernmc.launcher.api.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class LaunchService {
|
||||
|
||||
private static final ConcurrentHashMap<Long, Process> runningProcesses = new ConcurrentHashMap<>();
|
||||
|
||||
static {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
System.out.println("[LAUNCH] Shutting down all running processes...");
|
||||
runningProcesses.values().forEach(p -> {
|
||||
try {
|
||||
p.destroy();
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(instanceName);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Pack not found: " + instanceName);
|
||||
}
|
||||
|
||||
LauncherLogger.info("Preparing launch for: " + instanceName);
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
LaunchOptions options = createOptions();
|
||||
|
||||
List<String> command = builder.build(options);
|
||||
|
||||
LaunchInfo info = new LaunchInfo(
|
||||
instanceName,
|
||||
command,
|
||||
instance.getPath().toString()
|
||||
);
|
||||
return ApiResponse.success(info);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("Error preparing launch for " + instanceName, e);
|
||||
return ApiResponse.error("Error preparing launch: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<ProcessInfo> launch(String instanceName) {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(instanceName);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Pack not found: " + instanceName);
|
||||
}
|
||||
|
||||
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
|
||||
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
LaunchOptions options = createOptions();
|
||||
options.setUsername(AuthManager.getUsername());
|
||||
options.setAccessToken(AuthManager.getAccessToken());
|
||||
options.setUuid(AuthManager.getUuid());
|
||||
|
||||
List<String> command = builder.build(options);
|
||||
LauncherLogger.info("Generated command for " + instanceName + ":");
|
||||
command.forEach(arg -> LauncherLogger.debug(" " + arg));
|
||||
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||
processBuilder.directory(instance.getPath().toFile());
|
||||
processBuilder.redirectErrorStream(true);
|
||||
|
||||
Path logsDir = instance.getPath().resolve("logs");
|
||||
java.nio.file.Files.createDirectories(logsDir);
|
||||
Path gameLog = logsDir.resolve("game.log");
|
||||
|
||||
Process process = processBuilder.start();
|
||||
long pid = process.pid();
|
||||
|
||||
runningProcesses.put(pid, process);
|
||||
LauncherLogger.info("Process started, pid=" + pid);
|
||||
|
||||
java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true);
|
||||
|
||||
Thread logReader = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line;
|
||||
JFXLauncher.appendGameLog(line);
|
||||
try {
|
||||
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
logFileOut.flush();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
|
||||
} finally {
|
||||
try { logFileOut.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
}, "GameLogReader-" + instanceName);
|
||||
logReader.setDaemon(true);
|
||||
logReader.start();
|
||||
|
||||
process.onExit().thenRun(() -> {
|
||||
runningProcesses.remove(pid);
|
||||
JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
|
||||
});
|
||||
|
||||
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
|
||||
return ApiResponse.success(info);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("Launch error for " + instanceName, e);
|
||||
return ApiResponse.error("Launch error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void killAllProcesses() {
|
||||
runningProcesses.values().forEach(p -> {
|
||||
try {
|
||||
p.destroyForcibly();
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
runningProcesses.clear();
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> isReady(String instanceName) {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(instanceName);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Pack not found: " + instanceName);
|
||||
}
|
||||
|
||||
Path versionJson = instance.getPath().resolve("version.json");
|
||||
boolean hasVersionJson = versionJson.toFile().exists();
|
||||
|
||||
return ApiResponse.success(hasVersionJson);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Readiness check error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<InstanceInfo> getLaunchInfo(String instanceName) {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(instanceName);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Pack not found: " + instanceName);
|
||||
}
|
||||
|
||||
InstanceInfo info = new InstanceInfo(
|
||||
instance.getName(),
|
||||
instance.getMinecraftVersion(),
|
||||
instance.getLoaderType(),
|
||||
instance.getLoaderVersion(),
|
||||
instance.getAssetIndex()
|
||||
);
|
||||
return ApiResponse.success(info);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Info retrieval error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static LaunchOptions createOptions() {
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
options.setMaxMemory(Config.getMaxMemory());
|
||||
options.setWidth(Config.getWindowWidth());
|
||||
options.setHeight(Config.getWindowHeight());
|
||||
options.setJavaPath(Config.getJavaPath());
|
||||
List<String> extraArgs = new ArrayList<>();
|
||||
if (Config.isSystemBasedJvm()) {
|
||||
String[] systemFlags = Config.getSystemJvmFlags().split("\\s+");
|
||||
for (String arg : systemFlags) {
|
||||
if (!arg.isEmpty()) extraArgs.add(arg);
|
||||
}
|
||||
}
|
||||
String args = Config.getExtraJvmArgs();
|
||||
if (args != null && !args.isEmpty()) {
|
||||
for (String arg : args.split("\n")) {
|
||||
arg = arg.trim();
|
||||
if (!arg.isEmpty()) extraArgs.add(arg);
|
||||
}
|
||||
}
|
||||
options.setExtraJvmArgs(extraArgs);
|
||||
return options;
|
||||
}
|
||||
|
||||
public static class LaunchInfo {
|
||||
private String instanceName;
|
||||
private List<String> command;
|
||||
private String workingDirectory;
|
||||
|
||||
public LaunchInfo(String instanceName, List<String> command, String workingDirectory) {
|
||||
this.instanceName = instanceName;
|
||||
this.command = command;
|
||||
this.workingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
public String getInstanceName() { return instanceName; }
|
||||
public List<String> getCommand() { return command; }
|
||||
public String getWorkingDirectory() { return workingDirectory; }
|
||||
}
|
||||
|
||||
public static class ProcessInfo {
|
||||
private String instanceName;
|
||||
private long pid;
|
||||
private String status;
|
||||
|
||||
public ProcessInfo(String instanceName, long pid, String status) {
|
||||
this.instanceName = instanceName;
|
||||
this.pid = pid;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getInstanceName() { return instanceName; }
|
||||
public long getPid() { return pid; }
|
||||
public String getStatus() { return status; }
|
||||
}
|
||||
|
||||
public static class InstanceInfo {
|
||||
private String name;
|
||||
private String minecraftVersion;
|
||||
private String loaderType;
|
||||
private String loaderVersion;
|
||||
private String assetIndex;
|
||||
|
||||
public InstanceInfo(String name, String minecraftVersion, String loaderType,
|
||||
String loaderVersion, String assetIndex) {
|
||||
this.name = name;
|
||||
this.minecraftVersion = minecraftVersion;
|
||||
this.loaderType = loaderType;
|
||||
this.loaderVersion = loaderVersion;
|
||||
this.assetIndex = assetIndex;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public String getMinecraftVersion() { return minecraftVersion; }
|
||||
public String getLoaderType() { return loaderType; }
|
||||
public String getLoaderVersion() { return loaderVersion; }
|
||||
public String getAssetIndex() { return assetIndex; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package me.sashegdev.zernmc.launcher.auth;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public class AuthManager {
|
||||
|
||||
private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json");
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private static volatile AuthSession session = null;
|
||||
private static volatile UserInfo userInfo = null;
|
||||
|
||||
public static final int ROLE_USER = 0;
|
||||
public static final int ROLE_PASS_HOLDER = 1;
|
||||
public static final int ROLE_MODERATOR = 2;
|
||||
public static final int ROLE_ELDER = 3;
|
||||
public static final int ROLE_CREATOR = 4;
|
||||
|
||||
public static final String PERM_VIEW_PACKS = "view_packs";
|
||||
public static final String PERM_DOWNLOAD_PACK = "download_pack";
|
||||
|
||||
public static boolean loadSavedSession() {
|
||||
if (!Files.exists(AUTH_FILE)) {
|
||||
LauncherLogger.warn("loadSavedSession: auth.json not found at " + AUTH_FILE);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
String json = Files.readString(AUTH_FILE);
|
||||
AuthSession loaded = GSON.fromJson(json, AuthSession.class);
|
||||
if (loaded == null || loaded.accessToken == null) {
|
||||
LauncherLogger.warn("loadSavedSession: invalid auth.json content, deleting");
|
||||
Files.deleteIfExists(AUTH_FILE);
|
||||
return false;
|
||||
}
|
||||
|
||||
session = loaded;
|
||||
LauncherLogger.info("loadSavedSession: loaded session for " + loaded.username
|
||||
+ " expiresAt=" + loaded.expiresAt + " hasRefresh=" + (loaded.refreshToken != null));
|
||||
|
||||
refreshUserInfo();
|
||||
|
||||
if (isAccessTokenExpired()) {
|
||||
LauncherLogger.info("loadSavedSession: token expired, attempting refresh");
|
||||
boolean refreshed = tryRefresh();
|
||||
if (!refreshed) {
|
||||
if (session == null) {
|
||||
LauncherLogger.warn("loadSavedSession: token rejected by server (401)");
|
||||
return false;
|
||||
}
|
||||
LauncherLogger.warn("loadSavedSession: refresh failed (network/no refreshToken),"
|
||||
+ " keeping session for retry on next launch");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (session == null) {
|
||||
LauncherLogger.warn("loadSavedSession: session invalidated during token refresh");
|
||||
return false;
|
||||
}
|
||||
LauncherLogger.info("loadSavedSession: session valid for " + session.username);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("loadSavedSession error: " + e.getMessage());
|
||||
invalidateSession();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean tryAutoLogin() {
|
||||
if (isLoggedIn()) return true;
|
||||
if (!Files.exists(AUTH_FILE)) return false;
|
||||
return loadSavedSession();
|
||||
}
|
||||
|
||||
public static AuthResult login(String username, String password) {
|
||||
return authRequest("/auth/login", username, password);
|
||||
}
|
||||
|
||||
public static AuthResult register(String username, String password) {
|
||||
return authRequest("/auth/register", username, password);
|
||||
}
|
||||
|
||||
private static AuthResult authRequest(String endpoint, String username, String password) {
|
||||
try {
|
||||
String body = GSON.toJson(new LoginRequest(username, password));
|
||||
SimpleHttpResponse resp = post(endpoint, body);
|
||||
|
||||
if (resp.statusCode() == 200) {
|
||||
session = GSON.fromJson(resp.body(), AuthSession.class);
|
||||
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
|
||||
LauncherLogger.info("authRequest: login successful, expiresAt=" + session.expiresAt
|
||||
+ " hasRefresh=" + (session.refreshToken != null));
|
||||
saveSession();
|
||||
userInfo = fetchUserInfo();
|
||||
return AuthResult.ok();
|
||||
} else if (resp.statusCode() == 422) {
|
||||
return AuthResult.fail("Validation error: " + extractError(resp.body()));
|
||||
} else {
|
||||
return AuthResult.fail(extractError(resp.body()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return AuthResult.fail("Connection error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void logout() {
|
||||
if (session != null && session.refreshToken != null) {
|
||||
try {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("refresh_token", session.refreshToken);
|
||||
post("/auth/logout", json.toString());
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Logout error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
session = null;
|
||||
userInfo = null;
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception e) {
|
||||
LauncherLogger.warn("Failed to delete auth.json: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isLoggedIn() {
|
||||
return session != null && session.accessToken != null;
|
||||
}
|
||||
|
||||
public static boolean authFileExists() {
|
||||
return Files.exists(AUTH_FILE);
|
||||
}
|
||||
|
||||
public static String getUsername() {
|
||||
AuthSession localSession = session;
|
||||
return localSession != null ? localSession.username : "Player";
|
||||
}
|
||||
|
||||
public static String getUuid() {
|
||||
AuthSession localSession = session;
|
||||
return localSession != null ? localSession.uuid : "00000000-0000-0000-0000-000000000000";
|
||||
}
|
||||
|
||||
public static String getAccessToken() {
|
||||
AuthSession localSession = session;
|
||||
if (localSession == null) return "0";
|
||||
if (isAccessTokenExpired()) {
|
||||
boolean refreshed = tryRefresh();
|
||||
if (!refreshed) {
|
||||
localSession = session;
|
||||
if (localSession == null) return "0";
|
||||
return localSession.accessToken != null ? localSession.accessToken : "0";
|
||||
}
|
||||
}
|
||||
localSession = session;
|
||||
return localSession != null && localSession.accessToken != null ? localSession.accessToken : "0";
|
||||
}
|
||||
|
||||
private static boolean isAccessTokenExpired() {
|
||||
if (session == null) return true;
|
||||
return System.currentTimeMillis() / 1000L >= session.expiresAt - 300;
|
||||
}
|
||||
|
||||
private static boolean tryRefresh() {
|
||||
if (session == null) {
|
||||
LauncherLogger.warn("tryRefresh: session is null");
|
||||
return false;
|
||||
}
|
||||
if (session.refreshToken == null) {
|
||||
LauncherLogger.warn("tryRefresh: no refreshToken in session");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("refresh_token", session.refreshToken);
|
||||
SimpleHttpResponse resp = post("/auth/refresh", json.toString());
|
||||
|
||||
if (resp.statusCode() == 200) {
|
||||
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
|
||||
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
|
||||
session = newSession;
|
||||
userInfo = fetchUserInfo();
|
||||
if (userInfo != null) {
|
||||
session.role = userInfo.role;
|
||||
}
|
||||
saveSession();
|
||||
LauncherLogger.info("tryRefresh: token refreshed successfully");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (resp.statusCode() == 401) {
|
||||
LauncherLogger.warn("tryRefresh: server rejected refresh token (401)");
|
||||
invalidateSession();
|
||||
} else {
|
||||
LauncherLogger.warn("tryRefresh: server returned " + resp.statusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("tryRefresh: network error: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void invalidateSession() {
|
||||
session = null;
|
||||
userInfo = null;
|
||||
try {
|
||||
Files.deleteIfExists(AUTH_FILE);
|
||||
LauncherLogger.info("Session invalidated, auth.json deleted");
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("Failed to delete auth.json", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveSession() {
|
||||
try {
|
||||
Files.createDirectories(AUTH_FILE.getParent());
|
||||
Files.writeString(AUTH_FILE, GSON.toJson(session));
|
||||
LauncherLogger.info("Session saved to " + AUTH_FILE);
|
||||
} catch (IOException e) {
|
||||
LauncherLogger.error("Failed to save session", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static UserInfo fetchUserInfo() {
|
||||
if (!isLoggedIn() || session.accessToken == null) return null;
|
||||
|
||||
try {
|
||||
java.net.HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me");
|
||||
conn = (java.net.HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(10000);
|
||||
|
||||
int responseCode = conn.getResponseCode();
|
||||
if (responseCode != 200) return null;
|
||||
|
||||
StringBuilder response = new StringBuilder();
|
||||
try (var reader = new java.io.BufferedReader(
|
||||
new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
return GSON.fromJson(response.toString(), UserInfo.class);
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Failed to get UserInfo: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasPass() {
|
||||
if (!isLoggedIn()) return false;
|
||||
if (userInfo != null) return userInfo.has_pass;
|
||||
if (getRole() >= ROLE_PASS_HOLDER) return true;
|
||||
try {
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
if (json.has("has_active")) {
|
||||
return json.get("has_active").getAsBoolean();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Failed to check pass: " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean canViewPacks() {
|
||||
if (userInfo != null && userInfo.permissions != null) {
|
||||
return userInfo.permissions.contains(PERM_VIEW_PACKS);
|
||||
}
|
||||
return hasPass();
|
||||
}
|
||||
|
||||
public static boolean canDownloadPacks() {
|
||||
if (userInfo != null && userInfo.permissions != null) {
|
||||
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
|
||||
}
|
||||
return hasPass();
|
||||
}
|
||||
|
||||
public static int getRole() {
|
||||
return session != null ? session.role : ROLE_USER;
|
||||
}
|
||||
|
||||
public static String getRoleName() {
|
||||
if (userInfo != null && userInfo.role_name != null) {
|
||||
return userInfo.role_name;
|
||||
}
|
||||
return "USER";
|
||||
}
|
||||
|
||||
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
|
||||
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
||||
HttpURLConnection conn = null;
|
||||
|
||||
try {
|
||||
URL url = new URL(fullUrl);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
|
||||
conn.setRequestProperty("Connection", "close");
|
||||
|
||||
if (session != null && session.accessToken != null) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
|
||||
}
|
||||
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(15000);
|
||||
|
||||
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
||||
conn.setFixedLengthStreamingMode(input.length);
|
||||
|
||||
try (var os = conn.getOutputStream()) {
|
||||
os.write(input);
|
||||
os.flush();
|
||||
}
|
||||
|
||||
int statusCode = conn.getResponseCode();
|
||||
InputStream is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||
|
||||
String responseBody;
|
||||
if (is != null) {
|
||||
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
||||
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||
}
|
||||
} else {
|
||||
responseBody = "No response body (status " + statusCode + ")";
|
||||
}
|
||||
|
||||
return new SimpleHttpResponse(statusCode, responseBody);
|
||||
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractError(String body) {
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
if (json.has("detail")) {
|
||||
if (json.get("detail").isJsonArray()) {
|
||||
return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
|
||||
}
|
||||
return json.get("detail").getAsString();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||
}
|
||||
|
||||
public static void updateRole(int newRole) {
|
||||
if (session != null) {
|
||||
session.role = newRole;
|
||||
saveSession();
|
||||
}
|
||||
refreshUserInfo();
|
||||
}
|
||||
|
||||
public static void refreshUserInfo() {
|
||||
UserInfo fresh = fetchUserInfo();
|
||||
if (fresh != null) {
|
||||
userInfo = fresh;
|
||||
if (session != null) {
|
||||
session.role = fresh.role;
|
||||
}
|
||||
}
|
||||
if (session != null) {
|
||||
saveSession();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasActivePass() {
|
||||
if (!isLoggedIn()) return false;
|
||||
return hasPass();
|
||||
}
|
||||
|
||||
public static String getPassStatus() {
|
||||
if (!isLoggedIn()) return "Not logged in";
|
||||
try {
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean();
|
||||
return hasActive ? "Active pass" : "No pass";
|
||||
} catch (Exception e) {
|
||||
return "Check error";
|
||||
}
|
||||
}
|
||||
|
||||
public static class AuthSession {
|
||||
@SerializedName("access_token") public String accessToken;
|
||||
@SerializedName("refresh_token") public String refreshToken;
|
||||
@SerializedName("expires_in") public int expiresIn;
|
||||
public long expiresAt;
|
||||
public String username;
|
||||
public String uuid;
|
||||
public int role;
|
||||
}
|
||||
|
||||
public static class UserInfo {
|
||||
public int id;
|
||||
public String username;
|
||||
public String uuid;
|
||||
public int role;
|
||||
public String role_name;
|
||||
public boolean has_pass;
|
||||
public List<String> permissions;
|
||||
|
||||
public boolean hasPermission(String perm) {
|
||||
return permissions != null && permissions.contains(perm);
|
||||
}
|
||||
}
|
||||
|
||||
private static class LoginRequest {
|
||||
final String username;
|
||||
final String password;
|
||||
LoginRequest(String u, String p) {
|
||||
this.username = u;
|
||||
this.password = p;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AuthResult {
|
||||
public final boolean success;
|
||||
public final String error;
|
||||
private AuthResult(boolean s, String e) { success = s; error = e; }
|
||||
public static AuthResult ok() { return new AuthResult(true, null); }
|
||||
public static AuthResult fail(String msg) { return new AuthResult(false, msg); }
|
||||
}
|
||||
|
||||
// === TEST HELPERS ===
|
||||
static void resetForTest() {
|
||||
session = null;
|
||||
userInfo = null;
|
||||
}
|
||||
|
||||
static void setTestSession(AuthSession s) {
|
||||
session = s;
|
||||
}
|
||||
|
||||
static void setTestUserInfo(UserInfo u) {
|
||||
userInfo = u;
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleHttpResponse {
|
||||
final int statusCode;
|
||||
final String body;
|
||||
|
||||
SimpleHttpResponse(int statusCode, String body) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
int statusCode() { return statusCode; }
|
||||
String body() { return body; }
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
package me.sashegdev.zernmc.launcher.menu;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.Input;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.awt.*;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class LaunchMenu {
|
||||
|
||||
public void show() throws Exception {
|
||||
if (Config.isZernMCBuild()) {
|
||||
showZernMCOnly();
|
||||
} else {
|
||||
showGlobal();
|
||||
}
|
||||
}
|
||||
|
||||
private void showZernMCOnly() throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
|
||||
System.out.println(ZAnsi.cyan("Server packs only"));
|
||||
|
||||
if (!awaitActivePass()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PackDownloader tempDownloader = new PackDownloader(null);
|
||||
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
|
||||
|
||||
if (availablePacks.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("No packs available on the server."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> options = availablePacks.stream()
|
||||
.map(p -> String.format("%s [%s + %s v%d] - %d files",
|
||||
p.getName(),
|
||||
p.getMinecraftVersion(),
|
||||
p.getLoaderType(),
|
||||
p.getVersion(),
|
||||
p.getFilesCount()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
options.add("Back to main menu");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Select a pack", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
|
||||
ServerPack selected = availablePacks.get(choice);
|
||||
installAndRunServerPack(selected);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean awaitActivePass() throws Exception {
|
||||
if (AuthManager.hasActivePass()) {
|
||||
System.out.println(ZAnsi.brightGreen("✓ Active pass confirmed"));
|
||||
return true;
|
||||
}
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightRed("You don't have an active pass!"));
|
||||
System.out.println(ZAnsi.white("Access to ZernMC packs requires an active pass."));
|
||||
System.out.println();
|
||||
|
||||
openActivationWebsite();
|
||||
|
||||
System.out.println(ZAnsi.cyan("Waiting for pass activation... (checking every 10 seconds)"));
|
||||
System.out.println(ZAnsi.white("Press Enter to cancel"));
|
||||
|
||||
for (int i = 0; i < 60; i++) {
|
||||
try {
|
||||
if (System.in.available() > 0) {
|
||||
Input.readLine();
|
||||
System.out.println(ZAnsi.yellow("\nWaiting cancelled."));
|
||||
return false;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
Thread.sleep(10000);
|
||||
|
||||
if (AuthManager.hasActivePass()) {
|
||||
System.out.println(ZAnsi.brightGreen("\n✓ Pass activated successfully!"));
|
||||
return true;
|
||||
}
|
||||
|
||||
System.out.print(ZAnsi.cyan("."));
|
||||
if ((i + 1) % 6 == 0) System.out.println();
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightRed("\n\nWaiting time expired."));
|
||||
return false;
|
||||
}
|
||||
|
||||
private void openActivationWebsite() {
|
||||
String url = ZHttpClient.getBaseUrl() + "/activate-pass";
|
||||
|
||||
try {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(new URI(url));
|
||||
System.out.println(ZAnsi.cyan("Browser opened: " + url));
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Could not open browser automatically."));
|
||||
System.out.println(ZAnsi.white("Open manually: " + url));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Error opening browser: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.white("Link: " + url));
|
||||
}
|
||||
}
|
||||
|
||||
private void installAndRunServerPack(ServerPack selected) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Installing pack: " + selected.getName()));
|
||||
|
||||
System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion());
|
||||
System.out.println(ZAnsi.white(" Loader: ") + selected.getLoaderType() +
|
||||
(selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : ""));
|
||||
System.out.println(ZAnsi.white(" Version: v") + selected.getVersion());
|
||||
System.out.println(ZAnsi.white(" Files: ") + selected.getFilesCount());
|
||||
|
||||
String localName = askPackName();
|
||||
if (localName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(localName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
InstanceManager.createInstanceFolder(localName);
|
||||
Instance newInstance = InstanceManager.getInstance(localName);
|
||||
|
||||
PackDownloader packDownloader = new PackDownloader(newInstance);
|
||||
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
|
||||
|
||||
if (!success) {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
|
||||
ConsoleUtils.pause();
|
||||
|
||||
launchExistingInstance(newInstance);
|
||||
}
|
||||
|
||||
private void showGlobal() throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
List<Instance> instances = InstanceManager.getAllInstances();
|
||||
|
||||
List<String> options = instances.stream()
|
||||
.map(Instance::toString)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
options.add("Install new pack");
|
||||
options.add("Back to main menu");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Manage packs", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) break;
|
||||
|
||||
if (choice == instances.size()) {
|
||||
installNewPackGlobal();
|
||||
continue;
|
||||
}
|
||||
|
||||
Instance selected = instances.get(choice);
|
||||
manageInstance(selected);
|
||||
}
|
||||
}
|
||||
|
||||
private void installNewPackGlobal() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Install pack from ZernMC server",
|
||||
"Install Vanilla Minecraft",
|
||||
"Create custom pack (Fabric/Forge)",
|
||||
"Back"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Install new pack", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 3) return;
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> installServerPackGlobal();
|
||||
case 1 -> createVanillaInstance();
|
||||
case 2 -> createCustomInstance();
|
||||
}
|
||||
}
|
||||
|
||||
private void installServerPackGlobal() throws Exception {
|
||||
if (!awaitActivePass()) return;
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Fetching available packs..."));
|
||||
|
||||
PackDownloader tempDownloader = new PackDownloader(null);
|
||||
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
|
||||
|
||||
if (availablePacks.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("No packs available on the server."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> options = availablePacks.stream()
|
||||
.map(p -> String.format("%s [%s + %s v%d] - %d files",
|
||||
p.getName(),
|
||||
p.getMinecraftVersion(),
|
||||
p.getLoaderType(),
|
||||
p.getVersion(),
|
||||
p.getFilesCount()))
|
||||
.collect(Collectors.toList());
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Select a pack to install", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
|
||||
ServerPack selected = availablePacks.get(choice);
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Installing pack: " + selected.getName()));
|
||||
|
||||
System.out.print(ZAnsi.white("\nEnter local pack name (Enter = pack name): "));
|
||||
String localName = Input.readLine().trim();
|
||||
if (localName.isEmpty()) localName = selected.getName();
|
||||
|
||||
if (InstanceManager.getInstance(localName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
InstanceManager.createInstanceFolder(localName);
|
||||
Instance newInstance = InstanceManager.getInstance(localName);
|
||||
|
||||
PackDownloader packDownloader = new PackDownloader(newInstance);
|
||||
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void manageInstance(Instance instance) throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Managing pack: " + instance.getName()));
|
||||
System.out.println(ZAnsi.white("Version: " + instance.getMinecraftVersion()));
|
||||
System.out.println(ZAnsi.white("Loader: " + instance.getLoaderType() +
|
||||
(instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : "")));
|
||||
|
||||
if (instance.isServerPack()) {
|
||||
System.out.println(ZAnsi.green("Server pack: v" + instance.getServerVersion()));
|
||||
}
|
||||
|
||||
List<String> options = new ArrayList<>();
|
||||
options.add("Launch pack");
|
||||
if (instance.isServerPack()) {
|
||||
options.add("Check for updates");
|
||||
}
|
||||
options.add("Change loader version");
|
||||
options.add("Delete pack");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Actions", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> launchExistingInstance(instance);
|
||||
case 1 -> {
|
||||
if (instance.isServerPack()) {
|
||||
checkAndUpdateServerPack(instance);
|
||||
} else {
|
||||
changeLoaderVersion(instance);
|
||||
}
|
||||
}
|
||||
case 2 -> {
|
||||
if (instance.isServerPack()) {
|
||||
changeLoaderVersion(instance);
|
||||
} else {
|
||||
deleteInstance(instance);
|
||||
}
|
||||
}
|
||||
case 3 -> deleteInstance(instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAndUpdateServerPack(Instance instance) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName()));
|
||||
|
||||
PackDownloader downloader = new PackDownloader(instance);
|
||||
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||
|
||||
if (!hasUpdate) {
|
||||
System.out.println(ZAnsi.green("Pack is up to date (v" + instance.getServerVersion() + ")"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightYellow("Update available!"));
|
||||
if (Input.confirm("Update pack")) {
|
||||
boolean success = downloader.updatePack(instance.getServerPackName());
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("Pack updated successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Failed to update pack."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Update cancelled."));
|
||||
}
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void changeLoaderVersion(Instance instance) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Changing loader version for " + instance.getName()));
|
||||
|
||||
String currentLoader = instance.getLoaderType();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
|
||||
if ("vanilla".equalsIgnoreCase(currentLoader)) {
|
||||
System.out.println(ZAnsi.yellow("This is a vanilla instance. Cannot change loader."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
String newLoaderVersion;
|
||||
if ("fabric".equalsIgnoreCase(currentLoader)) {
|
||||
newLoaderVersion = askFabricLoaderVersion();
|
||||
} else if ("neoforge".equalsIgnoreCase(currentLoader)) {
|
||||
newLoaderVersion = askNeoForgeVersion(mcVersion);
|
||||
} else {
|
||||
newLoaderVersion = askForgeVersion(mcVersion);
|
||||
}
|
||||
|
||||
if (newLoaderVersion == null) return;
|
||||
|
||||
System.out.println(ZAnsi.cyan("Reinstalling loader " + currentLoader + " -> " + newLoaderVersion + "..."));
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
boolean success;
|
||||
|
||||
try {
|
||||
if ("fabric".equalsIgnoreCase(currentLoader)) {
|
||||
success = lib.installFabric(mcVersion, newLoaderVersion);
|
||||
} else if ("neoforge".equalsIgnoreCase(currentLoader)) {
|
||||
success = lib.installNeoForge(mcVersion, newLoaderVersion);
|
||||
} else {
|
||||
success = lib.installForge(mcVersion, newLoaderVersion);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("Loader version changed successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Failed to change loader version."));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Error changing loader: " + e.getMessage()));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void deleteInstance(Instance instance) throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> confirmOptions = List.of(
|
||||
"Yes, delete pack",
|
||||
"No, cancel"
|
||||
);
|
||||
|
||||
ArrowMenu confirmMenu = new ArrowMenu(
|
||||
"Are you sure you want to delete '" + instance.getName() + "'?",
|
||||
confirmOptions
|
||||
);
|
||||
|
||||
int choice = confirmMenu.show();
|
||||
|
||||
if (choice == 0) {
|
||||
boolean deleted = InstanceManager.deleteInstance(instance.getName());
|
||||
if (deleted) {
|
||||
System.out.println(ZAnsi.brightGreen("Pack '" + instance.getName() + "' deleted successfully."));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Failed to delete pack."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Deletion cancelled."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void launchExistingInstance(Instance instance) {
|
||||
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightRed("Launching a server pack requires an active pass!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
options.setMaxMemory(Config.getMaxMemory());
|
||||
options.setWidth(Config.getWindowWidth());
|
||||
options.setHeight(Config.getWindowHeight());
|
||||
options.setJavaPath(Config.getJavaPath());
|
||||
|
||||
options.setUsername(AuthManager.getUsername());
|
||||
options.setUuid(AuthManager.getUuid());
|
||||
options.setAccessToken(AuthManager.getAccessToken());
|
||||
|
||||
try {
|
||||
lib.launch(options);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Error launching: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private String askPackName() {
|
||||
System.out.print(ZAnsi.white("\nEnter new pack name: "));
|
||||
String name = Input.readLine().trim();
|
||||
if (name.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Cancelled."));
|
||||
return null;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private void createVanillaInstance() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||
|
||||
List<String> versionOptions = allVersions.stream()
|
||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||
.collect(Collectors.toList());
|
||||
versionOptions.add("Back");
|
||||
|
||||
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
|
||||
int versionChoice = versionMenu.show();
|
||||
|
||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||
|
||||
MinecraftVersion selectedMc = allVersions.get(versionChoice);
|
||||
String mcVersion = selectedMc.getId();
|
||||
|
||||
String packName = askPackName();
|
||||
if (packName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(packName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
InstanceManager.createInstanceFolder(packName);
|
||||
Instance newInstance = InstanceManager.getInstance(packName);
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(newInstance);
|
||||
boolean success = lib.installMinecraft(mcVersion);
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla pack '" + packName + "' created successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to create pack."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void createCustomInstance() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||
|
||||
List<String> versionOptions = allVersions.stream()
|
||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||
.collect(Collectors.toList());
|
||||
versionOptions.add("Back");
|
||||
|
||||
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
|
||||
int versionChoice = versionMenu.show();
|
||||
|
||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||
|
||||
MinecraftVersion selectedMc = allVersions.get(versionChoice);
|
||||
String mcVersion = selectedMc.getId();
|
||||
|
||||
List<String> loaderOptions = buildLoaderOptions(mcVersion);
|
||||
ArrowMenu loaderMenu = new ArrowMenu("Select mod loader for " + mcVersion, loaderOptions);
|
||||
int loaderChoice = loaderMenu.show();
|
||||
|
||||
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
|
||||
|
||||
String selectedLoader = loaderOptions.get(loaderChoice);
|
||||
|
||||
if (selectedLoader.contains("Vanilla")) {
|
||||
createVanillaInstance();
|
||||
return;
|
||||
}
|
||||
|
||||
String loaderType;
|
||||
if (selectedLoader.contains("Fabric")) {
|
||||
loaderType = "fabric";
|
||||
} else if (selectedLoader.contains("NeoForge")) {
|
||||
loaderType = "neoforge";
|
||||
} else {
|
||||
loaderType = "forge";
|
||||
}
|
||||
|
||||
String loaderVersion;
|
||||
if (loaderType.equals("fabric")) {
|
||||
loaderVersion = askFabricLoaderVersion();
|
||||
} else if (loaderType.equals("neoforge")) {
|
||||
loaderVersion = askNeoForgeVersion(mcVersion);
|
||||
} else {
|
||||
loaderVersion = askForgeVersion(mcVersion);
|
||||
}
|
||||
|
||||
if (loaderVersion == null) return;
|
||||
|
||||
String packName = askPackName();
|
||||
if (packName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(packName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
InstanceManager.createInstanceFolder(packName);
|
||||
Instance newInstance = InstanceManager.getInstance(packName);
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(newInstance);
|
||||
|
||||
boolean success;
|
||||
if (loaderType.equals("fabric")) {
|
||||
success = lib.installFabric(mcVersion, loaderVersion);
|
||||
} else if (loaderType.equals("neoforge")) {
|
||||
success = lib.installNeoForge(mcVersion, loaderVersion);
|
||||
} else {
|
||||
success = lib.installForge(mcVersion, loaderVersion);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + packName + "' installed successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to install pack."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private List<String> buildLoaderOptions(String mcVersion) {
|
||||
List<String> options = new ArrayList<>();
|
||||
|
||||
if (isFabricSupported(mcVersion)) options.add("Fabric");
|
||||
if (isNeoForgeSupported(mcVersion)) options.add("NeoForge");
|
||||
if (isForgeSupported(mcVersion)) options.add("Forge");
|
||||
options.add("Vanilla");
|
||||
options.add("Back");
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private boolean isFabricSupported(String version) {
|
||||
return version.matches("^1\\.(1[4-9]|[2-9]\\d).*");
|
||||
}
|
||||
|
||||
private boolean isForgeSupported(String version) {
|
||||
if (version.matches("^1\\.2[2-9].*") || version.matches("^\\d{2}.*")) return false;
|
||||
return version.matches("^1\\.(1[2-9]|[2-9]\\d).*") ||
|
||||
version.matches("^1\\.20.*") || version.matches("^1\\.21.*");
|
||||
}
|
||||
|
||||
private boolean isNeoForgeSupported(String version) {
|
||||
return version.matches("^1\\.20\\.[1-9].*") ||
|
||||
version.matches("^1\\.21.*") ||
|
||||
version.matches("^\\d{2}\\..*");
|
||||
}
|
||||
|
||||
private String askFabricLoaderVersion() throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Fetching Fabric Loader versions..."));
|
||||
List<String> versions = ZHttpClient.getFabricLoaderVersions();
|
||||
|
||||
List<String> options = versions.stream()
|
||||
.limit(30)
|
||||
.map(v -> "Fabric Loader " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Select Fabric Loader version", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
return versions.get(choice);
|
||||
}
|
||||
|
||||
private String askForgeVersion(String mcVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Fetching Forge versions for " + mcVersion + "..."));
|
||||
|
||||
List<String> allForgeVersions = getAllForgeVersions();
|
||||
|
||||
List<String> compatibleVersions = allForgeVersions.stream()
|
||||
.filter(v -> v.startsWith(mcVersion + "-"))
|
||||
.map(v -> v.substring(mcVersion.length() + 1))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (compatibleVersions.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("No compatible Forge versions found for " + mcVersion));
|
||||
ConsoleUtils.pause();
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> options = compatibleVersions.stream()
|
||||
.limit(30)
|
||||
.map(v -> "Forge " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Select Forge version for " + mcVersion, options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
|
||||
return compatibleVersions.get(choice);
|
||||
}
|
||||
|
||||
private List<String> getAllForgeVersions() throws Exception {
|
||||
String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
|
||||
|
||||
List<String> versions = new ArrayList<>();
|
||||
int index = 0;
|
||||
|
||||
while ((index = xml.indexOf("<version>", index)) != -1) {
|
||||
int start = index + 9;
|
||||
int end = xml.indexOf("</version>", start);
|
||||
if (end == -1) break;
|
||||
|
||||
String version = xml.substring(start, end).trim();
|
||||
versions.add(version);
|
||||
index = end;
|
||||
}
|
||||
|
||||
versions.sort((a, b) -> b.compareTo(a));
|
||||
return versions;
|
||||
}
|
||||
|
||||
private String askNeoForgeVersion(String mcVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Fetching NeoForge versions for " + mcVersion + "..."));
|
||||
|
||||
List<String> allNeoForgeVersions = getAllNeoForgeVersions();
|
||||
|
||||
List<String> compatibleVersions = allNeoForgeVersions.stream()
|
||||
.filter(v -> isNeoForgeVersionCompatible(v, mcVersion))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (compatibleVersions.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("No compatible NeoForge versions found for " + mcVersion));
|
||||
ConsoleUtils.pause();
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> options = compatibleVersions.stream()
|
||||
.limit(30)
|
||||
.map(v -> "NeoForge " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Select NeoForge version for " + mcVersion, options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
|
||||
return compatibleVersions.get(choice);
|
||||
}
|
||||
|
||||
private boolean isNeoForgeVersionCompatible(String version, String mcVersion) {
|
||||
if (mcVersion.equals("1.20.1")) {
|
||||
return version.startsWith("47.");
|
||||
}
|
||||
String majorMinor = mcVersion.replace("1.", "");
|
||||
String[] parts = majorMinor.split("\\.");
|
||||
int targetMajor = Integer.parseInt(parts[0]);
|
||||
return version.startsWith(targetMajor + ".");
|
||||
}
|
||||
|
||||
private List<String> getAllNeoForgeVersions() throws Exception {
|
||||
List<String> versions = new ArrayList<>();
|
||||
|
||||
String[] mavenUrls = {
|
||||
"https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml",
|
||||
"https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml"
|
||||
};
|
||||
|
||||
for (String mavenUrl : mavenUrls) {
|
||||
try {
|
||||
String xml = ZHttpClient.downloadString(mavenUrl);
|
||||
int index = 0;
|
||||
while ((index = xml.indexOf("<version>", index)) != -1) {
|
||||
int start = index + 9;
|
||||
int end = xml.indexOf("</version>", start);
|
||||
if (end == -1) break;
|
||||
|
||||
String version = xml.substring(start, end).trim();
|
||||
if (!versions.contains(version)) {
|
||||
versions.add(version);
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
}
|
||||
}
|
||||
|
||||
versions.sort((a, b) -> b.compareTo(a));
|
||||
return versions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package me.sashegdev.zernmc.launcher.menu;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager.AuthResult;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.Input;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class LoginMenu {
|
||||
|
||||
public boolean show() throws IOException {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Sign In",
|
||||
"Create Account",
|
||||
"Exit Launcher"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Welcome to ZernMC!", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 2) return false;
|
||||
|
||||
boolean success = switch (choice) {
|
||||
case 0 -> doLogin();
|
||||
case 1 -> doRegister();
|
||||
default -> false;
|
||||
};
|
||||
|
||||
if (success) return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void showAccountMenu() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
System.out.println(ZAnsi.header("=== Account ==="));
|
||||
System.out.println();
|
||||
System.out.println(ZAnsi.white(" Player: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
|
||||
System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid()));
|
||||
System.out.println();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Log Out",
|
||||
"Back"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Account Management", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == 0) {
|
||||
AuthManager.logout();
|
||||
System.out.println(ZAnsi.yellow("Logged out."));
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean doLogin() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
System.out.println(ZAnsi.cyan(" [ Sign In ]"));
|
||||
System.out.println();
|
||||
|
||||
String username = Input.readLine(ZAnsi.white(" Username: "));
|
||||
if (username.isEmpty()) return false;
|
||||
|
||||
String password = readPassword(" Password: ");
|
||||
if (password.isEmpty()) return false;
|
||||
|
||||
System.out.println();
|
||||
System.out.print(ZAnsi.cyan(" Signing in..."));
|
||||
|
||||
AuthResult result = AuthManager.login(username, password);
|
||||
|
||||
if (result.success) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Welcome, " + AuthManager.getUsername() + "! "));
|
||||
ConsoleUtils.pause();
|
||||
return true;
|
||||
} else {
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean doRegister() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
System.out.println(ZAnsi.cyan(" [ Create Account ]"));
|
||||
System.out.println();
|
||||
System.out.println(ZAnsi.yellow(" Allowed characters: a-z, A-Z, 0-9, _"));
|
||||
System.out.println(ZAnsi.yellow(" Name length: 3-16 chars | Password length: 6+ chars"));
|
||||
System.out.println();
|
||||
|
||||
String username = Input.readLine(ZAnsi.white(" Username: "));
|
||||
if (username.isEmpty()) return false;
|
||||
|
||||
String password = readPassword(" Password: ");
|
||||
if (password.isEmpty()) return false;
|
||||
|
||||
String confirm = readPassword(" Confirm password: ");
|
||||
if (!password.equals(confirm)) {
|
||||
System.out.println(ZAnsi.brightRed("\n Passwords do not match!"));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
System.out.print(ZAnsi.cyan(" Creating account..."));
|
||||
|
||||
AuthResult result = AuthManager.register(username, password);
|
||||
|
||||
if (result.success) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Account created! Welcome, " + AuthManager.getUsername() + "! "));
|
||||
ConsoleUtils.pause();
|
||||
return true;
|
||||
} else {
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String readPassword(String prompt) throws IOException {
|
||||
org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder()
|
||||
.system(true)
|
||||
.jna(true)
|
||||
.build();
|
||||
|
||||
passTerminal.enterRawMode();
|
||||
passTerminal.writer().print(prompt);
|
||||
passTerminal.writer().flush();
|
||||
|
||||
StringBuilder password = new StringBuilder();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
int key = passTerminal.reader().read();
|
||||
|
||||
if (key == 27) {
|
||||
int next = passTerminal.reader().read();
|
||||
if (next == 91) {
|
||||
passTerminal.reader().read();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key == 13 || key == 10) {
|
||||
passTerminal.writer().println();
|
||||
break;
|
||||
} else if (key == 127 || key == 8) {
|
||||
if (password.length() > 0) {
|
||||
password.setLength(password.length() - 1);
|
||||
passTerminal.writer().print("\b \b");
|
||||
passTerminal.writer().flush();
|
||||
}
|
||||
} else if (key == 3) {
|
||||
passTerminal.writer().println();
|
||||
System.exit(0);
|
||||
} else if (key >= 32 && key < 127) {
|
||||
password.append((char) key);
|
||||
passTerminal.writer().print('*');
|
||||
passTerminal.writer().flush();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
passTerminal.close();
|
||||
}
|
||||
|
||||
return password.toString();
|
||||
}
|
||||
|
||||
private void printBanner() {
|
||||
System.out.println(ZAnsi.header("╔══════════════════════════════╗"));
|
||||
System.out.println(ZAnsi.header("║ ZernMC Launcher ║"));
|
||||
System.out.println(ZAnsi.header("╚══════════════════════════════╝"));
|
||||
System.out.println();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package me.sashegdev.zernmc.launcher.menu;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public class ServerCheckMenu {
|
||||
|
||||
public void show() throws IOException {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Connection Diagnostics"));
|
||||
|
||||
List<String> options = List.of(
|
||||
"Check ZernMC server connection",
|
||||
"Check Mojang (Minecraft) access",
|
||||
"Check Fabric Meta access",
|
||||
"Check Forge Maven access",
|
||||
"Back to main menu"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Select check", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> checkZernServer();
|
||||
case 1 -> checkMojang();
|
||||
case 2 -> checkFabric();
|
||||
case 3 -> checkForge();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
}
|
||||
|
||||
private void checkZernServer() {
|
||||
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
|
||||
|
||||
try {
|
||||
String response = ZHttpClient.get("/health");
|
||||
System.out.println(ZAnsi.brightGreen("[OK] ZernMC server connected successfully!"));
|
||||
System.out.println(ZAnsi.white("Server response: ") + response);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Could not connect to ZernMC server"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkMojang() {
|
||||
System.out.println(ZAnsi.cyan("Checking Mojang access..."));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Mojang is accessible"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Mojang returned code " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Mojang"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkFabric() {
|
||||
System.out.println(ZAnsi.cyan("Checking Fabric Meta access..."));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://meta.fabricmc.net/v2/versions"))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta is accessible"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta returned code " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Fabric Meta"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkForge() {
|
||||
System.out.println(ZAnsi.cyan("Checking Forge Maven access..."));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven is accessible"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven returned code " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Forge Maven"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package me.sashegdev.zernmc.launcher.menu;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.Input;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class SettingsMenu {
|
||||
|
||||
public void show() throws IOException {
|
||||
List<String> options = List.of(
|
||||
"Configure Java path",
|
||||
"Configure allocated RAM",
|
||||
"Additional JVM parameters",
|
||||
"Back to main menu"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Launcher Settings", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 3) return;
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> configureJava();
|
||||
case 1 -> configureRam();
|
||||
case 2 -> configureJvmArgs();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void configureJava() {
|
||||
System.out.println(ZAnsi.cyan("Java path:"));
|
||||
System.out.println(" " + Config.getJreDir().toAbsolutePath());
|
||||
System.out.println(ZAnsi.white("\nJava will be searched automatically in ~/.zernmc/jre/"));
|
||||
System.out.println("If needed, place your own Java version there.");
|
||||
}
|
||||
|
||||
private void configureRam() {
|
||||
System.out.println(ZAnsi.cyan("RAM Allocation"));
|
||||
System.out.println(Config.getRamInfo());
|
||||
|
||||
int newRam = Input.readInt(
|
||||
ZAnsi.white("\nEnter new RAM value in MB (or 0 to cancel): "),
|
||||
0, 32768
|
||||
);
|
||||
|
||||
if (newRam == 0) {
|
||||
System.out.println(ZAnsi.yellow("Setting cancelled."));
|
||||
return;
|
||||
}
|
||||
|
||||
Config.setMaxMemory(newRam);
|
||||
System.out.println(ZAnsi.brightGreen("Allocated RAM changed to " + newRam + " MB"));
|
||||
}
|
||||
|
||||
private void configureJvmArgs() {
|
||||
System.out.println(ZAnsi.yellow("Additional JVM parameters"));
|
||||
System.out.println("Currently in development.");
|
||||
System.out.println("A list of preset optimizations will be available in the future.");
|
||||
}
|
||||
}
|
||||
+28
-30
@@ -18,12 +18,12 @@ public class UpdateMenu {
|
||||
|
||||
public void show() throws IOException {
|
||||
List<String> options = List.of(
|
||||
"Проверить обновления сборки (модпака)",
|
||||
"Проверить обновления лаунчера",
|
||||
"Назад в главное меню"
|
||||
"Check pack updates",
|
||||
"Check launcher updates",
|
||||
"Back to main menu"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Проверка обновлений", options);
|
||||
ArrowMenu menu = new ArrowMenu("Update Check", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 2) return;
|
||||
@@ -34,7 +34,7 @@ public class UpdateMenu {
|
||||
try {
|
||||
checkPackUpdates();
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.brightRed("Error: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public class UpdateMenu {
|
||||
}
|
||||
|
||||
private void checkPackUpdates() throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений сборок..."));
|
||||
System.out.println(ZAnsi.cyan("Checking pack updates..."));
|
||||
|
||||
List<Instance> instances = InstanceManager.getAllInstances();
|
||||
List<Instance> serverInstances = instances.stream()
|
||||
@@ -52,12 +52,12 @@ public class UpdateMenu {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (serverInstances.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Нет сборок, установленных с сервера."));
|
||||
System.out.println(ZAnsi.yellow("No server-installed packs found."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.cyan("\nПроверка обновлений для серверных сборок:\n"));
|
||||
System.out.println(ZAnsi.cyan("\nChecking updates for server packs:\n"));
|
||||
|
||||
boolean hasUpdates = false;
|
||||
List<Instance> updatableInstances = new ArrayList<>();
|
||||
@@ -68,42 +68,41 @@ public class UpdateMenu {
|
||||
try {
|
||||
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||
if (hasUpdate) {
|
||||
System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!"));
|
||||
System.out.println(ZAnsi.yellow(instance.getName() + " - Update available!"));
|
||||
updatableInstances.add(instance);
|
||||
hasUpdates = true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.green(instance.getName() + " - Актуальна"));
|
||||
System.out.println(ZAnsi.green(instance.getName() + " - Up to date"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.red(instance.getName() + " - Ошибка проверки: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.red(instance.getName() + " - Check error: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUpdates) {
|
||||
System.out.println(ZAnsi.green("\nВсе сборки актуальны!"));
|
||||
System.out.println(ZAnsi.green("\nAll packs are up to date!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Предлагаем обновить каждую сборку отдельно
|
||||
for (Instance instance : updatableInstances) {
|
||||
System.out.println(ZAnsi.brightYellow("\nОбновить сборку '" + instance.getName() + "'?"));
|
||||
if (Input.confirm("Обновить")) {
|
||||
System.out.println(ZAnsi.cyan("Обновление " + instance.getName() + "..."));
|
||||
System.out.println(ZAnsi.brightYellow("\nUpdate pack '" + instance.getName() + "'?"));
|
||||
if (Input.confirm("Update")) {
|
||||
System.out.println(ZAnsi.cyan("Updating " + instance.getName() + "..."));
|
||||
PackDownloader downloader = new PackDownloader(instance);
|
||||
|
||||
try {
|
||||
boolean success = downloader.updatePack(instance.getServerPackName());
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen(instance.getName() + " обновлен"));
|
||||
System.out.println(ZAnsi.brightGreen(instance.getName() + " updated"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed(instance.getName() + " не удалось обновить"));
|
||||
System.out.println(ZAnsi.brightRed(instance.getName() + " update failed"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage()));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Пропущено: " + instance.getName()));
|
||||
System.out.println(ZAnsi.yellow(" Skipped: " + instance.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,28 +110,27 @@ public class UpdateMenu {
|
||||
}
|
||||
|
||||
private void checkLauncherUpdates() {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
|
||||
System.out.println(ZAnsi.cyan("Checking launcher updates..."));
|
||||
|
||||
try {
|
||||
String json = ZHttpClient.getLauncherVersionInfo();
|
||||
String serverVersion = extractVersion(json);
|
||||
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
|
||||
|
||||
System.out.println(ZAnsi.white("Текущая версия: ") + currentVersion);
|
||||
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion);
|
||||
System.out.println(ZAnsi.white("Current version: ") + currentVersion);
|
||||
System.out.println(ZAnsi.white("Server version: ") + serverVersion);
|
||||
|
||||
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
|
||||
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия!"));
|
||||
if (Input.confirm("Обновить лаунчер?")) {
|
||||
// Обновление будет при следующем запуске
|
||||
System.out.println(ZAnsi.green("Лаунчер будет обновлен при следующем запуске."));
|
||||
System.out.println(ZAnsi.brightYellow("\nNew version available!"));
|
||||
if (Input.confirm("Update launcher?")) {
|
||||
System.out.println(ZAnsi.green("Launcher will be updated on next restart."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
|
||||
System.out.println(ZAnsi.brightGreen("Launcher is up to date."));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.yellow("Could not check launcher updates."));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -149,4 +147,4 @@ public class UpdateMenu {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ public class Instance {
|
||||
private final Path path;
|
||||
|
||||
private String minecraftVersion;
|
||||
private String loaderType; // vanilla, fabric, forge
|
||||
private String loaderType; // vanilla, fabric, forge, neoforge
|
||||
private String loaderVersion;
|
||||
private String assetIndex;
|
||||
private boolean isServerPack; // флаг, что это сборка с сервера
|
||||
+84
-32
@@ -2,13 +2,19 @@ package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
@@ -41,12 +47,17 @@ public class MinecraftLib {
|
||||
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 {
|
||||
FabricInstaller installer = new FabricInstaller(instance);
|
||||
boolean success = installer.install(minecraftVersion, loaderVersion);
|
||||
|
||||
if (success) {
|
||||
// Сохраняем информацию в Instance
|
||||
// Save info to Instance
|
||||
instance.setMinecraftVersion(minecraftVersion);
|
||||
instance.setLoaderType("fabric");
|
||||
instance.setLoaderVersion(loaderVersion);
|
||||
@@ -55,72 +66,110 @@ public class MinecraftLib {
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная установка сборки (vanilla + loader + моды)
|
||||
* Пока заглушка — будем расширять
|
||||
* Full pack install (vanilla + loader + mods)
|
||||
* Stub - will be expanded
|
||||
*/
|
||||
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Начинается полная установка сборки: " + packName));
|
||||
System.out.println(ZAnsi.cyan("Starting full pack install: " + packName));
|
||||
|
||||
// 1. Устанавливаем Minecraft
|
||||
// 1. Install Minecraft
|
||||
boolean mcInstalled = installMinecraft(minecraftVersion);
|
||||
if (!mcInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion));
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Устанавливаем лоадер
|
||||
// 2. Install loader
|
||||
if ("fabric".equalsIgnoreCase(loaderType)) {
|
||||
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
|
||||
if (!fabricInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить Fabric"));
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Fabric"));
|
||||
return false;
|
||||
}
|
||||
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
||||
System.out.println(ZAnsi.yellow("Forge пока не поддерживается"));
|
||||
return false;
|
||||
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
|
||||
if (!forgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Forge"));
|
||||
return false;
|
||||
}
|
||||
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
|
||||
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
|
||||
if (!neoforgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. В будущем здесь будет diff и скачивание модов
|
||||
// 3. In the future: diff and mod download
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Базовая установка сборки завершена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Basic pack install complete!"));
|
||||
return true;
|
||||
}
|
||||
|
||||
//Запуск
|
||||
//Launch
|
||||
public void launch(LaunchOptions options) throws Exception {
|
||||
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
||||
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
|
||||
cleanupOldLoaders();
|
||||
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
List<String> command = builder.build(options);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Команда запуска (" + command.size() + " аргументов):"));
|
||||
System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):"));
|
||||
command.forEach(arg -> System.out.println(" " + arg));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(instance.getPath().toFile());
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
||||
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
|
||||
pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
|
||||
System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
|
||||
// Capture output
|
||||
Thread outThread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
JFXLauncher.appendGameLog(line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXLauncher.appendGameLog("[Error reading output: " + e.getMessage() + "]");
|
||||
}
|
||||
});
|
||||
outThread.setDaemon(true);
|
||||
outThread.start();
|
||||
|
||||
// Capture errors
|
||||
Thread errThread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
JFXLauncher.appendGameLog("[ERR] " + line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXLauncher.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
|
||||
}
|
||||
});
|
||||
errThread.setDaemon(true);
|
||||
errThread.start();
|
||||
|
||||
int exitCode = process.waitFor();
|
||||
outThread.join(1000);
|
||||
errThread.join(1000);
|
||||
|
||||
System.out.println(ZAnsi.yellow("\nMinecraft exited with code: " + exitCode));
|
||||
}
|
||||
|
||||
private void safeDeleteDirectory(Path dir) {
|
||||
try {
|
||||
Files.walk(dir)
|
||||
.sorted((a, b) -> b.compareTo(a))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException ignored) {}
|
||||
});
|
||||
} catch (IOException ignored) {}
|
||||
try (var stream = Files.walk(dir)) {
|
||||
stream.sorted((a, b) -> b.compareTo(a))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException e) { /* ignore */ }
|
||||
});
|
||||
} catch (IOException e) {
|
||||
LauncherLogger.warn("safeDeleteDirectory: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException {
|
||||
@@ -129,7 +178,8 @@ public class MinecraftLib {
|
||||
try (var stream = Files.walk(versionsDir)) {
|
||||
stream.filter(Files::isDirectory)
|
||||
.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))
|
||||
.forEach(this::safeDeleteDirectory);
|
||||
}
|
||||
@@ -154,15 +204,17 @@ public class MinecraftLib {
|
||||
|
||||
if (currentLoaderVer == null) return;
|
||||
|
||||
System.out.println(ZAnsi.yellow("Выполняем очистку старых версий лоадера..."));
|
||||
System.out.println(ZAnsi.yellow("Cleaning old loader versions..."));
|
||||
|
||||
// Удаляем все старые fabric-loader / forge
|
||||
// Delete all old fabric-loader / forge
|
||||
Path libraries = instance.getPath().resolve("libraries");
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer);
|
||||
} else if ("forge".equals(loaderType)) {
|
||||
deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer);
|
||||
} else if ("neoforge".equals(loaderType)) {
|
||||
deleteAllExcept(libraries.resolve("net/neoforged/neoforge"), currentLoaderVer);
|
||||
}
|
||||
|
||||
// Также чистим versions/ от старых fabric/forge версий
|
||||
+172
-140
@@ -6,6 +6,9 @@ import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
@@ -17,6 +20,7 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
@@ -27,88 +31,104 @@ public class PackDownloader {
|
||||
private final Instance instance;
|
||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
|
||||
//private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
|
||||
|
||||
public PackDownloader(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список доступных паков с сервера
|
||||
* Get list of available packs from server
|
||||
*/
|
||||
public List<ServerPack> getAvailablePacks() throws Exception {
|
||||
String response = ZHttpClient.get("/packs");
|
||||
|
||||
// Для отладки - выведем ответ сервера
|
||||
System.out.println(ZAnsi.cyan("Ответ сервера: " + response));
|
||||
|
||||
JsonObject root = JsonParser.parseString(response).getAsJsonObject();
|
||||
|
||||
// Проверяем, есть ли поле "packs"
|
||||
if (!root.has("packs")) {
|
||||
System.out.println(ZAnsi.yellow("Сервер вернул неожиданный формат ответа"));
|
||||
return new ArrayList<>();
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken == null) {
|
||||
throw new IOException("Not authenticated. Active pass required to view packs.");
|
||||
}
|
||||
|
||||
if (!AuthManager.canViewPacks()) {
|
||||
throw new IOException("Active pass required to view packs");
|
||||
}
|
||||
|
||||
// Use HttpURLConnection for GET with auth
|
||||
java.net.HttpURLConnection connection = null;
|
||||
try {
|
||||
java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs");
|
||||
connection = (java.net.HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
|
||||
if (responseCode == 403) {
|
||||
throw new IOException("Active pass required to view packs");
|
||||
}
|
||||
|
||||
StringBuilder response = new StringBuilder();
|
||||
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
|
||||
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (responseCode != 200) {
|
||||
throw new IOException("HTTP " + responseCode);
|
||||
}
|
||||
|
||||
return parsePacksResponse(response.toString());
|
||||
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ServerPack> parsePacksResponse(String responseBody) {
|
||||
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
|
||||
JsonArray packsArray = root.getAsJsonArray("packs");
|
||||
List<ServerPack> result = new ArrayList<>();
|
||||
|
||||
for (JsonElement elem : packsArray) {
|
||||
JsonObject pack = elem.getAsJsonObject();
|
||||
|
||||
// Пропускаем паки с ошибками
|
||||
if (pack.has("error")) {
|
||||
System.out.println(ZAnsi.yellow("Пак имеет ошибку: " + pack.get("error").getAsString()));
|
||||
|
||||
if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
|
||||
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 {
|
||||
// Пробуем получить name или pack_name (разные форматы)
|
||||
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; // Пропускаем если нет имени
|
||||
}
|
||||
|
||||
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() : "";
|
||||
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;
|
||||
|
||||
// Парсим дату, если есть
|
||||
|
||||
LocalDateTime updatedAt = null;
|
||||
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
|
||||
try {
|
||||
updatedAt = parseDateTime(pack.get("updated_at").getAsString());
|
||||
} catch (Exception e) {
|
||||
// Игнорируем ошибки парсинга даты
|
||||
}
|
||||
updatedAt = LocalDateTime.parse(pack.get("updated_at").getAsString(),
|
||||
DateTimeFormatter.ISO_DATE_TIME);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
result.add(new ServerPack(name, version, minecraftVersion,
|
||||
loaderType, loaderVersion, updatedAt, filesCount));
|
||||
|
||||
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
|
||||
loaderVersion, updatedAt, filesCount));
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.yellow("Ошибка парсинга пака: " + e.getMessage()));
|
||||
LauncherLogger.warn("Error parsing pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить манифест пака
|
||||
* Get pack manifest
|
||||
*/
|
||||
public PackManifest getPackManifest(String packName) throws Exception {
|
||||
String response = ZHttpClient.get("/pack/" + packName);
|
||||
@@ -116,18 +136,18 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить или обновить сборку с сервера
|
||||
* Install or update a pack from the server
|
||||
*/
|
||||
public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка сборки " + packName + " с сервера..."));
|
||||
LauncherLogger.info("Installing pack " + packName + " from server...");
|
||||
|
||||
// 1. Получаем манифест
|
||||
// 1. Get manifest
|
||||
PackManifest manifest = getPackManifest(packName);
|
||||
|
||||
// 2. Сначала устанавливаем Minecraft + Loader через MinecraftLib
|
||||
// 2. First install Minecraft + Loader via MinecraftLib
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Установка Minecraft " + manifest.getMinecraftVersion() + "..."));
|
||||
System.out.println(ZAnsi.cyan("Installing Minecraft " + manifest.getMinecraftVersion() + "..."));
|
||||
|
||||
boolean needsMinecraftInstall = instance.getMinecraftVersion() == null ||
|
||||
!instance.getMinecraftVersion().equals(manifest.getMinecraftVersion());
|
||||
@@ -136,34 +156,40 @@ public class PackDownloader {
|
||||
if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) {
|
||||
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось установить Fabric"));
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
|
||||
return false;
|
||||
}
|
||||
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
|
||||
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
||||
return false;
|
||||
}
|
||||
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
|
||||
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось установить Forge"));
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Forge"));
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось установить Vanilla Minecraft"));
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Minecraft уже установлен, пропускаем..."));
|
||||
System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
|
||||
}
|
||||
|
||||
// 3. Сканируем локальные файлы ТОЛЬКО если есть файлы для скачивания
|
||||
// 3. Scan local files only if there are files to download
|
||||
Map<String, String> localFiles = scanLocalFiles();
|
||||
|
||||
// Если в сборке нет файлов (только vanilla/loader), пропускаем diff
|
||||
// If pack has no files (vanilla/loader only), skip diff
|
||||
if (manifest.files == null || manifest.files.isEmpty()) {
|
||||
System.out.println(ZAnsi.green("Сборка не содержит дополнительных файлов"));
|
||||
System.out.println(ZAnsi.green("Pack contains no additional files"));
|
||||
|
||||
// Обновляем метаданные инстанса
|
||||
// Update instance metadata
|
||||
instance.setServerPack(true);
|
||||
instance.setServerPackName(packName);
|
||||
instance.setServerVersion(manifest.getVersion());
|
||||
@@ -172,19 +198,19 @@ public class PackDownloader {
|
||||
instance.setLoaderVersion(manifest.getLoaderVersion());
|
||||
instance.setAssetIndex(manifest.getAssetIndex());
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Отправляем diff запрос
|
||||
System.out.println(ZAnsi.cyan("Проверка файлов сборки..."));
|
||||
// 4. Send diff request
|
||||
System.out.println(ZAnsi.cyan("Checking pack files..."));
|
||||
DiffResponse diff = getDiff(packName, localFiles);
|
||||
|
||||
// 5. Применяем изменения
|
||||
// 5. Apply changes
|
||||
boolean success = applyDiff(diff, packName);
|
||||
|
||||
if (success) {
|
||||
// 6. Обновляем метаданные инстанса
|
||||
// 6. Update instance metadata
|
||||
instance.setServerPack(true);
|
||||
instance.setServerPackName(packName);
|
||||
instance.setServerVersion(manifest.getVersion());
|
||||
@@ -193,14 +219,14 @@ public class PackDownloader {
|
||||
instance.setLoaderVersion(manifest.getLoaderVersion());
|
||||
instance.setAssetIndex(manifest.getAssetIndex());
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить наличие обновлений для серверной сборки
|
||||
* Check for server pack updates
|
||||
*/
|
||||
public boolean checkForUpdates(String packName) throws Exception {
|
||||
if (!instance.isServerPack()) return false;
|
||||
@@ -213,40 +239,40 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить существующую серверную сборку
|
||||
* Update an existing server pack
|
||||
*/
|
||||
public boolean updatePack(String packName) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName() + "..."));
|
||||
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "..."));
|
||||
|
||||
PackManifest manifest = getPackManifest(packName);
|
||||
int serverVersion = manifest.getVersion();
|
||||
|
||||
if (serverVersion <= instance.getServerVersion()) {
|
||||
System.out.println(ZAnsi.green("Сборка уже актуальна (v" + instance.getServerVersion() + ")"));
|
||||
System.out.println(ZAnsi.green("Pack is already up to date (v" + instance.getServerVersion() + ")"));
|
||||
return true;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.yellow("Доступно обновление: v" + instance.getServerVersion() + " → v" + serverVersion));
|
||||
System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " → v" + serverVersion));
|
||||
|
||||
// Сканируем локальные файлы
|
||||
// Scan local files
|
||||
Map<String, String> localFiles = scanLocalFiles();
|
||||
|
||||
// Получаем diff
|
||||
// Get diff
|
||||
DiffResponse diff = getDiff(packName, localFiles);
|
||||
|
||||
// Применяем изменения
|
||||
// Apply changes
|
||||
boolean success = applyDiff(diff, packName);
|
||||
|
||||
if (success) {
|
||||
instance.setServerVersion(serverVersion);
|
||||
System.out.println(ZAnsi.brightGreen("Сборка обновлена до v" + serverVersion));
|
||||
System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сканирование локальных файлов и вычисление хешей
|
||||
* Scan local files and compute hashes
|
||||
*/
|
||||
private Map<String, String> scanLocalFiles() throws IOException {
|
||||
Map<String, String> files = new HashMap<>();
|
||||
@@ -288,26 +314,23 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить diff запрос на сервер
|
||||
* Send diff request to server
|
||||
*/
|
||||
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
||||
String json = gson.toJson(localFiles);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Отправка diff запроса для " + packName));
|
||||
System.out.println(ZAnsi.cyan("JSON размер: " + json.length() + " байт"));
|
||||
System.out.println(ZAnsi.cyan("JSON тело: " + json));
|
||||
|
||||
String baseUrl = ZHttpClient.getBaseUrl();
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
|
||||
// Get auth token
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken == null) {
|
||||
throw new IOException("Not authenticated. Active pass required to download packs.");
|
||||
}
|
||||
String url = baseUrl + "/pack/" + packName + "/diff";
|
||||
|
||||
System.out.println(ZAnsi.cyan("URL: " + url));
|
||||
|
||||
// ПРОБЛЕМА: стандартный HttpClient может отправлять chunked encoding
|
||||
// РЕШЕНИЕ: используем HttpURLConnection вместо HttpClient
|
||||
|
||||
if (!AuthManager.canDownloadPacks()) {
|
||||
throw new IOException("Active pass required to download packs");
|
||||
}
|
||||
|
||||
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
|
||||
|
||||
// Use HttpURLConnection for full control
|
||||
java.net.HttpURLConnection connection = null;
|
||||
try {
|
||||
java.net.URL urlObj = new java.net.URL(url);
|
||||
@@ -315,22 +338,22 @@ public class PackDownloader {
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length));
|
||||
connection.setDoOutput(true);
|
||||
connection.setConnectTimeout(30000);
|
||||
connection.setReadTimeout(30000);
|
||||
|
||||
// Отправляем JSON
|
||||
|
||||
// Send JSON
|
||||
try (java.io.OutputStream os = connection.getOutputStream()) {
|
||||
byte[] input = json.getBytes("UTF-8");
|
||||
os.write(input, 0, input.length);
|
||||
os.flush();
|
||||
}
|
||||
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
System.out.println(ZAnsi.cyan("Diff ответ: HTTP " + responseCode));
|
||||
|
||||
// Читаем ответ
|
||||
|
||||
// Read response
|
||||
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"))) {
|
||||
@@ -339,16 +362,19 @@ public class PackDownloader {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
String responseBody = response.toString();
|
||||
System.out.println(ZAnsi.cyan("Тело ответа: " + responseBody));
|
||||
|
||||
if (responseCode != 200) {
|
||||
throw new IOException("HTTP " + responseCode + ": " + responseBody);
|
||||
|
||||
if (responseCode == 403) {
|
||||
throw new IOException("Active pass required to download packs. Contact the administrator.");
|
||||
}
|
||||
|
||||
|
||||
if (responseCode != 200) {
|
||||
throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody));
|
||||
}
|
||||
|
||||
return gson.fromJson(responseBody, DiffResponse.class);
|
||||
|
||||
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
@@ -356,35 +382,45 @@ public class PackDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
private String extractErrorFromResponse(String body) {
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
if (json.has("detail")) {
|
||||
return json.get("detail").getAsString();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить diff (скачать новые файлы, удалить старые)
|
||||
* Apply diff (download new files, delete old ones)
|
||||
*/
|
||||
private boolean applyDiff(DiffResponse diff, String packName) {
|
||||
System.out.println(ZAnsi.cyan("\nПрименение изменений:"));
|
||||
System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов");
|
||||
System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов");
|
||||
System.out.println(ZAnsi.cyan("\nApplying changes:"));
|
||||
System.out.println(" Download: " + diff.getToDownload().size() + " files");
|
||||
System.out.println(" Delete: " + diff.getToDelete().size() + " files");
|
||||
|
||||
// Создаем директории если нужно
|
||||
// Create directories if needed
|
||||
try {
|
||||
Files.createDirectories(instance.getPath());
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage()));
|
||||
System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Удаляем файлы
|
||||
// Delete files
|
||||
for (String filePath : diff.getToDelete()) {
|
||||
Path fullPath = instance.getPath().resolve(filePath);
|
||||
try {
|
||||
if (Files.deleteIfExists(fullPath)) {
|
||||
System.out.println(ZAnsi.yellow(" Удален: " + filePath));
|
||||
System.out.println(ZAnsi.yellow(" Deleted: " + filePath));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.red(" Ошибка удаления " + filePath + ": " + e.getMessage()));
|
||||
System.err.println(ZAnsi.red(" Error deleting " + filePath + ": " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// Скачиваем файлы
|
||||
// Download files
|
||||
AtomicInteger downloaded = new AtomicInteger(0);
|
||||
int total = diff.getToDownload().size();
|
||||
|
||||
@@ -393,32 +429,32 @@ public class PackDownloader {
|
||||
Path fullPath = instance.getPath().resolve(path);
|
||||
|
||||
try {
|
||||
// Создаем директории
|
||||
// Create directories
|
||||
Files.createDirectories(fullPath.getParent());
|
||||
|
||||
// Скачиваем файл
|
||||
// Download file
|
||||
downloadFile(file, fullPath);
|
||||
|
||||
// Проверяем хеш
|
||||
// Verify hash
|
||||
String actualHash = calculateHash(fullPath);
|
||||
if (!actualHash.equals(file.getHash())) {
|
||||
throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() +
|
||||
", получен: " + actualHash);
|
||||
throw new IOException("Hash mismatch! Expected: " + file.getHash() +
|
||||
", got: " + actualHash);
|
||||
}
|
||||
|
||||
downloaded.incrementAndGet();
|
||||
if (total > 0) {
|
||||
ProgressBar.show("Скачивание", downloaded.get(), total, "файлов");
|
||||
ProgressBar.show("Download", downloaded.get(), total, "files");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage()));
|
||||
System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
ProgressBar.finish("Скачивание");
|
||||
ProgressBar.finish("Download");
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -429,12 +465,19 @@ public class PackDownloader {
|
||||
*/
|
||||
private void downloadFile(FileInfo file, Path destination) throws Exception {
|
||||
String url = ZHttpClient.getBaseUrl() + file.getUrl();
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(java.net.URI.create(url))
|
||||
.GET()
|
||||
.build();
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||
.GET();
|
||||
|
||||
if (accessToken != null && !accessToken.equals("0")) {
|
||||
builder.header("Authorization", "Bearer " + accessToken);
|
||||
}
|
||||
|
||||
HttpRequest request = builder.build();
|
||||
HttpResponse<InputStream> response = httpClient.send(request,
|
||||
HttpResponse.BodyHandlers.ofInputStream());
|
||||
|
||||
@@ -486,17 +529,6 @@ public class PackDownloader {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг даты из строки
|
||||
*/
|
||||
private LocalDateTime parseDateTime(String dateTimeStr) {
|
||||
try {
|
||||
return LocalDateTime.parse(dateTimeStr, DATE_FORMATTER);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== Вложенные классы ======================
|
||||
|
||||
public static class PackManifest {
|
||||
+24
-24
@@ -26,7 +26,7 @@ public class FabricInstaller {
|
||||
}
|
||||
|
||||
public boolean install(String minecraftVersion, String loaderVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка Fabric " + loaderVersion + " для Minecraft " + minecraftVersion));
|
||||
System.out.println(ZAnsi.cyan("Installing Fabric " + loaderVersion + " for Minecraft " + minecraftVersion));
|
||||
|
||||
Path instancePath = instance.getPath();
|
||||
cleanOldFabricLoaders();
|
||||
@@ -34,7 +34,7 @@ public class FabricInstaller {
|
||||
VersionInstaller versionInstaller = new VersionInstaller(instancePath);
|
||||
String assetIndex = versionInstaller.install(minecraftVersion);
|
||||
|
||||
System.out.println(ZAnsi.green("Asset index получен: " + assetIndex));
|
||||
System.out.println(ZAnsi.green("Asset index obtained: " + assetIndex));
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
instance.setMinecraftVersion(minecraftVersion);
|
||||
@@ -46,12 +46,12 @@ public class FabricInstaller {
|
||||
Path installerJar = instancePath.resolve("fabric-installer.jar");
|
||||
|
||||
if (!Files.exists(installerJar)) {
|
||||
ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%");
|
||||
ProgressBar.show("Downloading Fabric Installer", 0, 100, "%");
|
||||
downloadFileWithFallback(installerUrl, installerJar);
|
||||
ProgressBar.finish("Fabric Installer скачан");
|
||||
ProgressBar.finish("Fabric Installer downloaded");
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.cyan("Запуск Fabric Installer..."));
|
||||
System.out.println(ZAnsi.cyan("Running Fabric Installer..."));
|
||||
|
||||
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
||||
|
||||
@@ -71,24 +71,24 @@ public class FabricInstaller {
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
if (exitCode != 0) {
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer завершился с ошибкой (код " + exitCode + ")"));
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer failed (code " + exitCode + ")"));
|
||||
return false;
|
||||
}
|
||||
|
||||
Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId);
|
||||
|
||||
if (Files.exists(fabricVersionDir)) {
|
||||
System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!"));
|
||||
System.out.println(ZAnsi.brightGreen("Fabric installed successfully!"));
|
||||
|
||||
instance.setLoaderType("fabric");
|
||||
instance.setLoaderVersion(loaderVersion);
|
||||
instance.setFabricVersionId(fabricVersionId); // ← СОХРАНЯЕМ
|
||||
instance.setFabricVersionId(fabricVersionId);
|
||||
|
||||
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена."));
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer ran, but version not found."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public class FabricInstaller {
|
||||
try {
|
||||
ZHttpClient.downloadFile(url, target);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось скачать Fabric Installer: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.yellow("Failed to download Fabric Installer: " + e.getMessage()));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -106,28 +106,28 @@ public class FabricInstaller {
|
||||
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
|
||||
|
||||
if (!Files.exists(versionJson)) {
|
||||
System.out.println(ZAnsi.yellow("JSON файл версии не найден: " + versionJson));
|
||||
System.out.println(ZAnsi.yellow("Version JSON file not found: " + versionJson));
|
||||
return;
|
||||
}
|
||||
|
||||
String content = Files.readString(versionJson);
|
||||
|
||||
// Проверяем и исправляем asset index
|
||||
// Check and fix asset index
|
||||
if (!content.contains("\"assets\":\"" + assetIndex + "\"")) {
|
||||
System.out.println(ZAnsi.yellow("Исправляем asset index в JSON файле версии..."));
|
||||
System.out.println(ZAnsi.yellow("Fixing asset index in version JSON file..."));
|
||||
|
||||
// Заменяем assets на правильное значение
|
||||
// Replace assets with correct value
|
||||
content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\"");
|
||||
|
||||
// Также проверяем assetIndex
|
||||
// Also check assetIndex
|
||||
if (content.contains("\"assetIndex\"")) {
|
||||
content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\"");
|
||||
}
|
||||
|
||||
Files.writeString(versionJson, content);
|
||||
System.out.println(ZAnsi.green("Asset index исправлен на: " + assetIndex));
|
||||
System.out.println(ZAnsi.green("Asset index fixed to: " + assetIndex));
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Asset index в JSON версии правильный: " + assetIndex));
|
||||
System.out.println(ZAnsi.green("Asset index in version JSON is correct: " + assetIndex));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ public class FabricInstaller {
|
||||
Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader");
|
||||
if (!Files.exists(librariesDir)) return;
|
||||
|
||||
System.out.println(ZAnsi.yellow("Очистка старых версий Fabric Loader..."));
|
||||
System.out.println(ZAnsi.yellow("Cleaning old Fabric Loader versions..."));
|
||||
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(Files::isDirectory)
|
||||
@@ -155,18 +155,18 @@ public class FabricInstaller {
|
||||
|
||||
private String getLatestInstallerVersion() throws Exception {
|
||||
try {
|
||||
// Используем ZHttpClient с умным прокси
|
||||
// Use ZHttpClient with smart proxy
|
||||
String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml");
|
||||
int start = xml.indexOf("<latest>") + 8;
|
||||
int end = xml.indexOf("</latest>", start);
|
||||
return xml.substring(start, end).trim();
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Ошибка получения версии Fabric Installer: " + e.getMessage()));
|
||||
throw new Exception("Не удалось получить версию Fabric Installer", e);
|
||||
System.out.println(ZAnsi.yellow("Error getting Fabric Installer version: " + e.getMessage()));
|
||||
throw new Exception("Failed to get Fabric Installer version", e);
|
||||
}
|
||||
}
|
||||
|
||||
// под рефактор оставить
|
||||
// under refactor - keep
|
||||
private String downloadString(String url) throws Exception {
|
||||
Exception lastException = null;
|
||||
|
||||
@@ -186,7 +186,7 @@ public class FabricInstaller {
|
||||
throw new IOException("HTTP " + resp.statusCode());
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
System.out.println(ZAnsi.yellow("Попытка " + attempt + " не удалась: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.yellow("Attempt " + attempt + " failed: " + e.getMessage()));
|
||||
if (attempt < 3) {
|
||||
Thread.sleep(1000 * attempt);
|
||||
}
|
||||
@@ -207,7 +207,7 @@ public class FabricInstaller {
|
||||
HttpResponse.BodyHandlers.ofFile(target));
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("HTTP " + response.statusCode() + " при скачивании " + url);
|
||||
throw new IOException("HTTP " + response.statusCode() + " when downloading " + url);
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-53
@@ -11,7 +11,9 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class ForgeInstaller {
|
||||
@@ -26,59 +28,59 @@ public class ForgeInstaller {
|
||||
}
|
||||
|
||||
public boolean install(String mcVersion, String forgeVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка Forge " + forgeVersion + " для Minecraft " + mcVersion));
|
||||
System.out.println(ZAnsi.cyan("Installing Forge " + forgeVersion + " for Minecraft " + mcVersion));
|
||||
|
||||
// Шаг 1: Устанавливаем vanilla и получаем настоящий assetIndex
|
||||
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
|
||||
// Step 1: Install vanilla and get real assetIndex
|
||||
System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "..."));
|
||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||
|
||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
|
||||
System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
|
||||
return false;
|
||||
}
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
|
||||
// Шаг 2: Создаём launcher_profiles.json
|
||||
// Step 2: Create launcher_profiles.json
|
||||
createLauncherProfile();
|
||||
|
||||
// Шаг 3: Скачиваем Forge Installer с прогресс-баром
|
||||
// Step 3: Download Forge Installer with progress bar
|
||||
String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/"
|
||||
+ mcVersion + "-" + forgeVersion
|
||||
+ "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar";
|
||||
|
||||
Path installerJar = instance.getPath().resolve("forge-installer.jar");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание Forge Installer..."));
|
||||
System.out.println(ZAnsi.cyan("Downloading Forge Installer..."));
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
// Шаг 4: Запускаем Forge Installer и показываем его вывод
|
||||
System.out.println(ZAnsi.cyan("Запуск Forge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n"));
|
||||
// Step 4: Run Forge Installer and show its output
|
||||
System.out.println(ZAnsi.cyan("Running Forge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
|
||||
|
||||
boolean success = runForgeInstaller(installerJar);
|
||||
|
||||
// После успешной установки Forge, но перед сохранением метаданных
|
||||
// After successful Forge install, before saving metadata
|
||||
if (success) {
|
||||
// Докачиваем пропущенные библиотеки
|
||||
// Download missing libraries
|
||||
try {
|
||||
downloadMissingLibraries(mcVersion, forgeVersion);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " успешно установлен!"));
|
||||
System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " installed successfully!"));
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType("forge");
|
||||
instance.setLoaderVersion(forgeVersion);
|
||||
|
||||
// Очищаем временный файл установщика
|
||||
// Clean up temporary installer file
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\nОшибка при установке Forge!"));
|
||||
System.out.println(ZAnsi.brightRed("\nError installing Forge!"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +96,7 @@ public class ForgeInstaller {
|
||||
}
|
||||
""";
|
||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
System.out.println(ZAnsi.yellow("Создан launcher_profiles.json"));
|
||||
System.out.println(ZAnsi.yellow("Created launcher_profiles.json"));
|
||||
}
|
||||
|
||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||
@@ -132,10 +134,10 @@ public class ForgeInstaller {
|
||||
lastPercent = percent;
|
||||
}
|
||||
} else {
|
||||
// Если размер неизвестен, показываем анимацию
|
||||
// If size unknown, show animation
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (totalRead / 1024) % 4;
|
||||
System.out.print("\rСкачивание Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
||||
System.out.print("\rDownloading Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,12 +146,12 @@ public class ForgeInstaller {
|
||||
}
|
||||
|
||||
private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException {
|
||||
// Пробуем до 3 раз с разными опциями
|
||||
// Try up to 3 times with different options
|
||||
int maxRetries = 3;
|
||||
int attempt = 1;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries));
|
||||
System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"java",
|
||||
@@ -158,7 +160,7 @@ public class ForgeInstaller {
|
||||
"--installClient"
|
||||
);
|
||||
|
||||
// Добавляем JVM аргументы для увеличения таймаутов
|
||||
// Add JVM args for increased timeouts
|
||||
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
|
||||
|
||||
pb.directory(instance.getPath().toFile());
|
||||
@@ -166,7 +168,7 @@ public class ForgeInstaller {
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
// Читаем вывод в реальном времени
|
||||
// Read output in real time
|
||||
StringBuilder output = new StringBuilder();
|
||||
boolean hasErrors = false;
|
||||
|
||||
@@ -175,7 +177,7 @@ public class ForgeInstaller {
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
|
||||
// Форматируем вывод Forge Installer
|
||||
// Format Forge Installer output
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
System.out.println(ZAnsi.blue(" -> " + line));
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
@@ -195,17 +197,17 @@ public class ForgeInstaller {
|
||||
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
// Если успешно или нет ошибок скачивания
|
||||
// If successful or no download errors
|
||||
if (exitCode == 0 && !hasErrors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Если ошибка и это не последняя попытка
|
||||
// If error and not last attempt
|
||||
if (attempt < maxRetries) {
|
||||
System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд..."));
|
||||
System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
|
||||
Thread.sleep(5000);
|
||||
|
||||
// Очищаем временные файлы перед повтором
|
||||
// Clean temp files before retry
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
// Удаляем только частично скачанные библиотеки Forge
|
||||
@@ -218,15 +220,15 @@ public class ForgeInstaller {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Forge Installer завершился с кодом ошибки: " + exitCode));
|
||||
System.out.println(ZAnsi.brightRed("Forge Installer exited with error code: " + exitCode));
|
||||
|
||||
// Показываем возможное решение
|
||||
// Show possible solution
|
||||
if (output.toString().contains("timed out")) {
|
||||
System.out.println(ZAnsi.yellow("\nВозможные решения:"));
|
||||
System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение"));
|
||||
System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора"));
|
||||
System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр"));
|
||||
System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию Forge"));
|
||||
System.out.println(ZAnsi.yellow("\nPossible solutions:"));
|
||||
System.out.println(ZAnsi.yellow("1. Check your internet connection"));
|
||||
System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
|
||||
System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
|
||||
System.out.println(ZAnsi.yellow("4. Try installing a different Forge version"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,32 +239,38 @@ public class ForgeInstaller {
|
||||
}
|
||||
|
||||
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек..."));
|
||||
|
||||
// Список проблемных библиотек и их альтернативные URL
|
||||
Map<String, String> alternativeUrls = new HashMap<>();
|
||||
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
|
||||
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
|
||||
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar");
|
||||
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
||||
|
||||
// List of problematic libraries and their alternate URLs
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
|
||||
for (Map.Entry<String, String> entry : alternativeUrls.entrySet()) {
|
||||
// Map from maven path to list of mirror URLs (tried in order)
|
||||
Map<String, List<String>> alternativeUrls = new HashMap<>();
|
||||
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", Arrays.asList(
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar",
|
||||
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"
|
||||
));
|
||||
|
||||
for (Map.Entry<String, List<String>> entry : alternativeUrls.entrySet()) {
|
||||
Path target = librariesDir.resolve(entry.getKey());
|
||||
if (!Files.exists(target)) {
|
||||
Files.createDirectories(target.getParent());
|
||||
System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName()));
|
||||
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
|
||||
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
downloadFileWithProgress(entry.getValue(), target);
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3) throw e;
|
||||
System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3..."));
|
||||
Thread.sleep(2000);
|
||||
boolean downloaded = false;
|
||||
for (String mirrorUrl : entry.getValue()) {
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
downloadFileWithProgress(mirrorUrl, target);
|
||||
downloaded = true;
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e;
|
||||
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
|
||||
try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
if (downloaded) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class NeoForgeInstaller {
|
||||
|
||||
private final Instance instance;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(java.time.Duration.ofSeconds(30))
|
||||
.build();
|
||||
|
||||
public NeoForgeInstaller(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Installing NeoForge " + neoForgeVersion + " for Minecraft " + mcVersion));
|
||||
|
||||
System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "..."));
|
||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
|
||||
return false;
|
||||
}
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
createLauncherProfile();
|
||||
|
||||
String mavenGroup = getMavenGroup(mcVersion);
|
||||
String mavenArtifact = getMavenArtifact(mcVersion);
|
||||
|
||||
String installerUrl = "https://maven.neoforged.net/releases/"
|
||||
+ mavenGroup.replace('.', '/') + "/"
|
||||
+ mavenArtifact + "/"
|
||||
+ neoForgeVersion
|
||||
+ "/" + mavenArtifact + "-" + neoForgeVersion + "-installer.jar";
|
||||
|
||||
Path installerJar = instance.getPath().resolve("neoforge-installer.jar");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Downloading NeoForge Installer..."));
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Running NeoForge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
|
||||
|
||||
boolean success = runNeoForgeInstaller(installerJar);
|
||||
|
||||
if (success) {
|
||||
try {
|
||||
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " installed successfully!"));
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType("neoforge");
|
||||
instance.setLoaderVersion(neoForgeVersion);
|
||||
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\nError installing NeoForge!"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String getMavenGroup(String mcVersion) {
|
||||
if (mcVersion.equals("1.20.1")) {
|
||||
return "net.neoforged";
|
||||
}
|
||||
return "net.neoforged";
|
||||
}
|
||||
|
||||
private String getMavenArtifact(String mcVersion) {
|
||||
if (mcVersion.equals("1.20.1")) {
|
||||
return "forge";
|
||||
}
|
||||
return "neoforge";
|
||||
}
|
||||
|
||||
private void createLauncherProfile() throws IOException {
|
||||
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
|
||||
if (Files.exists(profilePath)) return;
|
||||
|
||||
String minimalProfile = """
|
||||
{
|
||||
"profiles": {},
|
||||
"selectedProfile": "Default"
|
||||
}
|
||||
""";
|
||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
System.out.println(ZAnsi.yellow("Created launcher_profiles.json"));
|
||||
}
|
||||
|
||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
|
||||
|
||||
try (InputStream in = response.body();
|
||||
FileOutputStream out = new FileOutputStream(target.toFile())) {
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
long totalRead = 0;
|
||||
int lastPercent = -1;
|
||||
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
totalRead += bytesRead;
|
||||
|
||||
if (contentLength > 0) {
|
||||
int percent = (int) ((totalRead * 100) / contentLength);
|
||||
if (percent != lastPercent) {
|
||||
String downloaded = ProgressBar.formatBytes(totalRead);
|
||||
String total = ProgressBar.formatBytes(contentLength);
|
||||
ProgressBar.show("NeoForge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
|
||||
lastPercent = percent;
|
||||
}
|
||||
} else {
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (totalRead / 1024) % 4;
|
||||
System.out.print("\rDownloading NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
|
||||
}
|
||||
|
||||
private boolean runNeoForgeInstaller(Path installerJar) throws IOException, InterruptedException {
|
||||
int maxRetries = 3;
|
||||
int attempt = 1;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"java",
|
||||
"-jar",
|
||||
installerJar.toAbsolutePath().toString(),
|
||||
"--installClient"
|
||||
);
|
||||
|
||||
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
|
||||
pb.directory(instance.getPath().toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
StringBuilder output = new StringBuilder();
|
||||
boolean hasErrors = false;
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
System.out.println(ZAnsi.blue(" -> " + line));
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
System.out.println(ZAnsi.brightGreen(" + " + line));
|
||||
} else if (line.contains("WARNING") || line.contains("warning")) {
|
||||
System.out.println(ZAnsi.yellow(" ! " + line));
|
||||
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
|
||||
System.out.println(ZAnsi.brightRed(" X " + line));
|
||||
if (line.contains("timed out") || line.contains("failed to download")) {
|
||||
hasErrors = true;
|
||||
}
|
||||
} else if (!line.isBlank()) {
|
||||
System.out.println(" " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
if (exitCode == 0 && !hasErrors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
|
||||
Thread.sleep(5000);
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar"))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("NeoForge Installer exited with error code: " + exitCode));
|
||||
|
||||
if (output.toString().contains("timed out")) {
|
||||
System.out.println(ZAnsi.yellow("\nPossible solutions:"));
|
||||
System.out.println(ZAnsi.yellow("1. Check your internet connection"));
|
||||
System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
|
||||
System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
|
||||
System.out.println(ZAnsi.yellow("4. Try installing a different NeoForge version"));
|
||||
}
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
||||
|
||||
Map<String, String> alternativeUrls = new HashMap<>();
|
||||
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
|
||||
alternativeUrls.put("org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar");
|
||||
alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar");
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
|
||||
for (Map.Entry<String, String> entry : alternativeUrls.entrySet()) {
|
||||
Path target = librariesDir.resolve(entry.getKey());
|
||||
if (!Files.exists(target)) {
|
||||
Files.createDirectories(target.getParent());
|
||||
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
|
||||
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
downloadFileWithProgress(entry.getValue(), target);
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3) throw e;
|
||||
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-35
@@ -57,12 +57,12 @@ public class VersionInstaller {
|
||||
}
|
||||
|
||||
public String install(String versionId) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Полная установка Minecraft " + versionId + "..."));
|
||||
System.out.println(ZAnsi.cyan("Full install of Minecraft " + versionId + "..."));
|
||||
Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
|
||||
Files.createDirectories(versionDir);
|
||||
|
||||
String versionUrl = getVersionUrl(versionId);
|
||||
if (versionUrl == null) throw new Exception("Версия " + versionId + " не найдена");
|
||||
if (versionUrl == null) throw new Exception("Version " + versionId + " not found");
|
||||
|
||||
String versionJson = downloadString(versionUrl);
|
||||
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
|
||||
@@ -73,8 +73,8 @@ public class VersionInstaller {
|
||||
downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
|
||||
versionDir.resolve(versionId + ".jar"), "client.jar");
|
||||
|
||||
// Библиотеки
|
||||
System.out.println(ZAnsi.cyan("Скачивание библиотек..."));
|
||||
// Libraries
|
||||
System.out.println(ZAnsi.cyan("Downloading libraries..."));
|
||||
downloadLibraries(versionData.getJSONArray("libraries"));
|
||||
|
||||
String assetIndex;
|
||||
@@ -86,12 +86,12 @@ public class VersionInstaller {
|
||||
|
||||
System.out.println(ZAnsi.cyan("Asset index: " + assetIndex));
|
||||
|
||||
// Скачиваем ассеты используя правильный индекс
|
||||
System.out.println(ZAnsi.cyan("Скачивание ассетов..."));
|
||||
// Download assets using correct index
|
||||
System.out.println(ZAnsi.cyan("Downloading assets..."));
|
||||
downloadAssets(versionData, assetIndex);
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " полностью установлен!"));
|
||||
return assetIndex; // ← возвращаем "5" а не "1.20.1"
|
||||
System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " fully installed!"));
|
||||
return assetIndex;
|
||||
}
|
||||
|
||||
private void downloadLibraries(JSONArray libraries) throws Exception {
|
||||
@@ -111,32 +111,32 @@ public class VersionInstaller {
|
||||
try {
|
||||
downloadFile(url, target, "library");
|
||||
} catch (Exception e) {
|
||||
// Пропускаем проблемные библиотеки
|
||||
// Skip problematic libraries
|
||||
}
|
||||
}
|
||||
count++;
|
||||
ProgressBar.show("Библиотеки", count, total, "файлов");
|
||||
ProgressBar.show("Libraries", count, total, "files");
|
||||
}
|
||||
ProgressBar.finish("Библиотеки загружены");
|
||||
ProgressBar.finish("Libraries downloaded");
|
||||
}
|
||||
|
||||
private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception {
|
||||
// Находим URL для asset index
|
||||
// Find URL for asset index
|
||||
JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex");
|
||||
String indexUrl = assetIndexInfo.getString("url");
|
||||
|
||||
Path indexesDir = minecraftDir.resolve("assets/indexes");
|
||||
Files.createDirectories(indexesDir);
|
||||
Path indexPath = indexesDir.resolve(assetIndex + ".json"); // ← используем assetIndex
|
||||
Path indexPath = indexesDir.resolve(assetIndex + ".json");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание asset index (" + assetIndex + ")..."));
|
||||
System.out.println(ZAnsi.cyan("Downloading asset index (" + assetIndex + ")..."));
|
||||
downloadFile(indexUrl, indexPath, "asset index");
|
||||
|
||||
String jsonContent = Files.readString(indexPath);
|
||||
JSONObject root = new JSONObject(jsonContent);
|
||||
JSONObject objects = root.getJSONObject("objects");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание " + objects.length() + " объектов ассетов (index: " + assetIndex + ")..."));
|
||||
System.out.println(ZAnsi.cyan("Downloading " + objects.length() + " asset objects (index: " + assetIndex + ")..."));
|
||||
|
||||
int total = objects.length();
|
||||
int[] success = {0};
|
||||
@@ -146,7 +146,7 @@ public class VersionInstaller {
|
||||
|
||||
for (String key : objects.keySet()) {
|
||||
JSONObject asset = objects.getJSONObject(key);
|
||||
String hash = asset.getString("hash"); // ← вот это правильный хеш!
|
||||
String hash = asset.getString("hash");
|
||||
|
||||
String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash;
|
||||
Path target = minecraftDir.resolve("assets/objects")
|
||||
@@ -160,19 +160,19 @@ public class VersionInstaller {
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
downloadFile(url, target, "");
|
||||
synchronized (this) {
|
||||
success[0]++;
|
||||
ProgressBar.show("Ассеты", success[0], total, "файлов");
|
||||
}
|
||||
downloaded = true;
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3) {
|
||||
synchronized (this) {
|
||||
failed[0]++;
|
||||
success[0]++;
|
||||
ProgressBar.show("Assets", success[0], total, "files");
|
||||
}
|
||||
System.err.println("Не удалось скачать " + hash);
|
||||
} else {
|
||||
downloaded = true;
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3) {
|
||||
synchronized (this) {
|
||||
failed[0]++;
|
||||
}
|
||||
System.err.println("Failed to download " + hash);
|
||||
} else {
|
||||
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
@@ -183,18 +183,19 @@ public class VersionInstaller {
|
||||
}
|
||||
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||
executor.shutdown();
|
||||
|
||||
ProgressBar.finish("Ассеты загружены (" + success[0] + " успешно, " + failed[0] + " пропущено)");
|
||||
ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)");
|
||||
|
||||
if (failed[0] > 0) {
|
||||
System.out.println(ZAnsi.yellow("Предупреждение: " + failed[0] + " файлов ассетов не удалось скачать."));
|
||||
System.out.println(ZAnsi.yellow("Игра запустится, но некоторые текстуры/звуки могут отсутствовать."));
|
||||
System.out.println(ZAnsi.yellow("Warning: " + failed[0] + " asset files could not be downloaded."));
|
||||
System.out.println(ZAnsi.yellow("The game will launch, but some textures/sounds may be missing."));
|
||||
}
|
||||
}
|
||||
|
||||
public String getAssetIndexId(String versionId) throws Exception {
|
||||
String versionUrl = getVersionUrl(versionId);
|
||||
if (versionUrl == null) throw new Exception("Версия не найдена");
|
||||
if (versionUrl == null) throw new Exception("Version not found");
|
||||
|
||||
String versionJson = downloadString(versionUrl);
|
||||
JSONObject versionData = new JSONObject(versionJson);
|
||||
@@ -202,7 +203,7 @@ public class VersionInstaller {
|
||||
if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) {
|
||||
return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1
|
||||
}
|
||||
return versionData.getString("assets"); // fallback (очень старые версии)
|
||||
return versionData.getString("assets"); // fallback (very old versions)
|
||||
}
|
||||
|
||||
private String getVersionUrl(String versionId) throws Exception {
|
||||
@@ -222,7 +223,7 @@ public class VersionInstaller {
|
||||
private void downloadFile(String url, Path target, String label) throws Exception {
|
||||
if (!label.isEmpty()) {
|
||||
ProgressBar.clearLine();
|
||||
System.out.println(ZAnsi.cyan("Скачивание " + label + "..."));
|
||||
System.out.println(ZAnsi.cyan("Downloading " + label + "..."));
|
||||
}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
@@ -233,8 +234,8 @@ public class VersionInstaller {
|
||||
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
if (label.isEmpty()) return; // для ассетов молча
|
||||
throw new IOException("HTTP " + response.statusCode() + " при скачивании " + label);
|
||||
if (label.isEmpty()) return; // for assets silently
|
||||
throw new IOException("HTTP " + response.statusCode() + " while downloading " + label);
|
||||
}
|
||||
|
||||
if (!label.isEmpty()) {
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class LaunchCommandBuilder {
|
||||
|
||||
private final Instance instance;
|
||||
|
||||
public LaunchCommandBuilder(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public List<String> build(LaunchOptions options) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
|
||||
String javaPath = options.getJavaPath() != null && !options.getJavaPath().isEmpty()
|
||||
? options.getJavaPath() : "java";
|
||||
command.add(javaPath);
|
||||
|
||||
command.addAll(getJvmArguments(options));
|
||||
|
||||
Path nativesDir = instance.getPath().resolve("natives");
|
||||
if (!Files.exists(nativesDir)) {
|
||||
Files.createDirectories(nativesDir);
|
||||
}
|
||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
||||
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
boolean isModloader = "fabric".equals(loaderType) || "forge".equals(loaderType) || "neoforge".equals(loaderType);
|
||||
|
||||
VersionManifest manifest = resolveVersionManifest();
|
||||
|
||||
// For modloaders, always use vanilla classpath with all libraries
|
||||
if (isModloader) {
|
||||
System.out.println(ZAnsi.cyan(" Modloader detected (" + loaderType + "), using vanilla classpath"));
|
||||
command.add("-cp");
|
||||
command.add(buildVanillaClasspath());
|
||||
command.add(getVanillaMainClass());
|
||||
command.addAll(getVanillaGameArguments(options));
|
||||
} else if (manifest != null) {
|
||||
String classpath = buildClasspathFromManifest(manifest);
|
||||
|
||||
// Fallback if classpath is empty
|
||||
if (classpath.isEmpty() || classpath.equals(instance.getPath().resolve("versions").resolve(getVersionId()).resolve(getVersionId() + ".jar").toAbsolutePath().toString())) {
|
||||
System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
|
||||
command.add("-cp");
|
||||
command.add(buildVanillaClasspath());
|
||||
command.add(getVanillaMainClass());
|
||||
command.addAll(getVanillaGameArguments(options));
|
||||
} else {
|
||||
command.add("-cp");
|
||||
command.add(classpath);
|
||||
|
||||
String mainClass = resolveMainClass(manifest);
|
||||
command.add(mainClass);
|
||||
|
||||
command.addAll(resolveGameArguments(manifest, options));
|
||||
}
|
||||
} else {
|
||||
command.add("-cp");
|
||||
command.add(buildVanillaClasspath());
|
||||
command.add(getVanillaMainClass());
|
||||
command.addAll(getVanillaGameArguments(options));
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private VersionManifest resolveVersionManifest() {
|
||||
try {
|
||||
Path versionJson = findVersionJson();
|
||||
if (versionJson != null && Files.exists(versionJson)) {
|
||||
String content = Files.readString(versionJson);
|
||||
JSONObject json = new JSONObject(content);
|
||||
System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
|
||||
return new VersionManifest(json);
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName()));
|
||||
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
|
||||
System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path findVersionJson() {
|
||||
Path versionsDir = instance.getPath().resolve("versions");
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
String loaderVersion = instance.getLoaderVersion();
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
String versionId = getVersionId();
|
||||
// Try fabric version ID first
|
||||
Path jsonPath = versionsDir.resolve(versionId).resolve(versionId + ".json");
|
||||
if (Files.exists(jsonPath)) {
|
||||
return jsonPath;
|
||||
}
|
||||
// Try instance's fabricVersionId if available
|
||||
String fabricId = instance.getFabricVersionId();
|
||||
if (fabricId != null && !fabricId.isEmpty()) {
|
||||
Path fabricPath = versionsDir.resolve(fabricId).resolve(fabricId + ".json");
|
||||
if (Files.exists(fabricPath)) {
|
||||
return fabricPath;
|
||||
}
|
||||
}
|
||||
// Try generic fabric pattern
|
||||
try {
|
||||
if (Files.exists(versionsDir)) {
|
||||
try (var stream = Files.list(versionsDir)) {
|
||||
return stream
|
||||
.filter(Files::isDirectory)
|
||||
.filter(dir -> dir.getFileName().toString().contains("fabric"))
|
||||
.filter(dir -> dir.getFileName().toString().contains(mcVersion))
|
||||
.findFirst()
|
||||
.map(dir -> dir.resolve(dir.getFileName().toString() + ".json"))
|
||||
.filter(Files::exists)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
|
||||
String[] candidates = {
|
||||
getVersionId(),
|
||||
mcVersion + "-" + loaderType + "-" + loaderVersion,
|
||||
loaderType + "-" + loaderVersion,
|
||||
mcVersion + "-" + loaderVersion,
|
||||
mcVersion
|
||||
};
|
||||
for (String candidate : candidates) {
|
||||
Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json");
|
||||
if (Files.exists(jsonPath)) {
|
||||
return jsonPath;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(versionsDir)) {
|
||||
try (var stream = Files.list(versionsDir)) {
|
||||
return stream
|
||||
.filter(Files::isDirectory)
|
||||
.filter(dir -> dir.getFileName().toString().contains("forge") ||
|
||||
dir.getFileName().toString().contains("neoforge"))
|
||||
.filter(dir -> dir.getFileName().toString().contains(mcVersion))
|
||||
.findFirst()
|
||||
.map(dir -> dir.resolve(dir.getFileName().toString() + ".json"))
|
||||
.filter(Files::exists)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
Path fallback = versionsDir.resolve(mcVersion).resolve(mcVersion + ".json");
|
||||
if (Files.exists(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getVersionId() {
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
String loaderVer = instance.getLoaderVersion();
|
||||
|
||||
if ("vanilla".equals(loaderType)) {
|
||||
return mcVersion;
|
||||
}
|
||||
else if ("fabric".equals(loaderType)) {
|
||||
String fabricId = instance.getFabricVersionId();
|
||||
if (fabricId != null && !fabricId.isEmpty()) {
|
||||
return fabricId;
|
||||
}
|
||||
return "fabric-loader-" + loaderVer + "-" + mcVersion;
|
||||
}
|
||||
else if ("forge".equals(loaderType)) {
|
||||
return mcVersion + "-forge-" + loaderVer;
|
||||
}
|
||||
else if ("neoforge".equals(loaderType)) {
|
||||
if (mcVersion.equals("1.20.1")) {
|
||||
return mcVersion + "-neoforge-" + loaderVer;
|
||||
}
|
||||
return "neoforge-" + loaderVer;
|
||||
}
|
||||
|
||||
return mcVersion;
|
||||
}
|
||||
|
||||
private String resolveMainClass(VersionManifest manifest) {
|
||||
return manifest.getMainClass();
|
||||
}
|
||||
|
||||
private String getVanillaMainClass() {
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
if ("fabric".equals(loaderType)) {
|
||||
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
|
||||
} else if ("forge".equals(loaderType)) {
|
||||
return "net.minecraftforge.client.main.ForgeClient";
|
||||
} else if ("neoforge".equals(loaderType)) {
|
||||
return "cpw.mods.bootstraplauncher.BootstrapLauncher";
|
||||
}
|
||||
return "net.minecraft.client.main.Main";
|
||||
}
|
||||
|
||||
private List<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) {
|
||||
List<String> args = new ArrayList<>();
|
||||
Map<String, String> vars = buildVariableMap(options);
|
||||
|
||||
for (String raw : manifest.getGameArguments()) {
|
||||
args.add(resolveVariable(raw, vars));
|
||||
}
|
||||
|
||||
if (options.getWidth() > 0) {
|
||||
args.add("--width");
|
||||
args.add(String.valueOf(options.getWidth()));
|
||||
}
|
||||
if (options.getHeight() > 0) {
|
||||
args.add("--height");
|
||||
args.add(String.valueOf(options.getHeight()));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private List<String> getVanillaGameArguments(LaunchOptions options) {
|
||||
List<String> args = new ArrayList<>();
|
||||
|
||||
args.add("--version");
|
||||
args.add(instance.getName());
|
||||
args.add("--gameDir");
|
||||
args.add(instance.getPath().toAbsolutePath().toString());
|
||||
args.add("--assetsDir");
|
||||
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
|
||||
args.add("--assetIndex");
|
||||
String assetIndex = instance.getAssetIndex();
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
assetIndex = instance.getMinecraftVersion();
|
||||
System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Using asset index: " + assetIndex));
|
||||
}
|
||||
args.add(assetIndex);
|
||||
args.add("--username");
|
||||
args.add(options.getUsername() != null ? options.getUsername() : "Player");
|
||||
args.add("--accessToken");
|
||||
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||
args.add("--uuid");
|
||||
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||
args.add("--userType");
|
||||
args.add("legacy");
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private Map<String, String> buildVariableMap(LaunchOptions options) {
|
||||
Map<String, String> vars = new HashMap<>();
|
||||
|
||||
Path gameDir = instance.getPath().toAbsolutePath();
|
||||
Path assetsDir = gameDir.resolve("assets");
|
||||
Path nativesDir = gameDir.resolve("natives");
|
||||
Path librariesDir = gameDir.resolve("libraries");
|
||||
|
||||
vars.put("version_name", instance.getName());
|
||||
vars.put("game_directory", gameDir.toString());
|
||||
vars.put("assets_root", assetsDir.toString());
|
||||
vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion());
|
||||
vars.put("auth_player_name", options.getUsername() != null ? options.getUsername() : "Player");
|
||||
vars.put("auth_access_token", options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||
vars.put("auth_uuid", options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||
vars.put("auth_xuid", "0");
|
||||
vars.put("user_type", "legacy");
|
||||
vars.put("version_type", "release");
|
||||
vars.put("natives_directory", nativesDir.toString());
|
||||
vars.put("library_directory", librariesDir.toString());
|
||||
vars.put("launcher_name", "ZernMC");
|
||||
vars.put("launcher_version", "1.0");
|
||||
vars.put("classpath_separator", System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":");
|
||||
vars.put("resolution_width", String.valueOf(options.getWidth() > 0 ? options.getWidth() : 1920));
|
||||
vars.put("resolution_height", String.valueOf(options.getHeight() > 0 ? options.getHeight() : 1080));
|
||||
vars.put("game_directory", gameDir.toString());
|
||||
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
if ("forge".equals(loaderType)) {
|
||||
vars.put("forge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||
} else if ("neoforge".equals(loaderType)) {
|
||||
vars.put("neoforge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||
vars.put("fml.neoForgeVersion", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||
vars.put("fml.neoForgeGroup", "net.neoforged");
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
private String resolveVariable(String raw, Map<String, String> vars) {
|
||||
if (!raw.contains("${")) return raw;
|
||||
String result = raw;
|
||||
for (Map.Entry<String, String> entry : vars.entrySet()) {
|
||||
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildClasspathFromManifest(VersionManifest manifest) throws Exception {
|
||||
List<String> paths = new ArrayList<>();
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
|
||||
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getLibraries().size() + " libraries in manifest"));
|
||||
|
||||
for (VersionManifest.Library lib : manifest.getLibraries()) {
|
||||
Path libPath = librariesDir.resolve(lib.artifactPath);
|
||||
if (Files.exists(libPath)) {
|
||||
paths.add(libPath.toAbsolutePath().toString());
|
||||
} else {
|
||||
String mavenPath = mavenToPath(lib.name);
|
||||
Path fallbackPath = librariesDir.resolve(mavenPath);
|
||||
if (Files.exists(fallbackPath)) {
|
||||
paths.add(fallbackPath.toAbsolutePath().toString());
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Library not found: " + lib.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
|
||||
|
||||
Path versionJar = findVersionJar();
|
||||
if (versionJar != null) {
|
||||
paths.add(0, versionJar.toAbsolutePath().toString());
|
||||
System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
|
||||
}
|
||||
|
||||
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
||||
return String.join(separator, paths);
|
||||
}
|
||||
|
||||
private String buildVanillaClasspath() throws Exception {
|
||||
List<String> paths = new ArrayList<>();
|
||||
String versionId = getVersionId();
|
||||
Path versionJar = instance.getPath()
|
||||
.resolve("versions")
|
||||
.resolve(versionId)
|
||||
.resolve(versionId + ".jar");
|
||||
|
||||
if (Files.exists(versionJar)) {
|
||||
paths.add(versionJar.toAbsolutePath().toString());
|
||||
} else {
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
Path fallbackJar = instance.getPath()
|
||||
.resolve("versions")
|
||||
.resolve(mcVersion)
|
||||
.resolve(mcVersion + ".jar");
|
||||
if (Files.exists(fallbackJar)) {
|
||||
paths.add(fallbackJar.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(p -> p.toString().endsWith(".jar"))
|
||||
.map(p -> p.toAbsolutePath().toString())
|
||||
.forEach(paths::add);
|
||||
}
|
||||
}
|
||||
|
||||
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
||||
return String.join(separator, paths);
|
||||
}
|
||||
|
||||
private Path findVersionJar() {
|
||||
String versionId = getVersionId();
|
||||
Path versionsDir = instance.getPath().resolve("versions");
|
||||
|
||||
Path[] candidates = {
|
||||
versionsDir.resolve(versionId).resolve(versionId + ".jar"),
|
||||
versionsDir.resolve(instance.getMinecraftVersion()).resolve(instance.getMinecraftVersion() + ".jar")
|
||||
};
|
||||
|
||||
for (Path candidate : candidates) {
|
||||
if (Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(versionsDir)) {
|
||||
try (var stream = Files.list(versionsDir)) {
|
||||
return stream
|
||||
.filter(Files::isDirectory)
|
||||
.filter(dir -> dir.getFileName().toString().contains("forge") ||
|
||||
dir.getFileName().toString().contains("neoforge"))
|
||||
.filter(dir -> dir.getFileName().toString().contains(instance.getMinecraftVersion()))
|
||||
.findFirst()
|
||||
.map(dir -> dir.resolve(dir.getFileName().toString() + ".jar"))
|
||||
.filter(Files::exists)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private String mavenToPath(String mavenName) {
|
||||
String[] parts = mavenName.split(":");
|
||||
if (parts.length < 3) return mavenName;
|
||||
|
||||
String group = parts[0].replace('.', '/');
|
||||
String artifact = parts[1];
|
||||
String version = parts[2];
|
||||
|
||||
if (parts.length == 4) {
|
||||
String classifier = parts[3];
|
||||
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + "-" + classifier + ".jar";
|
||||
}
|
||||
|
||||
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + ".jar";
|
||||
}
|
||||
|
||||
private List<String> getJvmArguments(LaunchOptions options) {
|
||||
List<String> jvmArgs = new ArrayList<>();
|
||||
|
||||
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
|
||||
jvmArgs.add("-Xmx" + ramMB + "M");
|
||||
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
|
||||
|
||||
jvmArgs.add("-XX:+UseG1GC");
|
||||
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
|
||||
jvmArgs.add("-XX:G1NewSizePercent=20");
|
||||
jvmArgs.add("-XX:G1ReservePercent=20");
|
||||
jvmArgs.add("-XX:MaxGCPauseMillis=50");
|
||||
jvmArgs.add("-XX:G1HeapRegionSize=32M");
|
||||
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
} else if ("forge".equals(loaderType)) {
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
} else if ("neoforge".equals(loaderType)) {
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
}
|
||||
|
||||
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
|
||||
jvmArgs.addAll(options.getExtraJvmArgs());
|
||||
}
|
||||
|
||||
return jvmArgs;
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class VersionManifest {
|
||||
|
||||
private final String id;
|
||||
private final String mainClass;
|
||||
private final String assetIndexId;
|
||||
private final List<String> jvmArguments;
|
||||
private final List<String> gameArguments;
|
||||
private final List<Library> libraries;
|
||||
|
||||
public VersionManifest(JSONObject json) {
|
||||
this.id = json.getString("id");
|
||||
this.mainClass = json.getString("mainClass");
|
||||
|
||||
if (json.has("assetIndex")) {
|
||||
JSONObject ai = json.getJSONObject("assetIndex");
|
||||
this.assetIndexId = ai.has("id") ? ai.getString("id") : "unknown";
|
||||
} else {
|
||||
this.assetIndexId = "unknown";
|
||||
}
|
||||
|
||||
this.jvmArguments = parseArguments(json, "jvm");
|
||||
this.gameArguments = parseArguments(json, "game");
|
||||
this.libraries = parseLibraries(json);
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public String getMainClass() { return mainClass; }
|
||||
public String getAssetIndexId() { return assetIndexId; }
|
||||
public List<String> getJvmArguments() { return jvmArguments; }
|
||||
public List<String> getGameArguments() { return gameArguments; }
|
||||
public List<Library> getLibraries() { return libraries; }
|
||||
|
||||
private List<String> parseArguments(JSONObject json, String type) {
|
||||
List<String> args = new ArrayList<>();
|
||||
if (!json.has("arguments")) return args;
|
||||
|
||||
JSONObject arguments = json.getJSONObject("arguments");
|
||||
if (!arguments.has(type)) return args;
|
||||
|
||||
JSONArray arr = arguments.getJSONArray(type);
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
Object item = arr.get(i);
|
||||
if (item instanceof String) {
|
||||
args.add((String) item);
|
||||
} else if (item instanceof JSONObject) {
|
||||
JSONObject ruleObj = (JSONObject) item;
|
||||
if (ruleMatches(ruleObj)) {
|
||||
Object value = ruleObj.get("value");
|
||||
if (value instanceof String) {
|
||||
args.add((String) value);
|
||||
} else if (value instanceof JSONArray) {
|
||||
JSONArray valArr = (JSONArray) value;
|
||||
for (int j = 0; j < valArr.length(); j++) {
|
||||
args.add(valArr.getString(j));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
private boolean ruleMatches(JSONObject ruleObj) {
|
||||
JSONArray rules = ruleObj.getJSONArray("rules");
|
||||
boolean result = false;
|
||||
for (int i = 0; i < rules.length(); i++) {
|
||||
JSONObject rule = rules.getJSONObject(i);
|
||||
String action = rule.getString("action");
|
||||
boolean matches = true;
|
||||
|
||||
if (rule.has("os")) {
|
||||
JSONObject os = rule.getJSONObject("os");
|
||||
String osName = System.getProperty("os.name").toLowerCase();
|
||||
if (os.has("name")) {
|
||||
String reqName = os.getString("name").toLowerCase();
|
||||
if (reqName.equals("windows") && !osName.contains("win")) matches = false;
|
||||
else if (reqName.equals("linux") && !osName.contains("linux") && !osName.contains("nix")) matches = false;
|
||||
else if (reqName.equals("osx") && !osName.contains("mac")) matches = false;
|
||||
}
|
||||
if (os.has("arch")) {
|
||||
String reqArch = os.getString("arch");
|
||||
String osArch = System.getProperty("os.arch");
|
||||
if (!reqArch.equals(osArch)) matches = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.has("features")) {
|
||||
JSONObject features = rule.getJSONObject("features");
|
||||
for (String key : features.keySet()) {
|
||||
if (key.startsWith("has_custom_resolution")) {
|
||||
continue; // Лаунчер сам обрабатывает разрешение
|
||||
}
|
||||
if (key.startsWith("is_demo_user")) {
|
||||
// Лаунчер не использует demo режим, считаем фичу false
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
// Неизвестная фича — считаем false
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ("allow".equals(action) && matches) {
|
||||
result = true;
|
||||
} else if ("disallow".equals(action) && matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Library> parseLibraries(JSONObject json) {
|
||||
List<Library> libs = new ArrayList<>();
|
||||
if (!json.has("libraries")) return libs;
|
||||
|
||||
JSONArray arr = json.getJSONArray("libraries");
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
JSONObject libJson = arr.getJSONObject(i);
|
||||
if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) {
|
||||
String name = libJson.getString("name");
|
||||
String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path");
|
||||
Library lib = new Library(name, artifactPath);
|
||||
|
||||
if (libJson.has("natives")) {
|
||||
JSONObject natives = libJson.getJSONObject("natives");
|
||||
for (String key : natives.keySet()) {
|
||||
String osKey = key.toLowerCase();
|
||||
lib.natives.put(osKey, natives.getString(key));
|
||||
}
|
||||
}
|
||||
|
||||
if (libJson.has("rules")) {
|
||||
JSONObject dummyObj = new JSONObject();
|
||||
dummyObj.put("rules", libJson.getJSONArray("rules"));
|
||||
dummyObj.put("value", "");
|
||||
if (ruleMatches(dummyObj)) {
|
||||
libs.add(lib);
|
||||
}
|
||||
} else {
|
||||
libs.add(lib);
|
||||
}
|
||||
}
|
||||
}
|
||||
return libs;
|
||||
}
|
||||
|
||||
public static class Library {
|
||||
public final String name;
|
||||
public final String artifactPath;
|
||||
public final Map<String, String> natives = new HashMap<>();
|
||||
|
||||
public Library(String name, String artifactPath) {
|
||||
this.name = name;
|
||||
this.artifactPath = artifactPath;
|
||||
}
|
||||
|
||||
public String getSimpleName() {
|
||||
return name.substring(name.indexOf(':') + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -37,5 +37,7 @@ public class LaunchOptions {
|
||||
public void setExtraJvmArgs(List<String> extraJvmArgs) { this.extraJvmArgs = extraJvmArgs; }
|
||||
|
||||
public int getWidth() { return width; }
|
||||
public void setWidth(int width) { this.width = width; }
|
||||
public int getHeight() { return height; }
|
||||
public void setHeight(int height) { this.height = height; }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package me.sashegdev.zernmc.launcher.ui;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import org.jline.terminal.Terminal;
|
||||
import org.jline.terminal.TerminalBuilder;
|
||||
import org.jline.utils.InfoCmp;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
public class ArrowMenu {
|
||||
|
||||
private final String title;
|
||||
private final List<String> options;
|
||||
private int selected = 0;
|
||||
private final Terminal terminal;
|
||||
private final InputStream rawInput;
|
||||
|
||||
private static final int VISIBLE_ITEMS = 7;
|
||||
|
||||
public ArrowMenu(String title, List<String> options) throws IOException {
|
||||
this.title = title;
|
||||
this.options = options;
|
||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows");
|
||||
System.setProperty("jline.terminal", isWindows ? "win" : "unsupported");
|
||||
this.terminal = TerminalBuilder.builder()
|
||||
.system(true)
|
||||
.jna(isWindows)
|
||||
.jansi(true)
|
||||
.encoding(StandardCharsets.UTF_8)
|
||||
.build();
|
||||
this.rawInput = terminal.input();
|
||||
}
|
||||
|
||||
public int show() throws IOException {
|
||||
terminal.enterRawMode();
|
||||
terminal.puts(InfoCmp.Capability.clear_screen);
|
||||
terminal.puts(InfoCmp.Capability.cursor_invisible);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
printPagedMenu();
|
||||
int b = rawInput.read();
|
||||
if (b == -1) continue;
|
||||
|
||||
// w/W/k/K or ц (0xD1 0x86) = up
|
||||
// s/S/j/J or ы (0xD1 0x8B) = down
|
||||
if (b == 'w' || b == 'W' || b == 'k' || b == 'K') {
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
}
|
||||
else if (b == 's' || b == 'S' || b == 'j' || b == 'J') {
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
// ESC sequences: arrows + cyrillic start byte
|
||||
else if (b == 0x1B) {
|
||||
int next = nonBlockingRead();
|
||||
if (next == -1) {
|
||||
return -1;
|
||||
}
|
||||
if (next == 0x5B || next == 0x4F) { // '[' (CSI) or 'O' (SS3)
|
||||
int arrow = nonBlockingRead();
|
||||
if (arrow == 0x41) { // Up
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
} else if (arrow == 0x42) { // Down
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (b == 0xD1) {
|
||||
int second = nonBlockingRead();
|
||||
if (second == 0x86) { // ц
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
} else if (second == 0x8B) { // ы
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
}
|
||||
else if (b == 13 || b == 10) {
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
terminal.puts(InfoCmp.Capability.cursor_visible);
|
||||
terminal.close();
|
||||
}
|
||||
}
|
||||
|
||||
private int nonBlockingRead() throws IOException {
|
||||
long startTime = System.currentTimeMillis();
|
||||
while (System.currentTimeMillis() - startTime < 100) {
|
||||
if (rawInput.available() > 0) {
|
||||
return rawInput.read();
|
||||
}
|
||||
try {
|
||||
Thread.sleep(2);
|
||||
} catch (InterruptedException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void printPagedMenu() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\033[H\033[2J");
|
||||
|
||||
sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n");
|
||||
sb.append(ZAnsi.yellow(title)).append("\n\n");
|
||||
|
||||
int start = Math.max(0, selected - (VISIBLE_ITEMS / 2));
|
||||
int end = Math.min(options.size(), start + VISIBLE_ITEMS);
|
||||
|
||||
if (end - start < VISIBLE_ITEMS && start > 0) {
|
||||
start = Math.max(0, end - VISIBLE_ITEMS);
|
||||
}
|
||||
|
||||
for (int i = start; i < end; i++) {
|
||||
String line = options.get(i);
|
||||
if (i == selected) {
|
||||
sb.append(ZAnsi.selected(line)).append("\n");
|
||||
} else {
|
||||
sb.append(ZAnsi.white(" " + line)).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\n")
|
||||
.append(ZAnsi.white("W/S or \u2191/\u2193 - navigate | Enter - select | Esc - back"));
|
||||
|
||||
System.out.print(sb);
|
||||
System.out.flush();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Properties;
|
||||
|
||||
public class Config {
|
||||
|
||||
private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc");
|
||||
private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties");
|
||||
|
||||
private static final String BUILD_PROFILE = System.getProperty("build.profile", "global");
|
||||
|
||||
private static final Properties props = new Properties();
|
||||
|
||||
private static volatile int maxMemory = 4096;
|
||||
private static volatile String serverUrl = "http://87.120.187.36:1582";
|
||||
private static volatile String lastUsername = "Player";
|
||||
private static volatile int windowWidth = 1280;
|
||||
private static volatile int windowHeight = 720;
|
||||
private static volatile String extraJvmArgs = "";
|
||||
private static volatile String javaPath = "java";
|
||||
private static volatile boolean ramManuallySet = false;
|
||||
private static volatile String locale = "en";
|
||||
private static volatile boolean systemBasedJvm = false;
|
||||
|
||||
static {
|
||||
load();
|
||||
if (!ramManuallySet) {
|
||||
applySmartRamRecommendation();
|
||||
}
|
||||
}
|
||||
|
||||
private static void load() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR);
|
||||
if (Files.exists(CONFIG_FILE)) {
|
||||
try (var is = Files.newInputStream(CONFIG_FILE)) {
|
||||
props.load(is);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
|
||||
} catch (NumberFormatException e) {
|
||||
System.err.println(ZAnsi.yellow("Config: invalid maxMemory value, using default"));
|
||||
}
|
||||
ramManuallySet = Boolean.parseBoolean(props.getProperty("ramManuallySet", "false"));
|
||||
serverUrl = props.getProperty("serverUrl", serverUrl);
|
||||
lastUsername = props.getProperty("lastUsername", lastUsername);
|
||||
try {
|
||||
windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280"));
|
||||
} catch (NumberFormatException e) {
|
||||
System.err.println(ZAnsi.yellow("Config: invalid windowWidth value, using default"));
|
||||
}
|
||||
try {
|
||||
windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720"));
|
||||
} catch (NumberFormatException e) {
|
||||
System.err.println(ZAnsi.yellow("Config: invalid windowHeight value, using default"));
|
||||
}
|
||||
extraJvmArgs = props.getProperty("extraJvmArgs", "");
|
||||
javaPath = props.getProperty("javaPath", "java");
|
||||
locale = props.getProperty("locale", "en");
|
||||
systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false"));
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void save() {
|
||||
try {
|
||||
props.setProperty("maxMemory", String.valueOf(maxMemory));
|
||||
props.setProperty("ramManuallySet", String.valueOf(ramManuallySet));
|
||||
props.setProperty("serverUrl", serverUrl);
|
||||
props.setProperty("lastUsername", lastUsername);
|
||||
props.setProperty("windowWidth", String.valueOf(windowWidth));
|
||||
props.setProperty("windowHeight", String.valueOf(windowHeight));
|
||||
props.setProperty("extraJvmArgs", extraJvmArgs);
|
||||
props.setProperty("javaPath", javaPath);
|
||||
props.setProperty("locale", locale);
|
||||
props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm));
|
||||
|
||||
try (var os = Files.newOutputStream(CONFIG_FILE)) {
|
||||
props.store(os, "ZernMC Launcher Configuration");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to save config: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void applySmartRamRecommendation() {
|
||||
long totalRamMB = getTotalSystemRamMB();
|
||||
if (totalRamMB <= 0) return;
|
||||
|
||||
long recommended;
|
||||
if (totalRamMB <= 8192) {
|
||||
recommended = 2560;
|
||||
} else if (totalRamMB <= 12288) {
|
||||
recommended = 3072;
|
||||
} else if (totalRamMB <= 16384) {
|
||||
recommended = 4096;
|
||||
} else {
|
||||
recommended = 5120;
|
||||
}
|
||||
|
||||
if (Math.abs(maxMemory - recommended) > 512) {
|
||||
maxMemory = (int) recommended;
|
||||
save();
|
||||
System.out.println(ZAnsi.cyan("Auto-recommended RAM: " + maxMemory + " MB"));
|
||||
}
|
||||
}
|
||||
|
||||
public static void resetRamRecommendation() {
|
||||
ramManuallySet = false;
|
||||
applySmartRamRecommendation();
|
||||
}
|
||||
|
||||
private static long getTotalSystemRamMB() {
|
||||
try {
|
||||
Class<?> beanClass = Class.forName("com.sun.management.OperatingSystemMXBean");
|
||||
java.lang.management.OperatingSystemMXBean osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
|
||||
if (beanClass.isInstance(osBean)) {
|
||||
Object totalBytes = beanClass.getMethod("getTotalMemorySize").invoke(osBean);
|
||||
return ((Number) totalBytes).longValue() / (1024 * 1024);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int getMaxMemory() {
|
||||
return maxMemory;
|
||||
}
|
||||
|
||||
public static boolean isZernMCBuild() {
|
||||
return "zernmc".equalsIgnoreCase(BUILD_PROFILE);
|
||||
}
|
||||
|
||||
public static boolean isGlobalBuild() {
|
||||
return !isZernMCBuild();
|
||||
}
|
||||
|
||||
public static void setMaxMemory(int memory) {
|
||||
if (memory < 1024) memory = 1536;
|
||||
if (memory > 32768) memory = 32768;
|
||||
|
||||
maxMemory = memory;
|
||||
ramManuallySet = true;
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getServerUrl() {
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
public static String getLastUsername() {
|
||||
return lastUsername;
|
||||
}
|
||||
|
||||
public static void setLastUsername(String username) {
|
||||
lastUsername = username;
|
||||
save();
|
||||
}
|
||||
|
||||
public static Path getInstancesDir() {
|
||||
return CONFIG_DIR.resolve("instances");
|
||||
}
|
||||
|
||||
public static Path getJreDir() {
|
||||
return CONFIG_DIR.resolve("jre");
|
||||
}
|
||||
|
||||
public static Path getConfigDir() {
|
||||
return CONFIG_DIR;
|
||||
}
|
||||
|
||||
public static int getWindowWidth() {
|
||||
return windowWidth;
|
||||
}
|
||||
|
||||
public static void setWindowWidth(int width) {
|
||||
windowWidth = Math.max(640, width);
|
||||
save();
|
||||
}
|
||||
|
||||
public static int getWindowHeight() {
|
||||
return windowHeight;
|
||||
}
|
||||
|
||||
public static void setWindowHeight(int height) {
|
||||
windowHeight = Math.max(480, height);
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getExtraJvmArgs() {
|
||||
return extraJvmArgs;
|
||||
}
|
||||
|
||||
public static void setExtraJvmArgs(String args) {
|
||||
extraJvmArgs = args != null ? args : "";
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getJavaPath() {
|
||||
return javaPath;
|
||||
}
|
||||
|
||||
public static void setJavaPath(String path) {
|
||||
javaPath = path != null && !path.isEmpty() ? path : "java";
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public static void setLocale(String lang) {
|
||||
if (lang != null && (lang.equals("en") || lang.equals("ru"))) {
|
||||
locale = lang;
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isSystemBasedJvm() {
|
||||
return systemBasedJvm;
|
||||
}
|
||||
|
||||
public static void setSystemBasedJvm(boolean enabled) {
|
||||
systemBasedJvm = enabled;
|
||||
save();
|
||||
}
|
||||
|
||||
public static int getSystemCpuCores() {
|
||||
return Runtime.getRuntime().availableProcessors();
|
||||
}
|
||||
|
||||
public static long getSystemTotalRamMB() {
|
||||
long totalMb = getTotalSystemRamMB();
|
||||
if (totalMb > 0) return totalMb;
|
||||
return Runtime.getRuntime().maxMemory() / (1024 * 1024);
|
||||
}
|
||||
|
||||
public static String getSystemJvmFlags() {
|
||||
int cores = getSystemCpuCores();
|
||||
long ramMB = getSystemTotalRamMB();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("-XX:ParallelGCThreads=").append(Math.max(1, cores));
|
||||
sb.append(" -XX:ConcGCThreads=").append(Math.max(1, cores / 2));
|
||||
sb.append(" -XX:+AlwaysPreTouch");
|
||||
if (ramMB >= 8192) {
|
||||
sb.append(" -XX:+UseZGC");
|
||||
sb.append(" -XX:ZAllocationSpikeTolerance=2.0");
|
||||
} else {
|
||||
sb.append(" -XX:+UseG1GC");
|
||||
sb.append(" -XX:MaxGCPauseMillis=50");
|
||||
sb.append(" -XX:G1HeapRegionSize=16M");
|
||||
}
|
||||
sb.append(" -Xss4M");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String getRamInfo() {
|
||||
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
|
||||
return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB";
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -10,10 +10,9 @@ public class ConsoleUtils {
|
||||
}
|
||||
|
||||
public static void pause() {
|
||||
System.out.print(ZAnsi.white("\nНажмите Enter для продолжения..."));
|
||||
System.out.print(ZAnsi.white("\nPress Enter to continue..."));
|
||||
try {
|
||||
System.in.read();
|
||||
// Очищаем буфер ввода
|
||||
while (System.in.available() > 0) {
|
||||
System.in.read();
|
||||
}
|
||||
@@ -36,4 +35,4 @@ public class ConsoleUtils {
|
||||
public static void separator() {
|
||||
System.out.println(ZAnsi.white("────────────────────────────────────────────────────────────"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-24
@@ -3,22 +3,20 @@ package me.sashegdev.zernmc.launcher.utils;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Scanner;
|
||||
|
||||
/**
|
||||
* Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu
|
||||
*/
|
||||
public class Input {
|
||||
|
||||
// Используем UTF-8 явно — это помогает на Windows
|
||||
private static final Scanner scanner = new Scanner(System.in, "UTF-8");
|
||||
private static final Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8);
|
||||
|
||||
public static String readLine() {
|
||||
return scanner.nextLine().trim();
|
||||
}
|
||||
|
||||
public static String readLine(String prompt) {
|
||||
flushInput();
|
||||
System.out.print(prompt);
|
||||
return scanner.nextLine().trim();
|
||||
}
|
||||
@@ -29,7 +27,7 @@ public class Input {
|
||||
System.out.print(prompt);
|
||||
return Integer.parseInt(scanner.nextLine().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
System.out.println(ZAnsi.brightRed("Некорректное число. Попробуйте ещё раз."));
|
||||
System.out.println(ZAnsi.brightRed("Invalid number. Try again."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,43 +38,41 @@ public class Input {
|
||||
if (value >= min && value <= max) {
|
||||
return value;
|
||||
}
|
||||
System.out.println(ZAnsi.brightRed("Значение должно быть от " + min + " до " + max + "."));
|
||||
System.out.println(ZAnsi.brightRed("Value must be between " + min + " and " + max + "."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Новый confirm через ArrowMenu
|
||||
* @throws IOException
|
||||
*/
|
||||
public static boolean confirm(String question) throws IOException {
|
||||
ConsoleUtils.clearScreen(); // опционально, можно убрать
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Да",
|
||||
"Нет"
|
||||
"Yes",
|
||||
"No"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu(question, options);
|
||||
int choice = menu.show();
|
||||
|
||||
return choice == 0; // 0 = "Да"
|
||||
return choice == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Альтернативный confirm без очистки экрана
|
||||
* @throws IOException
|
||||
*/
|
||||
public static boolean confirmInline(String question) throws IOException {
|
||||
List<String> options = List.of("Да", "Нет");
|
||||
List<String> options = List.of("Yes", "No");
|
||||
ArrowMenu menu = new ArrowMenu(question, options);
|
||||
int choice = menu.show();
|
||||
return choice == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрытие сканнера (вызывать при выходе из программы, если нужно)
|
||||
*/
|
||||
public static void close() {
|
||||
scanner.close();
|
||||
}
|
||||
}
|
||||
|
||||
public static void flushInput() {
|
||||
try {
|
||||
while (System.in.available() > 0) {
|
||||
System.in.read();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
public class LauncherLogger {
|
||||
|
||||
private static Path logFile;
|
||||
private static boolean initialized = false;
|
||||
private static final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
public static synchronized void init() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
try {
|
||||
Path logsDir = Paths.get(System.getProperty("user.home"), ".zernmc", "logs");
|
||||
Files.createDirectories(logsDir);
|
||||
logFile = logsDir.resolve("launcher.log");
|
||||
|
||||
Files.writeString(logFile,
|
||||
"=== Launcher Log " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " ===\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
System.err.println("[LauncherLogger] init error: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public static Path getLogFile() {
|
||||
return logFile;
|
||||
}
|
||||
|
||||
public static void info(String msg) {
|
||||
write("INFO", msg, null);
|
||||
}
|
||||
|
||||
public static void warn(String msg) {
|
||||
write("WARN", msg, null);
|
||||
}
|
||||
|
||||
public static void error(String msg) {
|
||||
write("ERROR", msg, null);
|
||||
}
|
||||
|
||||
public static void error(String msg, Throwable t) {
|
||||
write("ERROR", msg, t);
|
||||
}
|
||||
|
||||
public static void debug(String msg) {
|
||||
write("DEBUG", msg, null);
|
||||
}
|
||||
|
||||
private static void write(String level, String msg, Throwable t) {
|
||||
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"));
|
||||
String line = "[" + ts + "] [" + level + "] " + msg;
|
||||
|
||||
System.out.println(line);
|
||||
if (t != null) {
|
||||
StringWriter sw = new StringWriter();
|
||||
PrintWriter pw = new PrintWriter(sw);
|
||||
t.printStackTrace(pw);
|
||||
pw.flush();
|
||||
System.err.print(sw.toString());
|
||||
}
|
||||
|
||||
if (logFile != null) {
|
||||
lock.lock();
|
||||
try {
|
||||
Files.writeString(logFile, line + "\n", StandardOpenOption.APPEND);
|
||||
if (t != null) {
|
||||
StringWriter sw = new StringWriter();
|
||||
PrintWriter pw = new PrintWriter(sw);
|
||||
t.printStackTrace(pw);
|
||||
pw.flush();
|
||||
Files.writeString(logFile, sw.toString(), StandardOpenOption.APPEND);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println("[LauncherLogger] write error: " + e.getMessage());
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-9
@@ -6,11 +6,20 @@ public class ProgressBar {
|
||||
|
||||
private static final int BAR_LENGTH = 40;
|
||||
private static final DecimalFormat DF = new DecimalFormat("#.##");
|
||||
|
||||
private static String currentLabel = "";
|
||||
private static long currentTotal = 0;
|
||||
|
||||
/**
|
||||
* Прогресс по количеству файлов (для библиотек и общего прогресса)
|
||||
*/
|
||||
public static void show(String label, long current, long total, String unit) {
|
||||
currentLabel = label;
|
||||
currentTotal = total;
|
||||
|
||||
try {
|
||||
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
|
||||
setProgress.invoke(null, label, (int) current, (int) total);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (total <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
return;
|
||||
@@ -27,10 +36,16 @@ public class ProgressBar {
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогресс по байтам для одного файла (реальный прогресс)
|
||||
*/
|
||||
public static void showDownload(String label, long downloaded, long totalBytes) {
|
||||
currentLabel = label;
|
||||
currentTotal = totalBytes;
|
||||
|
||||
try {
|
||||
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
|
||||
setProgress.invoke(null, label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (totalBytes <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
return;
|
||||
@@ -53,8 +68,16 @@ public class ProgressBar {
|
||||
}
|
||||
|
||||
public static void showAnimated(String label, long current, long total, String unit) {
|
||||
currentLabel = label;
|
||||
currentTotal = total;
|
||||
|
||||
try {
|
||||
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
|
||||
setProgress.invoke(null, label, (int) current, (int) (total > 0 ? total : 100));
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (total <= 0) {
|
||||
// Анимация для неизвестного размера
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (current / 1024) % 4;
|
||||
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
|
||||
@@ -64,7 +87,13 @@ public class ProgressBar {
|
||||
}
|
||||
|
||||
public static void finish(String message) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(message + " завершено ✓"));
|
||||
try {
|
||||
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||
java.lang.reflect.Method setInProgress = jfxClass.getMethod("setInstallInProgress", boolean.class);
|
||||
setInProgress.invoke(null, false);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓"));
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
@@ -78,4 +107,4 @@ public class ProgressBar {
|
||||
if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB";
|
||||
return DF.format(bytes / (1024.0 * 1024)) + " MB";
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-10
@@ -33,24 +33,42 @@ public class Version {
|
||||
|
||||
public static boolean isNewer(String current, String server) {
|
||||
if (current == null || server == null) return false;
|
||||
|
||||
current = current.replace("-SNAPSHOT", "").trim();
|
||||
server = server.replace("-SNAPSHOT", "").trim();
|
||||
|
||||
|
||||
// Нормализуем версии - убираем суффиксы типа -any, -alpha, -beta, -SNAPSHOT
|
||||
current = normalizeVersion(current);
|
||||
server = normalizeVersion(server);
|
||||
|
||||
if (current.equals(server)) return false;
|
||||
|
||||
|
||||
String[] cParts = current.split("\\.");
|
||||
String[] sParts = server.split("\\.");
|
||||
|
||||
|
||||
int max = Math.max(cParts.length, sParts.length);
|
||||
|
||||
|
||||
for (int i = 0; i < max; i++) {
|
||||
int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0;
|
||||
int s = i < sParts.length ? Integer.parseInt(sParts[i]) : 0;
|
||||
|
||||
int c = i < cParts.length ? parseVersionPart(cParts[i]) : 0;
|
||||
int s = i < sParts.length ? parseVersionPart(sParts[i]) : 0;
|
||||
|
||||
if (s > c) return true;
|
||||
if (s < c) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalizeVersion(String version) {
|
||||
if (version == null) return "0.0.0";
|
||||
|
||||
// Убираем суффиксы: -any, -alpha1, -beta2, -SNAPSHOT, -rc1 и т.д.
|
||||
return version.split("-")[0].split("\\+")[0].trim();
|
||||
}
|
||||
|
||||
private static int parseVersionPart(String part) {
|
||||
try {
|
||||
// Убираем всё, что не является цифрой (на случай если суффикс остался)
|
||||
String numeric = part.replaceAll("[^0-9]", "");
|
||||
return numeric.isEmpty() ? 0 : Integer.parseInt(numeric);
|
||||
} catch (Exception e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import org.fusesource.jansi.Ansi;
|
||||
import org.fusesource.jansi.AnsiConsole;
|
||||
|
||||
public class ZAnsi {
|
||||
|
||||
//поддержка ANSI епта
|
||||
public static void install() {
|
||||
AnsiConsole.systemInstall();
|
||||
}
|
||||
|
||||
public static void uninstall() {
|
||||
AnsiConsole.systemUninstall();
|
||||
}
|
||||
|
||||
// === Основные цвета ===
|
||||
public static String green(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.GREEN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightGreen(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.GREEN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String cyan(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightCyan(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String yellow(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightYellow(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String red(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.RED).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightRed(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.RED).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String blue(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightBlue(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.BLUE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String magenta(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.MAGENTA).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightMagenta(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.MAGENTA).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// Пурпурный как brightPurple (используем magenta)
|
||||
public static String purple(String text) {
|
||||
return brightMagenta(text);
|
||||
}
|
||||
|
||||
public static String brightPurple(String text) {
|
||||
return brightMagenta(text);
|
||||
}
|
||||
|
||||
public static String white(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightWhite(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String black(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.BLACK).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// === Фоновые цвета ===
|
||||
public static String bgGreen(String text) {
|
||||
return Ansi.ansi().bg(Ansi.Color.GREEN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String bgRed(String text) {
|
||||
return Ansi.ansi().bg(Ansi.Color.RED).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String bgYellow(String text) {
|
||||
return Ansi.ansi().bg(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String bgBlue(String text) {
|
||||
return Ansi.ansi().bg(Ansi.Color.BLUE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// === Стили ===
|
||||
public static String bold(String text) {
|
||||
return Ansi.ansi().bold().a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String reset() {
|
||||
return Ansi.ansi().reset().toString();
|
||||
}
|
||||
|
||||
// === Комбинированные удобные методы ===
|
||||
public static String header(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String success(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.GREEN).bold().a("[✓] " + text).reset().toString();
|
||||
}
|
||||
|
||||
public static String error(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.RED).bold().a("[✗] " + text).reset().toString();
|
||||
}
|
||||
|
||||
public static String warning(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).bold().a("[!] " + text).reset().toString();
|
||||
}
|
||||
|
||||
public static String info(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a("[i] " + text).reset().toString();
|
||||
}
|
||||
|
||||
public static String selected(String text) {
|
||||
return Ansi.ansi()
|
||||
.bgBright(Ansi.Color.WHITE)
|
||||
.fg(Ansi.Color.BLACK)
|
||||
.bold()
|
||||
.a(" > " + text + " ")
|
||||
.reset()
|
||||
.toString();
|
||||
}
|
||||
|
||||
public static String dim(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.BLACK).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// === Цветной текст для ролей ===
|
||||
public static String roleUser(String text) {
|
||||
return white(text);
|
||||
}
|
||||
|
||||
public static String rolePassHolder(String text) {
|
||||
return brightGreen(text);
|
||||
}
|
||||
|
||||
public static String roleModerator(String text) {
|
||||
return brightBlue(text);
|
||||
}
|
||||
|
||||
public static String roleElder(String text) {
|
||||
return brightPurple(text);
|
||||
}
|
||||
|
||||
public static String roleCreator(String text) {
|
||||
return brightRed(text);
|
||||
}
|
||||
|
||||
// === Очистка экрана ===
|
||||
public static String clearScreen() {
|
||||
return Ansi.ansi().eraseScreen().cursor(1, 1).toString();
|
||||
}
|
||||
|
||||
// === Прогресс бар символы ===
|
||||
public static String progressChar() {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a("█").reset().toString();
|
||||
}
|
||||
|
||||
public static String progressEmpty() {
|
||||
return Ansi.ansi().fg(Ansi.Color.BLACK).a("░").reset().toString();
|
||||
}
|
||||
}
|
||||
+79
-79
@@ -3,6 +3,8 @@ package me.sashegdev.zernmc.launcher.utils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
@@ -25,20 +27,27 @@ public class ZHttpClient {
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.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 proxyTested = new AtomicBoolean(false);
|
||||
|
||||
// Умное проксирование по сервисам
|
||||
public static void setBaseUrl(String url) {
|
||||
BASE_URL = url;
|
||||
}
|
||||
|
||||
public static String getBaseUrl() {
|
||||
return BASE_URL;
|
||||
}
|
||||
|
||||
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_MAVEN("https://maven.fabricmc.net", false),
|
||||
MOJANG_META("https://piston-meta.mojang.com", false),
|
||||
MOJANG_RESOURCES("https://resources.download.minecraft.net", false),
|
||||
FORGE_MAVEN("https://maven.minecraftforge.net", false),
|
||||
NEOFORGE_MAVEN("https://maven.neoforged.net", false),
|
||||
GOOGLE("https://google.com", false),
|
||||
CLOUDFLARE("https://cloudflare.com", false);
|
||||
|
||||
@@ -54,17 +63,15 @@ public class ZHttpClient {
|
||||
public boolean isAlwaysDirect() { return alwaysDirect; }
|
||||
}
|
||||
|
||||
// Статусы сервисов
|
||||
private static final Map<ServiceType, Boolean> serviceProxyMode = new ConcurrentHashMap<>();
|
||||
private static final Map<ServiceType, Integer> serviceFailCount = new ConcurrentHashMap<>();
|
||||
private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>();
|
||||
private static final Map<ServiceType, Boolean> serviceHealthy = new ConcurrentHashMap<>();
|
||||
|
||||
private static final int MAX_FAILS_BEFORE_PROXY = 2;
|
||||
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута
|
||||
private static final long CHECK_TIMEOUT_MS = 7000; // 7 секунд на проверку
|
||||
private static final long HEALTH_CHECK_INTERVAL_MS = 60000;
|
||||
private static final long CHECK_TIMEOUT_MS = 7000;
|
||||
|
||||
// Статистика
|
||||
private static int directSuccessCount = 0;
|
||||
private static int proxySuccessCount = 0;
|
||||
private static int directFailCount = 0;
|
||||
@@ -77,14 +84,13 @@ public class ZHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вызывать один раз при запуске лаунчера
|
||||
*/
|
||||
public static void checkAllServicesOnStartup() {
|
||||
checkAllServicesOnStartup(false);
|
||||
}
|
||||
|
||||
public static void checkAllServicesOnStartup(boolean verbose) {
|
||||
if (proxyTested.get()) return;
|
||||
|
||||
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
|
||||
|
||||
List<ServiceType> servicesToCheck = List.of(
|
||||
ServiceType.ZERN_SERVER,
|
||||
ServiceType.GOOGLE,
|
||||
@@ -92,7 +98,8 @@ public class ZHttpClient {
|
||||
ServiceType.FABRIC_MAVEN,
|
||||
ServiceType.MOJANG_META,
|
||||
ServiceType.MOJANG_RESOURCES,
|
||||
ServiceType.FORGE_MAVEN
|
||||
ServiceType.FORGE_MAVEN,
|
||||
ServiceType.NEOFORGE_MAVEN
|
||||
);
|
||||
|
||||
for (ServiceType service : servicesToCheck) {
|
||||
@@ -100,14 +107,20 @@ public class ZHttpClient {
|
||||
serviceHealthy.put(service, isHealthy);
|
||||
|
||||
if (service.isAlwaysDirect()) {
|
||||
System.out.println(isHealthy ?
|
||||
ZAnsi.green(" " + service.name() + " - OK") :
|
||||
ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)"));
|
||||
if (verbose) {
|
||||
System.out.println(isHealthy ?
|
||||
ZAnsi.green(" " + service.name() + " - OK") :
|
||||
ZAnsi.red(" " + service.name() + " - NOT ACCESSIBLE (critical!)"));
|
||||
}
|
||||
} else {
|
||||
if (isHealthy) {
|
||||
System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает"));
|
||||
if (verbose) {
|
||||
System.out.println(ZAnsi.green(" " + service.name() + " - direct connection works"));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси"));
|
||||
if (verbose) {
|
||||
System.out.println(ZAnsi.yellow(" " + service.name() + " - NOT ACCESSIBLE, proxy will be used"));
|
||||
}
|
||||
serviceProxyMode.put(service, true);
|
||||
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
|
||||
}
|
||||
@@ -115,30 +128,31 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
|
||||
System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!"));
|
||||
if (verbose) {
|
||||
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
|
||||
}
|
||||
}
|
||||
|
||||
proxyTested.set(true);
|
||||
startHealthCheckThread();
|
||||
printStats();
|
||||
if (verbose) {
|
||||
startHealthCheckThread();
|
||||
printStats();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки)
|
||||
*/
|
||||
public static void forceCheckMojangServices() {
|
||||
System.out.println(ZAnsi.cyan("Принудительная проверка Mojang сервисов..."));
|
||||
System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
|
||||
|
||||
for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) {
|
||||
boolean healthy = checkServiceHealth(service);
|
||||
serviceHealthy.put(service, healthy);
|
||||
|
||||
if (healthy) {
|
||||
System.out.println(ZAnsi.green(" " + service.name() + " доступен напрямую"));
|
||||
System.out.println(ZAnsi.green(" " + service.name() + " accessible directly"));
|
||||
serviceProxyMode.put(service, false);
|
||||
serviceFailCount.put(service, 0);
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" " + service.name() + " недоступен → прокси режим активирован"));
|
||||
System.out.println(ZAnsi.yellow(" " + service.name() + " not accessible -> proxy mode activated"));
|
||||
serviceProxyMode.put(service, true);
|
||||
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
|
||||
}
|
||||
@@ -149,9 +163,6 @@ public class ZHttpClient {
|
||||
return checkDirectConnection(service.getBaseUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Улучшенная проверка прямого подключения
|
||||
*/
|
||||
private static boolean checkDirectConnection(String baseUrl) {
|
||||
String testUrl = baseUrl;
|
||||
|
||||
@@ -171,7 +182,7 @@ public class ZHttpClient {
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
int code = response.statusCode();
|
||||
return code == 200 || code == 404; // 404 для ресурсов — нормально
|
||||
return code == 200 || code == 404;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
@@ -202,7 +213,7 @@ public class ZHttpClient {
|
||||
if (isHealthy && serviceProxyMode.get(service)) {
|
||||
serviceProxyMode.put(service, false);
|
||||
serviceFailCount.put(service, 0);
|
||||
System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение"));
|
||||
System.out.println(ZAnsi.green("[NET] " + service.name() + " restored, switched to direct connection"));
|
||||
} else if (!isHealthy && !serviceProxyMode.get(service)) {
|
||||
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
|
||||
serviceFailCount.put(service, fails);
|
||||
@@ -210,7 +221,7 @@ public class ZHttpClient {
|
||||
|
||||
if (fails >= MAX_FAILS_BEFORE_PROXY) {
|
||||
serviceProxyMode.put(service, true);
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " недоступен, включен прокси режим"));
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " unavailable, proxy mode enabled"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +234,7 @@ public class ZHttpClient {
|
||||
return ServiceType.MOJANG_META;
|
||||
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.neoforged.net")) return ServiceType.NEOFORGE_MAVEN;
|
||||
if (url.contains("google.com")) return ServiceType.GOOGLE;
|
||||
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
|
||||
return null;
|
||||
@@ -260,14 +272,11 @@ public class ZHttpClient {
|
||||
|
||||
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
|
||||
serviceProxyMode.put(service, true);
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " заблокирован, переключаемся на прокси"));
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " blocked, switching to proxy"));
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Универсальный GET с умным прокси + автоматическим fallback
|
||||
*/
|
||||
|
||||
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
|
||||
// Попытка прямого подключения
|
||||
if (!shouldUseProxyForUrl(url)) {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
@@ -292,11 +301,9 @@ public class ZHttpClient {
|
||||
directFailCount++;
|
||||
markServiceAsBlocked(url);
|
||||
}
|
||||
// Если ошибка соединения — пробуем через прокси
|
||||
}
|
||||
}
|
||||
|
||||
// Через прокси
|
||||
try {
|
||||
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
||||
String proxyUrl = BASE_URL + "/download?url=" + encodedUrl;
|
||||
@@ -318,13 +325,10 @@ public class ZHttpClient {
|
||||
return response.body();
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Не удалось получить данные ни напрямую, ни через прокси: " + e.getMessage(), e);
|
||||
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание файла с умным прокси + fallback
|
||||
*/
|
||||
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
|
||||
if (!shouldUseProxyForUrl(url)) {
|
||||
try {
|
||||
@@ -346,11 +350,9 @@ public class ZHttpClient {
|
||||
directFailCount++;
|
||||
markServiceAsBlocked(url);
|
||||
}
|
||||
// fallback на прокси ниже
|
||||
}
|
||||
}
|
||||
|
||||
// Скачивание через прокси
|
||||
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
||||
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
|
||||
|
||||
@@ -370,23 +372,24 @@ public class ZHttpClient {
|
||||
proxySuccessCount++;
|
||||
}
|
||||
|
||||
// ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ======================
|
||||
|
||||
public static String get(String endpoint) throws IOException, InterruptedException {
|
||||
checkAllServicesOnStartup();
|
||||
|
||||
if (useProxyMode.get()) {
|
||||
return proxyGet(endpoint);
|
||||
}
|
||||
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(BASE_URL + endpoint))
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||
.GET()
|
||||
.build();
|
||||
.GET();
|
||||
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken != null && !accessToken.equals("0")) {
|
||||
requestBuilder.header("Authorization", "Bearer " + accessToken);
|
||||
}
|
||||
|
||||
HttpRequest request = requestBuilder.build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
@@ -401,28 +404,31 @@ public class ZHttpClient {
|
||||
|
||||
private static String proxyGet(String endpoint) throws IOException {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(BASE_URL + "/proxy" + endpoint))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
.GET();
|
||||
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken != null && !accessToken.equals("0")) {
|
||||
requestBuilder.header("Authorization", "Bearer " + accessToken);
|
||||
}
|
||||
|
||||
HttpRequest request = requestBuilder.build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
|
||||
proxySuccessCount++;
|
||||
return response.body();
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Ошибка прокси: " + e.getMessage(), e);
|
||||
throw new IOException("Proxy error: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ======================
|
||||
|
||||
public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException {
|
||||
String url = "https://meta.fabricmc.net/v2/versions/loader";
|
||||
return parseFabricVersionsFromJson(getWithSmartProxy(url));
|
||||
@@ -477,19 +483,13 @@ public class ZHttpClient {
|
||||
return versions;
|
||||
}
|
||||
|
||||
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
|
||||
|
||||
public static String getBaseUrl() {
|
||||
return BASE_URL;
|
||||
}
|
||||
|
||||
public static String getLauncherVersionInfo() throws IOException, InterruptedException {
|
||||
return get("/launcher/version");
|
||||
}
|
||||
|
||||
public static void forceProxyMode() {
|
||||
useProxyMode.set(true);
|
||||
System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим"));
|
||||
System.out.println(ZAnsi.yellow("Global proxy mode forced on"));
|
||||
}
|
||||
|
||||
public static void disableProxyMode() {
|
||||
@@ -500,7 +500,7 @@ public class ZHttpClient {
|
||||
serviceFailCount.put(type, 0);
|
||||
}
|
||||
}
|
||||
System.out.println(ZAnsi.green("Режим прокси выключен"));
|
||||
System.out.println(ZAnsi.green("Proxy mode disabled"));
|
||||
}
|
||||
|
||||
public static boolean isProxyMode() {
|
||||
@@ -508,18 +508,18 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
public static void printStats() {
|
||||
System.out.println(ZAnsi.cyan("\n=== Статистика сети ==="));
|
||||
System.out.println(ZAnsi.white("Глобальный прокси: ") + (useProxyMode.get() ? "ВКЛ" : "ВЫКЛ"));
|
||||
System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount);
|
||||
System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount);
|
||||
System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount);
|
||||
System.out.println(ZAnsi.cyan("\n=== Network Stats ==="));
|
||||
System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF"));
|
||||
System.out.println(ZAnsi.white("Direct successes: ") + directSuccessCount);
|
||||
System.out.println(ZAnsi.white("Direct failures: ") + directFailCount);
|
||||
System.out.println(ZAnsi.white("Proxy successes: ") + proxySuccessCount);
|
||||
|
||||
System.out.println(ZAnsi.cyan("\nСтатус сервисов:"));
|
||||
System.out.println(ZAnsi.cyan("\nService status:"));
|
||||
for (ServiceType type : ServiceType.values()) {
|
||||
if (type.isAlwaysDirect()) continue;
|
||||
String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО");
|
||||
String status = serviceProxyMode.get(type) ? ZAnsi.red("PROXY") : ZAnsi.green("DIRECT");
|
||||
String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]");
|
||||
System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZernMC Launcher</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="bg-canvas"></canvas>
|
||||
|
||||
<div id="app">
|
||||
<!-- Login Screen -->
|
||||
<div id="login-screen" class="screen">
|
||||
<div class="login-container">
|
||||
<div class="login-brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none">
|
||||
<rect width="56" height="56" rx="14" fill="url(#brandGrad)"/>
|
||||
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
|
||||
<defs>
|
||||
<linearGradient id="brandGrad" x1="0" y1="0" x2="56" y2="56">
|
||||
<stop offset="0%" stop-color="#e94560"/>
|
||||
<stop offset="100%" stop-color="#ff6b6b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="brand-title">ZernMC</h1>
|
||||
<p class="brand-sub">Launcher <span id="version" data-i18n="version">1.0.9</span></p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="login-form">
|
||||
<div class="field">
|
||||
<input type="text" id="username" placeholder="Username" data-i18n-placeholder="login.username" autocomplete="username" required>
|
||||
<label for="username" data-i18n="login.username">Username</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input type="password" id="password" placeholder="Password" data-i18n-placeholder="login.password" autocomplete="current-password" required>
|
||||
<label for="password" data-i18n="login.password">Password</label>
|
||||
</div>
|
||||
<p id="login-error" class="error-msg hidden"></p>
|
||||
<button type="submit" class="btn-primary" id="login-btn">
|
||||
<span class="btn-label" data-i18n="login.title">Sign In</span>
|
||||
<div class="spinner hidden"></div>
|
||||
</button>
|
||||
<p class="login-hint" data-i18n="login.hint">New account will be created automatically on first login</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loading-overlay" class="overlay hidden">
|
||||
<div class="loader-ring"></div>
|
||||
<p class="loader-text" data-i18n="loading.text">Loading...</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Screen -->
|
||||
<div id="main-screen" class="screen hidden">
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-top">
|
||||
<div class="sidebar-brand">
|
||||
<svg width="32" height="32" viewBox="0 0 56 56" fill="none">
|
||||
<rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/>
|
||||
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
|
||||
<defs>
|
||||
<linearGradient id="brandGrad2" x1="0" y1="0" x2="56" y2="56">
|
||||
<stop offset="0%" stop-color="#e94560"/>
|
||||
<stop offset="100%" stop-color="#ff6b6b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="sidebar-brand-text">
|
||||
<span class="sidebar-brand-name">ZernMC</span>
|
||||
<span class="sidebar-brand-ver">v<span id="header-version">1.0.9</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<button class="nav-btn active" data-view="packs" title="Packs">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
|
||||
<span data-i18n="nav.packs">Packs</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-view="news" title="News">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
|
||||
<span data-i18n="nav.news">News</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-view="friends" title="Friends">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span data-i18n="nav.friends">Friends</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
|
||||
</div>
|
||||
<div id="server-packs-list" class="pack-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section" id="local-packs-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
|
||||
<button class="btn-icon" id="add-pack-btn" title="Add pack">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="local-packs-list" class="pack-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<div class="user-card">
|
||||
<div class="user-avatar" id="user-avatar">Z</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name" id="username-display">Player</span>
|
||||
<span class="user-badges">
|
||||
<span id="account-status" class="badge badge-free">FREE</span>
|
||||
<span id="account-role" class="badge badge-role hidden"></span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn-icon" id="settings-btn" title="Settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="logout-btn" title="Log out">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<!-- Packs View -->
|
||||
<div id="view-packs" class="view active">
|
||||
<div class="view-header">
|
||||
<div>
|
||||
<h2 class="view-title" id="selected-pack-title">Select a pack</h2>
|
||||
<p class="view-subtitle" id="selected-pack-meta">Choose a pack from the sidebar to get started</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
<button id="update-btn" class="btn-secondary hidden">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
<span data-i18n="pack.update">Update</span>
|
||||
</button>
|
||||
<button id="delete-pack-btn" class="btn-secondary btn-danger hidden">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
<span data-i18n="pack.delete">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pack-detail" id="pack-detail">
|
||||
<div class="pack-empty" id="pack-empty-state">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
<h3 data-i18n="pack.emptyState.title">No pack selected</h3>
|
||||
<p data-i18n="pack.emptyState.desc">Select a pack from the sidebar or add a new one</p>
|
||||
</div>
|
||||
<div id="pack-detail-content" class="pack-detail-content hidden">
|
||||
<div class="pack-hero">
|
||||
<div class="pack-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="detail-name" class="detail-name">pack</h3>
|
||||
<div class="detail-tags">
|
||||
<span class="tag tag-mc" id="detail-mc">1.21</span>
|
||||
<span class="tag tag-loader" id="detail-loader">fabric</span>
|
||||
<span class="tag tag-server hidden" id="detail-server">v1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pack-stats">
|
||||
<div class="stat"><span class="stat-value" id="detail-loader-ver">-</span><span class="stat-label" data-i18n="stat.loaderVer">Loader Ver</span></div>
|
||||
<div class="stat"><span class="stat-value" id="detail-files">0</span><span class="stat-label" data-i18n="stat.files">Files</span></div>
|
||||
<div class="stat"><span class="stat-value" id="detail-size">-</span><span class="stat-label" data-i18n="stat.size">Size</span></div>
|
||||
<div class="stat"><span class="stat-value" id="detail-playtime">-</span><span class="stat-label" data-i18n="playtime.label">Playtime</span></div>
|
||||
</div>
|
||||
<div id="pack-description" class="pack-description">
|
||||
<p id="pack-description-text" class="pack-description-text" data-i18n="pack.description.loading">Loading description...</p>
|
||||
<div id="pack-gallery" class="pack-gallery">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="play-bar" id="play-bar">
|
||||
<div class="play-bar-info">
|
||||
<span id="play-bar-name">Select a pack</span>
|
||||
</div>
|
||||
<button id="play-btn" class="btn-play" disabled>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
<span data-i18n="playBar.play">Play</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News View -->
|
||||
<div id="view-news" class="view">
|
||||
<div class="view-header">
|
||||
<h2 class="view-title" data-i18n="news.title">News</h2>
|
||||
</div>
|
||||
<div id="news-grid" class="news-grid">
|
||||
<div class="news-loading" data-i18n="news.loading">Loading news...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Friends View -->
|
||||
<div id="view-friends" class="view">
|
||||
<div class="view-header">
|
||||
<h2 class="view-title" data-i18n="friends.title">Friends</h2>
|
||||
<div class="view-actions">
|
||||
<button id="friends-add-btn" class="btn-primary btn-sm" onclick="app.showAddFriend()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<span data-i18n="friends.add">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="friends-search">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="friends-search-input" placeholder="Search friends..." data-i18n-placeholder="friends.search" oninput="app.filterFriends()">
|
||||
</div>
|
||||
<div id="friends-list" class="friends-list">
|
||||
<div class="friends-empty" data-i18n="friends.empty">No friends yet</div>
|
||||
</div>
|
||||
<div id="friend-requests-section" class="friend-requests-section hidden">
|
||||
<div class="section-header"><span data-i18n="friends.requests">Friend Requests</span></div>
|
||||
<div id="friend-requests-list" class="friend-requests-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings View -->
|
||||
<div id="view-settings" class="view">
|
||||
<div class="view-header">
|
||||
<h2 class="view-title" data-i18n="settings.title">Settings</h2>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.activatePass.title">Activate Pass</h4>
|
||||
<p data-i18n="settings.activatePass.desc">Enter your pass code to access server packs</p>
|
||||
</div>
|
||||
<div class="setting-control setting-pass">
|
||||
<input type="text" id="pass-code" placeholder="Pass code" data-i18n-placeholder="settings.activatePass.placeholder" class="pass-input">
|
||||
<button id="activate-pass-btn" class="btn-primary btn-sm" onclick="app.activatePass()"><span data-i18n="settings.activatePass.button">Activate</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.ram.title">Allocated RAM</h4>
|
||||
<p id="ram-info" data-i18n="settings.ram.info">Loading...</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="range" id="ram-slider" min="1024" max="16384" step="512" value="4096">
|
||||
<span class="setting-value" id="ram-value">4 GB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.resolution.title">Game Resolution</h4>
|
||||
<p data-i18n="settings.resolution.desc">Width x Height</p>
|
||||
</div>
|
||||
<div class="setting-control" style="gap:6px">
|
||||
<input type="number" id="win-width" min="640" max="7680" step="1" value="1280" class="setting-input" style="width:80px">
|
||||
<span style="color:var(--text-muted)">x</span>
|
||||
<input type="number" id="win-height" min="480" max="4320" step="1" value="720" class="setting-input" style="width:80px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.jvmArgs.title">Extra JVM Arguments</h4>
|
||||
<p data-i18n="settings.jvmArgs.desc">Additional Java VM options</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" id="jvm-args" placeholder="-XX:+UseZGC" class="setting-input" style="width:280px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.javaPath.title">Java Path</h4>
|
||||
<p id="java-path">~/.zernmc/jre/</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" id="java-path-input" placeholder="java" class="setting-input" style="width:280px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.server.title">Server</h4>
|
||||
<p id="server-url">http://87.120.187.36:1582</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<span class="setting-badge" id="server-status">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.language.title">Language</h4>
|
||||
<p data-i18n="settings.language.desc">Interface language</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="locale-select" class="setting-input" style="width:160px">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.systemJvm.title">System-based JVM Optimization</h4>
|
||||
<p id="system-jvm-info">-</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle" id="system-jvm-toggle-wrapper">
|
||||
<input type="checkbox" id="system-jvm-toggle">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.logViewer.title">Game Log</h4>
|
||||
<p data-i18n="settings.logViewer.desc">View real-time game logs</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Friend Modal -->
|
||||
<div id="add-friend-modal" class="modal-backdrop hidden">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3 data-i18n="friends.addTitle">Add Friend</h3>
|
||||
<button class="modal-close" onclick="app.closeAddFriend()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label data-i18n="friends.addLabel">Username</label>
|
||||
<input type="text" id="add-friend-input" placeholder="Enter username..." data-i18n-placeholder="friends.addPlaceholder">
|
||||
</div>
|
||||
<button id="add-friend-submit" class="btn-primary" onclick="app.submitAddFriend()"><span data-i18n="friends.add">Add Friend</span></button>
|
||||
<p id="add-friend-error" class="error-msg hidden"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Viewer Overlay -->
|
||||
<div id="log-viewer-overlay" class="modal-backdrop hidden">
|
||||
<div class="modal modal-log">
|
||||
<div class="modal-head">
|
||||
<h3 data-i18n="logViewer.title">Game Log</h3>
|
||||
<div class="log-viewer-actions">
|
||||
<button class="btn-secondary btn-sm" id="copy-log-btn" onclick="app.copyLogs()"><span data-i18n="logViewer.copy">Copy</span></button>
|
||||
<button class="btn-secondary btn-sm" onclick="app.req('/open-log-file', {method:'POST'})"><span data-i18n="logViewer.openFile">Open File</span></button>
|
||||
<button class="modal-close" id="close-log-viewer-btn" onclick="app.closeLogViewer()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body log-viewer-body">
|
||||
<div id="log-viewer-content" class="log-viewer-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install Modal -->
|
||||
<div id="install-modal" class="modal-backdrop hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h3 data-i18n="install.title">Install Pack</h3>
|
||||
<button class="modal-close" id="close-modal-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab active" data-tab="zernmc"><span data-i18n="install.tab.serverPack">Server Pack</span></button>
|
||||
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span> <span class="tag-wip">WIP</span></button>
|
||||
</div>
|
||||
|
||||
<div id="tab-zernmc" class="modal-tab-content active">
|
||||
<div class="field">
|
||||
<label data-i18n="install.serverPack.label">Server Pack</label>
|
||||
<select id="zernmc-pack-select">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label data-i18n="install.localName.label">Local Name</label>
|
||||
<input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
|
||||
</div>
|
||||
<button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
|
||||
</div>
|
||||
|
||||
<div id="tab-custom" class="modal-tab-content">
|
||||
<div class="disabled-tab">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<h3 data-i18n="install.custom.unavailable">Not available yet</h3>
|
||||
<p data-i18n="install.custom.desc">Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="install-progress" class="install-progress hidden">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<p class="progress-label" id="progress-label" data-i18n="install.progress.installing">Installing...</p>
|
||||
<p class="progress-stage hidden" id="progress-stage"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Toast -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
</div>
|
||||
|
||||
<script src="marked.min.js"></script>
|
||||
<script src="launcher.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,759 @@
|
||||
:root {
|
||||
--bg-deep: #07070a;
|
||||
--bg-surface: #0c0c12;
|
||||
--bg-elevated: #111118;
|
||||
--bg-card: #16161f;
|
||||
--bg-card-hover: #1c1c28;
|
||||
--bg-inset: #0a0a0f;
|
||||
--accent: #e94560;
|
||||
--accent-glow: rgba(233, 69, 96, 0.25);
|
||||
--accent-soft: rgba(233, 69, 96, 0.1);
|
||||
--text: #eeeef0;
|
||||
--text-secondary: #88889a;
|
||||
--text-muted: #555566;
|
||||
--border: #1e1e2a;
|
||||
--border-light: #2a2a3a;
|
||||
--success: #4ade80;
|
||||
--error: #f87171;
|
||||
--warning: #fbbf24;
|
||||
--info: #60a5fa;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
--shadow-glow: 0 0 40px var(--accent-glow);
|
||||
--transition: 200ms ease;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', Arial, sans-serif;
|
||||
--mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { font-size: 14px; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg-deep);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#bg-canvas {
|
||||
position: fixed; inset: 0; width: 100%; height: 100%;
|
||||
z-index: 0; opacity: 0.08; pointer-events: none;
|
||||
}
|
||||
|
||||
#app { position: relative; z-index: 1; height: 100vh; display: flex; }
|
||||
|
||||
.screen {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
}
|
||||
|
||||
.screen.hidden { opacity: 0; transform: scale(0.97); pointer-events: none; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ========== LOGIN ========== */
|
||||
.login-container {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 48px 40px 40px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: var(--shadow);
|
||||
animation: floatIn 0.5s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes floatIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-brand { text-align: center; margin-bottom: 36px; }
|
||||
.brand-icon { margin-bottom: 16px; }
|
||||
.brand-title {
|
||||
font-size: 28px; font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
.brand-sub { color: var(--text-muted); font-size: 13px; margin-top: 4px; }
|
||||
|
||||
.login-form { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.field { position: relative; }
|
||||
.field label {
|
||||
position: absolute; top: 50%; left: 14px; transform: translateY(-50%);
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
transition: var(--transition); pointer-events: none;
|
||||
background: var(--bg-elevated); padding: 0 4px;
|
||||
}
|
||||
.field input:focus + label,
|
||||
.field input:not(:placeholder-shown) + label {
|
||||
top: 0; font-size: 11px; color: var(--accent);
|
||||
}
|
||||
.field input {
|
||||
width: 100%; padding: 14px 14px; font-size: 14px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm); color: var(--text);
|
||||
font-family: var(--font); transition: var(--transition);
|
||||
outline: none;
|
||||
}
|
||||
.field input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
.field select {
|
||||
width: 100%; padding: 12px 14px; font-size: 14px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm); color: var(--text);
|
||||
font-family: var(--font); cursor: pointer; outline: none;
|
||||
}
|
||||
.field select:focus { border-color: var(--accent); }
|
||||
|
||||
.btn-primary {
|
||||
width: 100%; padding: 14px; border: none; border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--accent), #ff6b6b);
|
||||
color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
font-family: var(--font); transition: var(--transition);
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
min-height: 48px; position: relative;
|
||||
}
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: var(--shadow-glow); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.error-msg {
|
||||
color: var(--error); font-size: 13px; text-align: center;
|
||||
padding: 10px; background: rgba(248,113,113,0.1);
|
||||
border-radius: var(--radius-sm); animation: shake 0.4s ease;
|
||||
}
|
||||
@keyframes shake {
|
||||
0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}
|
||||
}
|
||||
|
||||
.login-hint { text-align: center; font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
.spinner {
|
||||
position: absolute; width: 20px; height: 20px;
|
||||
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
|
||||
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ========== OVERLAY ========== */
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(7,7,10,0.92);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
z-index: 100; animation: fadeIn 0.3s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.loader-ring {
|
||||
width: 48px; height: 48px;
|
||||
border: 3px solid var(--border-light); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px;
|
||||
}
|
||||
.loader-text { color: var(--text-secondary); font-size: 14px; }
|
||||
|
||||
/* ========== MAIN SHELL ========== */
|
||||
.shell {
|
||||
display: flex; width: 100%; height: 100vh;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
/* ========== SIDEBAR ========== */
|
||||
.sidebar {
|
||||
width: 260px; min-width: 260px;
|
||||
background: var(--bg-deep);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 20px; overflow: hidden; }
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 4px 8px 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-brand-text { display: flex; flex-direction: column; }
|
||||
.sidebar-brand-name { font-size: 16px; font-weight: 700; }
|
||||
.sidebar-brand-ver { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex; gap: 4px;
|
||||
padding-bottom: 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nav-btn {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 8px; background: transparent; border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 11px;
|
||||
font-weight: 500; cursor: pointer; font-family: var(--font);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.nav-btn:hover { color: var(--text-secondary); background: var(--bg-card); }
|
||||
.nav-btn.active { color: var(--accent); background: var(--accent-soft); border-color: rgba(233,69,96,0.2); }
|
||||
|
||||
.section-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 8px; padding: 0 4px;
|
||||
}
|
||||
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
|
||||
|
||||
.pack-list {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
overflow-y: auto; max-height: calc((100vh - 460px) / 2);
|
||||
min-height: 40px;
|
||||
}
|
||||
.pack-list:empty::after {
|
||||
content: 'No packs'; display: block; padding: 12px 8px;
|
||||
font-size: 12px; color: var(--text-muted); text-align: center;
|
||||
}
|
||||
|
||||
.pack-entry {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: var(--radius-sm);
|
||||
cursor: pointer; transition: var(--transition);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.pack-entry:hover { background: var(--bg-card); }
|
||||
.pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); }
|
||||
|
||||
.pack-entry-icon {
|
||||
width: 32px; height: 32px; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pack-entry-icon.server { background: rgba(251,191,36,0.15); color: var(--warning); }
|
||||
.pack-entry-icon.local { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
.pack-entry-info { flex: 1; min-width: 0; }
|
||||
.pack-entry-name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pack-entry-meta { font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.btn-icon {
|
||||
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: 1px solid transparent; border-radius: var(--radius-sm);
|
||||
color: var(--text-muted); cursor: pointer; transition: var(--transition); flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover { color: var(--text); background: var(--bg-card); border-color: var(--border-light); }
|
||||
|
||||
/* Sidebar bottom */
|
||||
.sidebar-bottom { padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
.user-card {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px; border-radius: var(--radius-sm);
|
||||
}
|
||||
.user-avatar {
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--accent), #ff6b6b);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 14px; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
.user-info { flex: 1; min-width: 0; }
|
||||
.user-name { font-size: 13px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.user-badges { display: flex; gap: 4px; margin-top: 2px; }
|
||||
.badge {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.badge-pro { background: rgba(74,222,128,0.15); color: var(--success); }
|
||||
.badge-free { background: rgba(248,113,113,0.12); color: var(--error); }
|
||||
.badge-role { background: rgba(96,165,250,0.15); color: var(--info); }
|
||||
|
||||
/* ========== CONTENT ========== */
|
||||
.content {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
padding: 24px 32px; min-width: 0;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.view.active { display: flex; }
|
||||
|
||||
.view-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
margin-bottom: 24px; gap: 16px;
|
||||
}
|
||||
.view-title { font-size: 22px; font-weight: 700; }
|
||||
.view-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 4px; }
|
||||
.view-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.btn-secondary {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; background: var(--bg-card); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 13px;
|
||||
font-weight: 500; cursor: pointer; font-family: var(--font);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg-card-hover); color: var(--text); border-color: var(--border); }
|
||||
.btn-secondary.btn-danger:hover { color: var(--error); border-color: rgba(248,113,113,0.3); background: rgba(248,113,113,0.08); }
|
||||
|
||||
/* ========== PACK DETAIL ========== */
|
||||
.pack-detail { flex: 1; display: flex; }
|
||||
.pack-empty {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 12px; color: var(--text-muted);
|
||||
}
|
||||
.pack-empty h3 { font-size: 18px; font-weight: 600; color: var(--text-secondary); }
|
||||
.pack-empty p { font-size: 13px; }
|
||||
|
||||
.pack-detail-content { flex: 1; display: flex; flex-direction: column; gap: 24px; }
|
||||
.pack-hero { display: flex; align-items: center; gap: 16px; }
|
||||
.pack-icon {
|
||||
width: 56px; height: 56px; border-radius: var(--radius-md);
|
||||
background: var(--bg-card); border: 1px solid var(--border-light);
|
||||
display: flex; align-items: center; justify-content: center; color: var(--accent);
|
||||
}
|
||||
.detail-name { font-size: 20px; font-weight: 700; }
|
||||
.detail-tags { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.tag {
|
||||
font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 4px;
|
||||
}
|
||||
.tag-mc { background: var(--bg-card); color: var(--text-secondary); }
|
||||
.tag-loader { background: rgba(99,102,241,0.15); color: #818cf8; }
|
||||
.tag-server { background: rgba(251,191,36,0.15); color: var(--warning); }
|
||||
|
||||
.pack-stats {
|
||||
display: flex; gap: 24px; padding: 16px;
|
||||
background: var(--bg-card); border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.stat-value { font-size: 18px; font-weight: 700; color: var(--text); }
|
||||
.stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
/* ========== PLAY BAR ========== */
|
||||
.play-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; margin-top: auto;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.play-bar-info { font-size: 14px; font-weight: 500; color: var(--text-secondary); }
|
||||
|
||||
/* ========== PACK DESCRIPTION ========== */
|
||||
.pack-description {
|
||||
padding: 16px; background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-md);
|
||||
}
|
||||
.pack-description-text {
|
||||
font-size: 13px; color: var(--text-secondary); line-height: 1.6;
|
||||
}
|
||||
.pack-gallery {
|
||||
display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.pack-gallery-item {
|
||||
width: 120px; height: 80px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated); border: 1px solid var(--border-light);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-muted); font-size: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pack-gallery-item img {
|
||||
width: 100%; height: 100%; object-fit: cover;
|
||||
}
|
||||
.pack-description-text .news-link {
|
||||
color: var(--accent); text-decoration: underline; cursor: pointer;
|
||||
}
|
||||
.pack-description-text .news-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-play {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 28px; border: none; border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--success), #22c55e);
|
||||
color: #07070a; font-size: 15px; font-weight: 700; cursor: pointer;
|
||||
font-family: var(--font); transition: var(--transition);
|
||||
box-shadow: 0 4px 20px rgba(74,222,128,0.35);
|
||||
}
|
||||
.btn-play:hover:not(:disabled) { transform: translateY(-2px) scale(1.02); box-shadow: 0 8px 32px rgba(74,222,128,0.45); }
|
||||
.btn-play:active:not(:disabled) { transform: translateY(0); }
|
||||
.btn-play:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
/* ========== CUSTOM SELECT ========== */
|
||||
.custom-select-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.custom-select-trigger {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; padding: 10px 12px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm); color: var(--text);
|
||||
font-size: 13px; font-family: var(--font); cursor: pointer;
|
||||
transition: var(--transition); user-select: none;
|
||||
gap: 8px;
|
||||
}
|
||||
.custom-select-trigger:hover { border-color: var(--text-muted); }
|
||||
.custom-select-trigger.open { border-color: var(--accent); }
|
||||
.custom-select-trigger .arrow {
|
||||
width: 16px; height: 16px; flex-shrink: 0;
|
||||
transition: transform 0.2s ease; opacity: 0.5;
|
||||
}
|
||||
.custom-select-trigger.open .arrow { transform: rotate(180deg); }
|
||||
.custom-select-trigger .placeholder { color: var(--text-muted); }
|
||||
.custom-select-dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); box-shadow: var(--shadow);
|
||||
z-index: 100; max-height: 240px; display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.custom-select-dropdown.open { display: flex; }
|
||||
.custom-select-search {
|
||||
padding: 8px; border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; background: var(--bg-elevated);
|
||||
z-index: 1;
|
||||
}
|
||||
.custom-select-search input {
|
||||
width: 100%; padding: 6px 10px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-light);
|
||||
border-radius: 4px; color: var(--text); font-size: 12px;
|
||||
font-family: var(--font); outline: none;
|
||||
}
|
||||
.custom-select-search input:focus { border-color: var(--accent); }
|
||||
.custom-select-options {
|
||||
overflow-y: auto; flex: 1;
|
||||
}
|
||||
.custom-select-option {
|
||||
padding: 8px 12px; cursor: pointer; font-size: 13px;
|
||||
color: var(--text-secondary); transition: var(--transition);
|
||||
}
|
||||
.custom-select-option:hover { background: var(--bg-card); color: var(--text); }
|
||||
.custom-select-option.selected { background: var(--accent-soft); color: var(--accent); }
|
||||
.custom-select-option.hidden { display: none; }
|
||||
|
||||
/* ========== NEWS ========== */
|
||||
.news-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px; overflow-y: auto; padding-bottom: 24px;
|
||||
}
|
||||
.news-loading {
|
||||
grid-column: 1 / -1; text-align: center; padding: 60px 20px;
|
||||
color: var(--text-muted); font-size: 14px;
|
||||
}
|
||||
.news-empty {
|
||||
grid-column: 1 / -1; text-align: center; padding: 60px 20px;
|
||||
color: var(--text-muted); font-size: 14px;
|
||||
}
|
||||
.news-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md); padding: 24px; display: flex;
|
||||
flex-direction: column; gap: 12px; transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
.news-card:hover { border-color: var(--border-light); transform: translateY(-1px); }
|
||||
.news-preview {
|
||||
font-size: 13px; color: var(--text-secondary); line-height: 1.5;
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.news-card-badge {
|
||||
align-self: flex-start; font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 1px; padding: 4px 10px; border-radius: 4px;
|
||||
}
|
||||
.news-placeholder .news-card-badge { background: var(--accent-soft); color: var(--accent); }
|
||||
.news-card h3 { font-size: 16px; font-weight: 600; }
|
||||
.news-card p { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
|
||||
.news-card time { font-size: 11px; color: var(--text-muted); margin-top: auto; }
|
||||
.news-card-badge.type-Update { background: rgba(96,165,250,0.15); color: var(--info); }
|
||||
.news-card-badge.type-Announcement { background: rgba(251,191,36,0.15); color: var(--warning); }
|
||||
.news-card-badge.type-Event { background: rgba(74,222,128,0.15); color: var(--success); }
|
||||
|
||||
.news-modal-body {
|
||||
max-height: 60vh; overflow-y: auto; line-height: 1.7;
|
||||
font-size: 14px; color: var(--text-secondary);
|
||||
}
|
||||
.news-modal-body .news-text-line { display: inline; }
|
||||
.news-modal-body .news-link {
|
||||
color: var(--info); text-decoration: underline; cursor: pointer;
|
||||
}
|
||||
.news-modal-body .news-link:hover { color: var(--accent); }
|
||||
.news-modal-body .news-photo {
|
||||
display: block; max-width: 100%; border-radius: var(--radius-sm);
|
||||
margin: 12px 0; cursor: pointer; border: 1px solid var(--border);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.news-modal-body .news-photo:hover { opacity: 0.9; }
|
||||
.modal-news { max-width: 640px; }
|
||||
|
||||
/* ========== SETTINGS ========== */
|
||||
.settings-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||
.setting-card {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md); gap: 24px;
|
||||
}
|
||||
.setting-info h4 { font-size: 14px; font-weight: 600; }
|
||||
.setting-info p { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||||
.setting-control { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||
.setting-control input[type="range"] {
|
||||
width: 160px; height: 4px; -webkit-appearance: none; appearance: none;
|
||||
background: var(--border); border-radius: 2px; outline: none;
|
||||
}
|
||||
.setting-control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: var(--accent); cursor: pointer; border: 2px solid var(--bg-deep);
|
||||
}
|
||||
.setting-value { font-size: 14px; font-weight: 600; color: var(--text); min-width: 48px; text-align: right; }
|
||||
.setting-badge {
|
||||
font-size: 12px; padding: 4px 10px; border-radius: 4px;
|
||||
background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-light);
|
||||
}
|
||||
.setting-pass { display: flex; align-items: center; gap: 8px; }
|
||||
.pass-input {
|
||||
width: 160px; padding: 6px 12px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-inset); border: 1px solid var(--border-light);
|
||||
color: var(--text); font-size: 13px; outline: none;
|
||||
}
|
||||
.pass-input:focus { border-color: var(--accent); }
|
||||
.setting-input {
|
||||
padding: 6px 10px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-inset); border: 1px solid var(--border-light);
|
||||
color: var(--text); font-size: 13px; outline: none; font-family: var(--mono);
|
||||
}
|
||||
.setting-input:focus { border-color: var(--accent); }
|
||||
.btn-sm { padding: 6px 14px !important; font-size: 12px !important; }
|
||||
|
||||
/* ========== TOGGLE ========== */
|
||||
.toggle {
|
||||
position: relative; display: inline-block; width: 44px; height: 24px; cursor: pointer;
|
||||
}
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider {
|
||||
position: absolute; inset: 0; background: var(--border-light); border-radius: 12px; transition: var(--transition);
|
||||
}
|
||||
.toggle-slider::before {
|
||||
content: ''; position: absolute; left: 3px; bottom: 3px; width: 18px; height: 18px;
|
||||
background: var(--text-secondary); border-radius: 50%; transition: var(--transition);
|
||||
}
|
||||
.toggle input:checked + .toggle-slider { background: var(--accent); }
|
||||
.toggle input:checked + .toggle-slider::before { transform: translateX(20px); background: #fff; }
|
||||
|
||||
/* ========== LOCALE SELECT ========== */
|
||||
#locale-select { font-family: var(--font); cursor: pointer; }
|
||||
|
||||
/* ========== MODAL ========== */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(7,7,10,0.85);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 50;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); width: 90%; max-width: 480px;
|
||||
max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);
|
||||
animation: floatIn 0.3s ease;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-head h3 { font-size: 17px; font-weight: 600; }
|
||||
.modal-close {
|
||||
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: none; color: var(--text-muted);
|
||||
font-size: 22px; cursor: pointer; border-radius: var(--radius-sm); transition: var(--transition);
|
||||
}
|
||||
.modal-close:hover { color: var(--text); background: var(--bg-card); }
|
||||
|
||||
.modal-body { padding: 20px 24px 24px; }
|
||||
.modal-tabs { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
.modal-tab {
|
||||
flex: 1; padding: 10px; background: transparent; border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 13px;
|
||||
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
|
||||
}
|
||||
.modal-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); }
|
||||
.modal-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); }
|
||||
|
||||
.modal-tab-content { display: none; flex-direction: column; gap: 16px; }
|
||||
.modal-tab-content.active { display: flex; }
|
||||
.modal-tab-content .field label {
|
||||
display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary);
|
||||
margin-bottom: 6px; position: static; transform: none;
|
||||
background: none; padding: 0;
|
||||
}
|
||||
|
||||
.tag-wip {
|
||||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
|
||||
background: rgba(251,191,36,0.15); color: var(--warning); vertical-align: middle; margin-left: 4px;
|
||||
}
|
||||
.disabled-tab {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||
padding: 40px 20px; text-align: center; color: var(--text-muted);
|
||||
}
|
||||
.disabled-tab h3 { font-size: 18px; font-weight: 600; color: var(--text-secondary); }
|
||||
.disabled-tab p { font-size: 13px; line-height: 1.5; max-width: 300px; }
|
||||
|
||||
.select-wrap select {
|
||||
width: 100%; padding: 10px 12px; font-size: 13px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm); color: var(--text);
|
||||
font-family: var(--font); cursor: pointer; outline: none;
|
||||
}
|
||||
.select-wrap select:focus { border-color: var(--accent); }
|
||||
|
||||
.install-progress { padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.progress-track {
|
||||
height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%; width: 0%;
|
||||
background: linear-gradient(90deg, var(--accent), #ff6b6b);
|
||||
border-radius: 3px; transition: width 0.3s ease;
|
||||
}
|
||||
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
|
||||
.progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; }
|
||||
|
||||
/* ========== LOG VIEWER ========== */
|
||||
.modal-log { max-width: 800px; }
|
||||
.log-viewer-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.log-viewer-body { padding: 0 !important; }
|
||||
.log-viewer-content {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-deep);
|
||||
padding: 12px 16px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-line { padding: 1px 0; }
|
||||
.log-empty { color: var(--text-muted); font-family: var(--font); font-size: 13px; padding: 20px; text-align: center; }
|
||||
.log-error { color: #f87171; }
|
||||
.log-warn { color: #fbbf24; }
|
||||
.log-info { color: #4ade80; }
|
||||
.log-debug { color: #60a5fa; }
|
||||
|
||||
/* ========== TOAST ========== */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
padding: 12px 24px; border-radius: var(--radius-sm);
|
||||
font-size: 13px; font-weight: 500; z-index: 200;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
color: var(--text); box-shadow: var(--shadow);
|
||||
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
|
||||
}
|
||||
.toast.error { border-color: rgba(248,113,113,0.3); color: var(--error); }
|
||||
.toast.success { border-color: rgba(74,222,128,0.3); color: var(--success); }
|
||||
.toast.warning { border-color: rgba(251,191,36,0.3); color: var(--warning); }
|
||||
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
|
||||
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
|
||||
/* ========== SCROLLBAR ========== */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* ========== RESPONSIVE ========== */
|
||||
@media (max-width: 900px) {
|
||||
.sidebar { width: 200px; min-width: 200px; }
|
||||
.content { padding: 16px; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.sidebar { width: 56px; min-width: 56px; }
|
||||
.sidebar-brand-text, .sidebar-nav .nav-btn span,
|
||||
.section-header, .pack-entry-info, .user-info,
|
||||
.sidebar-bottom .user-card .btn-icon:first-child { display: none; }
|
||||
.sidebar-brand { justify-content: center; padding: 8px; }
|
||||
.sidebar-nav { flex-direction: column; }
|
||||
.nav-btn { padding: 8px; }
|
||||
.pack-entry { justify-content: center; padding: 8px; }
|
||||
.content { padding: 12px; }
|
||||
.play-bar { flex-direction: column; gap: 12px; }
|
||||
.view-header { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ========== FRIENDS ========== */
|
||||
.friends-search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 12px;
|
||||
}
|
||||
.friends-search svg { flex-shrink: 0; color: var(--text-muted); }
|
||||
.friends-search input {
|
||||
flex: 1; background: transparent; border: none; outline: none;
|
||||
color: var(--text); font-size: 13px; font-family: var(--font);
|
||||
}
|
||||
.friends-search input::placeholder { color: var(--text-muted); }
|
||||
.friends-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.friends-empty {
|
||||
text-align: center; padding: 40px 20px; color: var(--text-muted); font-size: 13px;
|
||||
}
|
||||
.friends-group-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
color: var(--text-muted); padding: 8px 4px 4px; letter-spacing: 0.5px;
|
||||
}
|
||||
.friend-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
|
||||
border-radius: var(--radius-sm); transition: var(--transition);
|
||||
}
|
||||
.friend-item:hover { background: var(--bg-card-hover); }
|
||||
.friend-item:hover .friend-remove-btn { opacity: 1; }
|
||||
.friend-avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%; display: flex;
|
||||
align-items: center; justify-content: center; font-weight: 600; font-size: 14px;
|
||||
flex-shrink: 0; position: relative;
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
}
|
||||
.friend-avatar.online::after {
|
||||
content: ''; position: absolute; bottom: 0; right: 0;
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--success); border: 2px solid var(--bg-surface);
|
||||
}
|
||||
.friend-avatar.offline { opacity: 0.6; }
|
||||
.friend-info { flex: 1; min-width: 0; }
|
||||
.friend-name-row { display: flex; align-items: center; gap: 6px; }
|
||||
.friend-name { font-size: 13px; font-weight: 500; color: var(--text); }
|
||||
.friend-status { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
.friend-status-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.friend-status-dot.online { background: var(--success); }
|
||||
.friend-status-dot.offline { background: var(--text-muted); }
|
||||
.friend-pack { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.friend-remove-btn {
|
||||
opacity: 0; transition: var(--transition); flex-shrink: 0;
|
||||
width: 28px; height: 28px; color: var(--text-muted);
|
||||
}
|
||||
.friend-remove-btn:hover { color: var(--error); }
|
||||
.friend-requests-section {
|
||||
margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.friend-requests-section .section-header {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px;
|
||||
}
|
||||
.friend-request-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px;
|
||||
background: var(--bg-card); border-radius: var(--radius-sm);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.friend-request-avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%; display: flex;
|
||||
align-items: center; justify-content: center; font-weight: 600; font-size: 14px;
|
||||
flex-shrink: 0; background: var(--accent-soft); color: var(--accent);
|
||||
}
|
||||
.friend-request-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; }
|
||||
.friend-request-name { font-size: 13px; font-weight: 500; }
|
||||
.friend-request-text { font-size: 11px; color: var(--text-muted); }
|
||||
.friend-request-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
/* ========== MODAL SM ========== */
|
||||
.modal-sm { max-width: 360px; }
|
||||
|
||||
/* ========== BADGE SM ========== */
|
||||
.badge-sm { font-size: 10px; padding: 2px 6px; }
|
||||
+90
@@ -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;
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package me.sashegdev.zernmc.launcher.auth;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
class AuthManagerPassTest {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private static AuthManager.AuthSession createSession(String token, int role) {
|
||||
AuthManager.AuthSession s = new AuthManager.AuthSession();
|
||||
s.accessToken = token;
|
||||
s.role = role;
|
||||
s.expiresAt = System.currentTimeMillis() / 1000L + 3600;
|
||||
return s;
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_returnsFalse_whenNotLoggedIn() {
|
||||
AuthManager.resetForTest();
|
||||
assertFalse(AuthManager.hasPass());
|
||||
assertFalse(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_usesUserInfo_whenAvailable() {
|
||||
AuthManager.UserInfo info = new AuthManager.UserInfo();
|
||||
info.has_pass = true;
|
||||
info.role = 1;
|
||||
AuthManager.setTestUserInfo(info);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", 1));
|
||||
|
||||
assertTrue(AuthManager.hasPass());
|
||||
assertTrue(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_usesRole_whenUserInfoNull() {
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
|
||||
|
||||
assertTrue(AuthManager.hasPass());
|
||||
assertTrue(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_returnsFalse_whenRoleTooLow() {
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_USER));
|
||||
|
||||
assertFalse(AuthManager.hasPass());
|
||||
assertFalse(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_userInfoTakesPriorityOverRole() {
|
||||
AuthManager.UserInfo info = new AuthManager.UserInfo();
|
||||
info.has_pass = false;
|
||||
info.role = 1;
|
||||
AuthManager.setTestUserInfo(info);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
|
||||
|
||||
assertFalse(AuthManager.hasPass());
|
||||
assertFalse(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canViewPacks_usesPermissions_whenAvailable() {
|
||||
AuthManager.UserInfo info = new AuthManager.UserInfo();
|
||||
info.permissions = List.of("view_packs", "download_pack");
|
||||
info.has_pass = true;
|
||||
AuthManager.setTestUserInfo(info);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", 1));
|
||||
|
||||
assertTrue(AuthManager.canViewPacks());
|
||||
assertTrue(AuthManager.canDownloadPacks());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canViewPacks_fallsBackToHasPass_whenNoPermissions() {
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
|
||||
|
||||
assertTrue(AuthManager.canViewPacks());
|
||||
assertTrue(AuthManager.canDownloadPacks());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authSession_parsesFromLoginResponse() {
|
||||
String json = """
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh",
|
||||
"expires_in": 86400,
|
||||
"token_type": "bearer",
|
||||
"username": "testuser",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role": 1,
|
||||
"role_name": "PASS_HOLDER"
|
||||
}
|
||||
""";
|
||||
AuthManager.AuthSession session = GSON.fromJson(json, AuthManager.AuthSession.class);
|
||||
|
||||
assertNotNull(session);
|
||||
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test", session.accessToken);
|
||||
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh", session.refreshToken);
|
||||
assertEquals(86400, session.expiresIn);
|
||||
assertEquals("testuser", session.username);
|
||||
assertEquals("550e8400-e29b-41d4-a716-446655440000", session.uuid);
|
||||
assertEquals(1, session.role);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authSession_roundTrip() {
|
||||
AuthManager.AuthSession original = new AuthManager.AuthSession();
|
||||
original.accessToken = "access123";
|
||||
original.refreshToken = "refresh123";
|
||||
original.expiresIn = 86400;
|
||||
original.expiresAt = System.currentTimeMillis() / 1000L + 86400;
|
||||
original.username = "testuser";
|
||||
original.uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
original.role = 1;
|
||||
|
||||
String json = GSON.toJson(original);
|
||||
AuthManager.AuthSession parsed = GSON.fromJson(json, AuthManager.AuthSession.class);
|
||||
|
||||
assertEquals(original.accessToken, parsed.accessToken);
|
||||
assertEquals(original.refreshToken, parsed.refreshToken);
|
||||
assertEquals(original.expiresIn, parsed.expiresIn);
|
||||
assertEquals(original.expiresAt, parsed.expiresAt);
|
||||
assertEquals(original.username, parsed.username);
|
||||
assertEquals(original.uuid, parsed.uuid);
|
||||
assertEquals(original.role, parsed.role);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userInfo_parsesFromMeEndpoint() {
|
||||
String json = """
|
||||
{
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role": 1,
|
||||
"role_name": "PASS_HOLDER",
|
||||
"has_pass": true,
|
||||
"permissions": ["view_packs", "download_pack"]
|
||||
}
|
||||
""";
|
||||
AuthManager.UserInfo info = GSON.fromJson(json, AuthManager.UserInfo.class);
|
||||
|
||||
assertNotNull(info);
|
||||
assertEquals(1, info.id);
|
||||
assertEquals("testuser", info.username);
|
||||
assertEquals(1, info.role);
|
||||
assertEquals("PASS_HOLDER", info.role_name);
|
||||
assertTrue(info.has_pass);
|
||||
assertTrue(info.permissions.contains("view_packs"));
|
||||
assertTrue(info.permissions.contains("download_pack"));
|
||||
assertTrue(info.hasPermission("view_packs"));
|
||||
assertFalse(info.hasPermission("admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRole_updatesSessionRole() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestSession(createSession("tok", 0));
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
assertEquals(0, AuthManager.getRole());
|
||||
assertFalse(AuthManager.hasPass());
|
||||
|
||||
AuthManager.updateRole(1);
|
||||
|
||||
assertEquals(1, AuthManager.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLoggedIn_returnsTrue_whenSessionExists() {
|
||||
AuthManager.resetForTest();
|
||||
assertFalse(AuthManager.isLoggedIn());
|
||||
|
||||
AuthManager.AuthSession s = createSession("tok", 0);
|
||||
s.username = "testuser";
|
||||
AuthManager.setTestSession(s);
|
||||
|
||||
assertTrue(AuthManager.isLoggedIn());
|
||||
assertEquals("testuser", AuthManager.getUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUsername_returnsSessionUsername() {
|
||||
AuthManager.AuthSession s = createSession("tok", 0);
|
||||
s.username = "testuser";
|
||||
AuthManager.setTestSession(s);
|
||||
|
||||
assertEquals("testuser", AuthManager.getUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRole_returnsZero_whenSessionNull() {
|
||||
AuthManager.resetForTest();
|
||||
assertEquals(0, AuthManager.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRoleName_fallsBackToUSER_whenUserInfoNull() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestUserInfo(null);
|
||||
AuthManager.setTestSession(createSession("tok", 0));
|
||||
|
||||
assertEquals("USER", AuthManager.getRoleName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessToken_returnsToken_whenSessionValid() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestUserInfo(null);
|
||||
AuthManager.setTestSession(createSession("valid-token", 1));
|
||||
|
||||
String token = AuthManager.getAccessToken();
|
||||
assertEquals("valid-token", token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessToken_doesNotInvalidate_whenNoRefreshToken() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestUserInfo(null);
|
||||
AuthManager.AuthSession s = createSession("tok", 1);
|
||||
s.refreshToken = null;
|
||||
AuthManager.setTestSession(s);
|
||||
|
||||
String token = AuthManager.getAccessToken();
|
||||
assertEquals("tok", token);
|
||||
assertTrue(AuthManager.isLoggedIn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessToken_returnsZero_whenSessionNull() {
|
||||
AuthManager.resetForTest();
|
||||
assertEquals("0", AuthManager.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidateSession_clearsState() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestSession(createSession("tok", 1));
|
||||
AuthManager.setTestUserInfo(new AuthManager.UserInfo());
|
||||
assertTrue(AuthManager.isLoggedIn());
|
||||
|
||||
AuthManager.logout();
|
||||
|
||||
assertFalse(AuthManager.isLoggedIn());
|
||||
assertEquals(0, AuthManager.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSavedSession_returnsFalse_whenNoAuthFile() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.logout();
|
||||
assertFalse(AuthManager.loadSavedSession());
|
||||
}
|
||||
}
|
||||
+469
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for PackDownloader JSON parsing.
|
||||
* Tests that the Java client correctly parses server JSON responses.
|
||||
*/
|
||||
class PackDownloaderParsingTest {
|
||||
|
||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
// ===== /packs response parsing =====
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_singlePack() {
|
||||
String body = "{" +
|
||||
"\"packs\":[" +
|
||||
"{" +
|
||||
"\"name\":\"test-modpack\"," +
|
||||
"\"version\":3," +
|
||||
"\"files_count\":15," +
|
||||
"\"updated_at\":\"2024-01-15T10:30:00\"," +
|
||||
"\"minecraft_version\":\"1.20.4\"," +
|
||||
"\"loader_type\":\"fabric\"," +
|
||||
"\"loader_version\":\"0.15.6\"" +
|
||||
"}" +
|
||||
"]" +
|
||||
"}";
|
||||
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertEquals(1, packs.size());
|
||||
|
||||
ServerPack pack = packs.get(0);
|
||||
assertEquals("test-modpack", pack.getName());
|
||||
assertEquals(3, pack.getVersion());
|
||||
assertEquals(15, pack.getFilesCount());
|
||||
assertEquals("1.20.4", pack.getMinecraftVersion());
|
||||
assertEquals("fabric", pack.getLoaderType());
|
||||
assertEquals("0.15.6", pack.getLoaderVersion());
|
||||
assertNotNull(pack.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_multiplePacks() {
|
||||
String body = "{" +
|
||||
"\"packs\":[" +
|
||||
"{\"name\":\"survival\",\"version\":1,\"files_count\":5,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
|
||||
"{\"name\":\"pvp\",\"version\":10,\"files_count\":50,\"minecraft_version\":\"1.20.4\",\"loader_type\":\"fabric\",\"loader_version\":\"0.15.6\",\"updated_at\":\"2024-02-01T00:00:00\"}" +
|
||||
"]" +
|
||||
"}";
|
||||
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertEquals(2, packs.size());
|
||||
assertEquals("survival", packs.get(0).getName());
|
||||
assertEquals("pvp", packs.get(1).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_skipsErroredPacks() {
|
||||
String body = "{" +
|
||||
"\"packs\":[" +
|
||||
"{\"name\":\"good-pack\",\"version\":1,\"files_count\":1,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
|
||||
"{\"name\":\"bad-pack\",\"error\":\"scan failed\"}," +
|
||||
"{\"name\":\"not-scanned\",\"status\":\"not_scanned\"}" +
|
||||
"]" +
|
||||
"}";
|
||||
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertEquals(1, packs.size());
|
||||
assertEquals("good-pack", packs.get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_missingFields_defaults() {
|
||||
String body = "{" +
|
||||
"\"packs\":[" +
|
||||
"{\"name\":\"minimal-pack\"}" +
|
||||
"]" +
|
||||
"}";
|
||||
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertEquals(1, packs.size());
|
||||
|
||||
ServerPack pack = packs.get(0);
|
||||
assertEquals("minimal-pack", pack.getName());
|
||||
assertEquals(0, pack.getVersion()); // default
|
||||
assertEquals("unknown", pack.getMinecraftVersion()); // default
|
||||
assertEquals("vanilla", pack.getLoaderType()); // default
|
||||
assertEquals("", pack.getLoaderVersion()); // default
|
||||
assertEquals(0, pack.getFilesCount()); // default
|
||||
assertNull(pack.getUpdatedAt()); // default
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_emptyList() {
|
||||
String body = "{\"packs\":[]}";
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertTrue(packs.isEmpty());
|
||||
}
|
||||
|
||||
// ===== PackManifest parsing =====
|
||||
|
||||
@Test
|
||||
void parsePackManifest_withFiles() {
|
||||
String body = "{" +
|
||||
"\"pack_name\":\"my-pack\"," +
|
||||
"\"version\":5," +
|
||||
"\"minecraft_version\":\"1.20.4\"," +
|
||||
"\"loader_type\":\"fabric\"," +
|
||||
"\"loader_version\":\"0.15.6\"," +
|
||||
"\"asset_index\":\"1.20.4\"," +
|
||||
"\"files\":{" +
|
||||
"\"mods/sodium.jar\":{\"path\":\"mods/sodium.jar\",\"url\":\"/pack/my-pack/file/mods/sodium.jar\",\"size\":1024000,\"hash\":\"abc123\"}," +
|
||||
"\"mods/fabric-api.jar\":{\"path\":\"mods/fabric-api.jar\",\"url\":\"/pack/my-pack/file/mods/fabric-api.jar\",\"size\":2048000,\"hash\":\"def456\"}" +
|
||||
"}" +
|
||||
"}";
|
||||
|
||||
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
|
||||
|
||||
assertEquals("my-pack", manifest.getPackName());
|
||||
assertEquals(5, manifest.getVersion());
|
||||
assertEquals("1.20.4", manifest.getMinecraftVersion());
|
||||
assertEquals("fabric", manifest.getLoaderType());
|
||||
assertEquals("0.15.6", manifest.getLoaderVersion());
|
||||
assertEquals("1.20.4", manifest.getAssetIndex());
|
||||
assertFalse(manifest.isEmpty());
|
||||
assertEquals(2, manifest.getFiles().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePackManifest_nullAssetIndex_defaultsToMinecraftVersion() {
|
||||
String body = "{" +
|
||||
"\"pack_name\":\"no-asset\"," +
|
||||
"\"version\":1," +
|
||||
"\"minecraft_version\":\"1.19.4\"," +
|
||||
"\"loader_type\":\"vanilla\"," +
|
||||
"\"loader_version\":null" +
|
||||
"}";
|
||||
|
||||
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
|
||||
assertEquals("1.19.4", manifest.getAssetIndex()); // defaults to minecraft_version
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePackManifest_noFiles_isEmpty() {
|
||||
String body = "{" +
|
||||
"\"pack_name\":\"empty-pack\"," +
|
||||
"\"version\":1," +
|
||||
"\"minecraft_version\":\"1.20.1\"," +
|
||||
"\"loader_type\":\"vanilla\"," +
|
||||
"\"loader_version\":null" +
|
||||
"}";
|
||||
|
||||
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
|
||||
assertTrue(manifest.isEmpty());
|
||||
}
|
||||
|
||||
// ===== DiffResponse parsing =====
|
||||
|
||||
@Test
|
||||
void parseDiffResponse_allFields() {
|
||||
String body = "{" +
|
||||
"\"version\":6," +
|
||||
"\"to_download\":[" +
|
||||
"{\"path\":\"mods/new-mod.jar\",\"url\":\"/pack/test/file/mods/new-mod.jar\",\"size\":512000,\"hash\":\"aaa111\"}" +
|
||||
"]," +
|
||||
"\"to_delete\":[\"mods/old-mod.jar\"]," +
|
||||
"\"to_update\":[\"mods/updated-mod.jar\"]" +
|
||||
"}";
|
||||
|
||||
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
|
||||
|
||||
assertEquals(6, diff.getVersion());
|
||||
assertEquals(1, diff.getToDownload().size());
|
||||
assertEquals(1, diff.getToDelete().size());
|
||||
assertEquals(1, diff.getToUpdate().size());
|
||||
|
||||
PackDownloader.FileInfo fileInfo = diff.getToDownload().get(0);
|
||||
assertEquals("mods/new-mod.jar", fileInfo.getPath());
|
||||
assertEquals("/pack/test/file/mods/new-mod.jar", fileInfo.getUrl());
|
||||
assertEquals(512000, fileInfo.getSize());
|
||||
assertEquals("aaa111", fileInfo.getHash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseDiffResponse_emptyArrays() {
|
||||
String body = "{" +
|
||||
"\"version\":1," +
|
||||
"\"to_download\":[]," +
|
||||
"\"to_delete\":[]," +
|
||||
"\"to_update\":[]" +
|
||||
"}";
|
||||
|
||||
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
|
||||
assertTrue(diff.getToDownload().isEmpty());
|
||||
assertTrue(diff.getToDelete().isEmpty());
|
||||
assertTrue(diff.getToUpdate().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseDiffResponse_nullArrays_returnsEmpty() {
|
||||
String body = "{\"version\":1}";
|
||||
|
||||
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
|
||||
assertNotNull(diff.getToDownload());
|
||||
assertNotNull(diff.getToDelete());
|
||||
assertNotNull(diff.getToUpdate());
|
||||
assertTrue(diff.getToDownload().isEmpty());
|
||||
assertTrue(diff.getToDelete().isEmpty());
|
||||
}
|
||||
|
||||
// ===== ServerPack toString =====
|
||||
|
||||
@Test
|
||||
void serverPack_toString_withDate() {
|
||||
java.time.LocalDateTime date = java.time.LocalDateTime.of(2024, 3, 15, 12, 0);
|
||||
ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", date, 25);
|
||||
|
||||
String str = pack.toString();
|
||||
assertTrue(str.contains("my-pack"));
|
||||
assertTrue(str.contains("1.20.4"));
|
||||
assertTrue(str.contains("fabric"));
|
||||
assertTrue(str.contains("25 файлов"));
|
||||
assertTrue(str.contains("15.03.2024"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverPack_toString_withoutDate() {
|
||||
ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", null, 25);
|
||||
|
||||
String str = pack.toString();
|
||||
assertTrue(str.contains("my-pack"));
|
||||
assertTrue(str.contains("25 файлов"));
|
||||
assertFalse(str.contains("обновлен"));
|
||||
}
|
||||
|
||||
// ===== Helper: replicates PackDownloader.parsePacksResponse() =====
|
||||
|
||||
private static List<ServerPack> parsePacksResponse(String responseBody) {
|
||||
JsonObject root = com.google.gson.JsonParser.parseString(responseBody).getAsJsonObject();
|
||||
JsonArray packsArray = root.getAsJsonArray("packs");
|
||||
List<ServerPack> result = new ArrayList<>();
|
||||
|
||||
for (var elem : packsArray) {
|
||||
JsonObject pack = elem.getAsJsonObject();
|
||||
|
||||
if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String name = pack.get("name").getAsString();
|
||||
int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
|
||||
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
|
||||
String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
|
||||
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull()
|
||||
? pack.get("loader_version").getAsString() : "";
|
||||
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
|
||||
|
||||
java.time.LocalDateTime updatedAt = null;
|
||||
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
|
||||
try {
|
||||
updatedAt = java.time.LocalDateTime.parse(pack.get("updated_at").getAsString(),
|
||||
java.time.format.DateTimeFormatter.ISO_DATE_TIME);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
|
||||
loaderVersion, updatedAt, filesCount));
|
||||
} catch (Exception e) {
|
||||
System.err.println("Ошибка парсинга пака: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+122
-109
@@ -6,56 +6,110 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>me.sashegdev</groupId>
|
||||
<artifactId>ZernMCLauncher</artifactId>
|
||||
<version>1.0.7</version>
|
||||
<packaging>jar</packaging>
|
||||
<version>1.0.9</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>ZernMC Launcher Parent</name>
|
||||
<description>ZernMC Launcher - Multi-module project</description>
|
||||
|
||||
<modules>
|
||||
<module>bootstrap</module>
|
||||
<module>launcher</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||
<project.organization.name>ZernMC</project.organization.name>
|
||||
<project.inceptionYear>2026</project.inceptionYear>
|
||||
<project.description>ZernMC Launcher - Multi-module project</project.description>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>4.5.14</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.15.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.10.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>20231013</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.fusesource.jansi</groupId>
|
||||
<artifactId>jansi</artifactId>
|
||||
<version>2.4.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.tongfei</groupId>
|
||||
<artifactId>progressbar</artifactId>
|
||||
<version>0.9.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.15.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>4.5.14</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.15.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.10.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>20231013</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.fusesource.jansi</groupId>
|
||||
<artifactId>jansi</artifactId>
|
||||
<version>2.4.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jline</groupId>
|
||||
<artifactId>jline</artifactId>
|
||||
<version>3.24.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.tongfei</groupId>
|
||||
<artifactId>progressbar</artifactId>
|
||||
<version>0.9.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.15.1</version>
|
||||
</dependency>
|
||||
<!-- JavaFX for Windows -->
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-web</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-graphics</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-base</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-media</artifactId>
|
||||
<version>21</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.3</version>
|
||||
</plugin>
|
||||
|
||||
<!-- Shade Plugin -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
@@ -68,7 +122,7 @@
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputFile>../server/builds/ZernMCLauncher.jar</outputFile>
|
||||
<outputFile>../../server/builds/ZernMCLauncher.jar</outputFile>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>${mainClass}</mainClass>
|
||||
@@ -76,81 +130,40 @@
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<Implementation-Title>ZernMC Launcher</Implementation-Title>
|
||||
<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>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Launch4j для создания .exe -->
|
||||
<plugin>
|
||||
<groupId>com.akathist.maven.plugins.launch4j</groupId>
|
||||
<artifactId>launch4j-maven-plugin</artifactId>
|
||||
<version>2.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>l4j</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>launch4j</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outfile>../server/builds/ZernMCLauncher.exe</outfile>
|
||||
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
||||
<headerType>console</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<jre>
|
||||
<path>jre21</path>
|
||||
<minVersion>21</minVersion>
|
||||
</jre>
|
||||
<versionInfo>
|
||||
<fileVersion>${project.version}.0</fileVersion>
|
||||
<txtFileVersion>${project.version}</txtFileVersion>
|
||||
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
|
||||
<productVersion>${project.version}.0</productVersion>
|
||||
<txtProductVersion>${project.version}</txtProductVersion>
|
||||
<productName>ZernMC Launcher</productName>
|
||||
<companyName>ZernMC(SashegDev)</companyName>
|
||||
<internalName>ZernMCLauncher</internalName>
|
||||
<originalFilename>ZernMCLauncher.exe</originalFilename>
|
||||
</versionInfo>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Antrun: копирование JRE и создание build.version + zip -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-antrun-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>run</goal></goals>
|
||||
<configuration>
|
||||
<target>
|
||||
<echo file="../server/builds/build.version">${project.version}</echo>
|
||||
|
||||
<!-- Копируем содержимое jre/jre21 в папку jre21 (без лишней вложенности) -->
|
||||
<copy todir="../server/builds/jre21" overwrite="true">
|
||||
<fileset dir="${user.home}/launcher/jre/jre21"/>
|
||||
</copy>
|
||||
|
||||
<!-- Создаём zip только с .exe и jre21 (без .jar и build.version) -->
|
||||
<zip destfile="../server/builds/ZernMCLauncher-${project.version}.zip"
|
||||
basedir="../server/builds"
|
||||
includes="ZernMCLauncher.exe,jre21/**"
|
||||
excludes="*.jar,build.version"/>
|
||||
</target>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<profiles>
|
||||
<!-- ==================== GLOBAL ==================== -->
|
||||
<profile>
|
||||
<id>global</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<build.profile>global</build.profile>
|
||||
<launcher.title>ZernMC Launcher</launcher.title>
|
||||
<server.url>http://87.120.187.36:1582</server.url>
|
||||
<!-- Можно добавить флаги для отключения некоторых фич -->
|
||||
</properties>
|
||||
</profile>
|
||||
|
||||
<!-- ==================== ZERNMC ==================== -->
|
||||
<profile>
|
||||
<id>zernmc</id>
|
||||
<properties>
|
||||
<build.profile>zernmc</build.profile>
|
||||
<launcher.title>ZernMC Private Launcher</launcher.title>
|
||||
<server.url>http://87.120.187.36:1582</server.url>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -1,182 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.menu.*;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.utils.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
|
||||
public class Main {
|
||||
|
||||
private static final String CURRENT_VERSION = Version.getCurrentVersion();
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
||||
System.setProperty("file.encoding", "UTF-8");
|
||||
System.setProperty("sun.err.encoding", "UTF-8");
|
||||
System.setProperty("sun.stdout.encoding", "UTF-8");
|
||||
java.nio.charset.Charset.defaultCharset();
|
||||
ZAnsi.install();
|
||||
|
||||
System.out.print("\033[H\033[2J");
|
||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
|
||||
|
||||
//проверка всех сервисов при старте
|
||||
ZHttpClient.checkAllServicesOnStartup();
|
||||
|
||||
checkAndAutoUpdateLauncher();
|
||||
|
||||
// === АВТОРИЗАЦИЯ ===
|
||||
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
|
||||
boolean sessionRestored = AuthManager.loadSavedSession();
|
||||
|
||||
if (!sessionRestored) {
|
||||
LoginMenu loginMenu = new LoginMenu();
|
||||
boolean loggedIn = loginMenu.show();
|
||||
if (!loggedIn) {
|
||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||
ZAnsi.uninstall();
|
||||
System.exit(0);
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
|
||||
}
|
||||
// === КОНЕЦ АВТОРИЗАЦИИ ===
|
||||
|
||||
try {
|
||||
mainLoop();
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
ZAnsi.uninstall();
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkAndAutoUpdateLauncher() {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
|
||||
|
||||
try {
|
||||
String json = ZHttpClient.getLauncherVersionInfo();
|
||||
String serverVersion = extractVersion(json);
|
||||
|
||||
System.out.println(ZAnsi.white("Текущая версия: ") + CURRENT_VERSION);
|
||||
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion);
|
||||
|
||||
if (Version.isNewer(CURRENT_VERSION, serverVersion)) {
|
||||
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
|
||||
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
|
||||
|
||||
performAutoUpdate(serverVersion);
|
||||
restartLauncher();
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void performAutoUpdate(String newVersion) throws Exception {
|
||||
String downloadUrl = ZHttpClient.getBaseUrl() + "/launcher/download?type=jar";
|
||||
Path currentJar = getCurrentJarPath();
|
||||
Path tempJar = currentJar.getParent().resolve("zernmc-launcher-new.jar");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание версии " + newVersion + "..."));
|
||||
|
||||
HttpClient client = HttpClient.newBuilder().build();
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(java.net.URI.create(downloadUrl))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(tempJar));
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("Сервер вернул код: " + response.statusCode());
|
||||
}
|
||||
|
||||
long size = Files.size(tempJar);
|
||||
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
|
||||
|
||||
// Заменяем текущий jar
|
||||
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
|
||||
}
|
||||
|
||||
private static void restartLauncher() {
|
||||
try {
|
||||
String javaPath = System.getProperty("java.home") + "/bin/java";
|
||||
String jarPath = getCurrentJarPath().toAbsolutePath().toString();
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
|
||||
|
||||
new ProcessBuilder(javaPath, "-jar", jarPath)
|
||||
.inheritIO()
|
||||
.start();
|
||||
|
||||
System.exit(0);
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось перезапустить лаунчер."));
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractVersion(String json) {
|
||||
try {
|
||||
return json.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static Path getCurrentJarPath() {
|
||||
try {
|
||||
return Path.of(Main.class.getProtectionDomain()
|
||||
.getCodeSource()
|
||||
.getLocation()
|
||||
.toURI());
|
||||
} catch (Exception e) {
|
||||
return Path.of("zernmc-launcher-1.0-jar-with-dependencies.jar");
|
||||
}
|
||||
}
|
||||
|
||||
private static void mainLoop() throws Exception {
|
||||
while (true) {
|
||||
List<String> options = List.of(
|
||||
"Запустить игру",
|
||||
"Проверка обновлений",
|
||||
"Настройки",
|
||||
"Проверка подключения к серверам Zern",
|
||||
"Выход"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Главное меню", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 4) {
|
||||
System.out.print("\033[H\033[2J");
|
||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||
break;
|
||||
}
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> new LaunchMenu().show();
|
||||
case 1 -> new UpdateMenu().show();
|
||||
case 2 -> new SettingsMenu().show();
|
||||
case 3 -> new ServerCheckMenu().show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.auth;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class AuthManager {
|
||||
|
||||
private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json");
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private static volatile AuthSession session = null;
|
||||
|
||||
public static boolean loadSavedSession() {
|
||||
if (!Files.exists(AUTH_FILE)) return false;
|
||||
try {
|
||||
String json = Files.readString(AUTH_FILE);
|
||||
AuthSession loaded = GSON.fromJson(json, AuthSession.class);
|
||||
if (loaded == null || loaded.accessToken == null) return false;
|
||||
|
||||
session = loaded;
|
||||
if (isAccessTokenExpired()) {
|
||||
return tryRefresh();
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static AuthResult login(String username, String password) {
|
||||
return authRequest("/auth/login", username, password);
|
||||
}
|
||||
|
||||
public static AuthResult register(String username, String password) {
|
||||
return authRequest("/auth/register", username, password);
|
||||
}
|
||||
|
||||
private static AuthResult authRequest(String endpoint, String username, String password) {
|
||||
try {
|
||||
String body = GSON.toJson(new LoginRequest(username, password));
|
||||
|
||||
//System.out.println(ZAnsi.cyan("[AUTH] Отправка запроса: " + endpoint));
|
||||
|
||||
SimpleHttpResponse resp = post(endpoint, body);
|
||||
|
||||
//System.out.println(ZAnsi.cyan("[AUTH] Ответ: HTTP " + resp.statusCode()));
|
||||
|
||||
if (resp.statusCode() == 200) {
|
||||
session = GSON.fromJson(resp.body(), AuthSession.class);
|
||||
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
|
||||
saveSession();
|
||||
return AuthResult.ok();
|
||||
} else if (resp.statusCode() == 422) {
|
||||
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
|
||||
} else {
|
||||
return AuthResult.fail(extractError(resp.body()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
//System.err.println(ZAnsi.red("[AUTH] Исключение: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void logout() {
|
||||
if (session != null && session.refreshToken != null) {
|
||||
try {
|
||||
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
session = null;
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
public static boolean isLoggedIn() {
|
||||
return session != null && session.accessToken != null;
|
||||
}
|
||||
|
||||
public static String getUsername() {
|
||||
return session != null ? session.username : "Player";
|
||||
}
|
||||
|
||||
public static String getUuid() {
|
||||
return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000";
|
||||
}
|
||||
|
||||
public static String getAccessToken() {
|
||||
if (session == null) return "0";
|
||||
if (isAccessTokenExpired()) {
|
||||
tryRefresh();
|
||||
}
|
||||
return session != null && session.accessToken != null ? session.accessToken : "0";
|
||||
}
|
||||
|
||||
private static boolean isAccessTokenExpired() {
|
||||
if (session == null) return true;
|
||||
return System.currentTimeMillis() / 1000L >= session.expiresAt - 300;
|
||||
}
|
||||
|
||||
private static boolean tryRefresh() {
|
||||
if (session == null || session.refreshToken == null) return false;
|
||||
try {
|
||||
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
|
||||
SimpleHttpResponse resp = post("/auth/refresh", body);
|
||||
|
||||
if (resp.statusCode() == 200) {
|
||||
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
|
||||
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
|
||||
session = newSession;
|
||||
saveSession();
|
||||
return true;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
session = null;
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void saveSession() {
|
||||
try {
|
||||
Files.createDirectories(AUTH_FILE.getParent());
|
||||
Files.writeString(AUTH_FILE, GSON.toJson(session));
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
|
||||
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
||||
|
||||
java.net.HttpURLConnection conn = null;
|
||||
try {
|
||||
java.net.URL url = java.net.URI.create(fullUrl).toURL();
|
||||
conn = (java.net.HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
|
||||
|
||||
// Добавляем токен авторизации, если есть сессия
|
||||
if (session != null && session.accessToken != null) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
|
||||
}
|
||||
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(15000);
|
||||
|
||||
try (java.io.OutputStream os = conn.getOutputStream()) {
|
||||
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
||||
os.write(input, 0, input.length);
|
||||
}
|
||||
|
||||
int statusCode = conn.getResponseCode();
|
||||
|
||||
java.io.InputStream is = (statusCode >= 200 && statusCode < 300)
|
||||
? conn.getInputStream()
|
||||
: conn.getErrorStream();
|
||||
|
||||
String responseBody;
|
||||
try (java.util.Scanner scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
||||
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||
}
|
||||
|
||||
return new SimpleHttpResponse(statusCode, responseBody);
|
||||
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractError(String body) {
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
|
||||
if (json.has("detail")) {
|
||||
if (json.get("detail").isJsonArray()) {
|
||||
return json.getAsJsonArray("detail").get(0).getAsJsonObject()
|
||||
.get("msg").getAsString();
|
||||
}
|
||||
return json.get("detail").getAsString();
|
||||
}
|
||||
if (json.has("error")) {
|
||||
return json.get("error").getAsString();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||
}
|
||||
|
||||
public static boolean hasActivePass() {
|
||||
if (!isLoggedIn()) return false;
|
||||
try {
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
return response.contains("\"is_active\":true");
|
||||
} catch (Exception e) {
|
||||
System.err.println("Не удалось проверить проходки: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static String activatePass(String passCode) {
|
||||
try {
|
||||
String json = "{\"pass_code\":\"" + passCode.toUpperCase() + "\"}";
|
||||
SimpleHttpResponse resp = post("/auth/pass/activate", json);
|
||||
|
||||
System.out.println(ZAnsi.cyan("[AUTH] Активация проходки: HTTP " + resp.statusCode()));
|
||||
|
||||
if (resp.statusCode() == 200) {
|
||||
return "Проходка успешно активирована!";
|
||||
} else if (resp.statusCode() == 401) {
|
||||
return "Ошибка: Требуется авторизация. Перезайдите в аккаунт.";
|
||||
} else {
|
||||
String error = extractError(resp.body());
|
||||
return "Ошибка: " + error;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return "Ошибка соединения: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
|
||||
|
||||
public static class AuthSession {
|
||||
@SerializedName("access_token") public String accessToken;
|
||||
@SerializedName("refresh_token") public String refreshToken;
|
||||
@SerializedName("expires_in") public int expiresIn;
|
||||
public transient long expiresAt;
|
||||
public String username;
|
||||
public String uuid;
|
||||
}
|
||||
|
||||
private static class LoginRequest {
|
||||
final String username;
|
||||
final String password;
|
||||
LoginRequest(String u, String p) { this.username = u; this.password = p; }
|
||||
}
|
||||
|
||||
public static class AuthResult {
|
||||
public final boolean success;
|
||||
public final String error;
|
||||
private AuthResult(boolean s, String e) { success = s; error = e; }
|
||||
public static AuthResult ok() { return new AuthResult(true, null); }
|
||||
public static AuthResult fail(String msg) { return new AuthResult(false, msg); }
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ======================
|
||||
class SimpleHttpResponse {
|
||||
final int statusCode;
|
||||
final String body;
|
||||
|
||||
SimpleHttpResponse(int statusCode, String body) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
int statusCode() { return statusCode; }
|
||||
String body() { return body; }
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.menu;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.Input;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class LaunchMenu {
|
||||
|
||||
public void show() throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
List<Instance> instances = InstanceManager.getAllInstances();
|
||||
|
||||
List<String> options = instances.stream()
|
||||
.map(Instance::toString)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
options.add("Установить новую сборку");
|
||||
options.add("Назад в главное меню");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Управление сборками", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1) break;
|
||||
if (choice == options.size() - 1) break;
|
||||
|
||||
if (choice == instances.size()) {
|
||||
installNewPack();
|
||||
continue;
|
||||
}
|
||||
|
||||
Instance selected = instances.get(choice);
|
||||
manageInstance(selected);
|
||||
}
|
||||
}
|
||||
|
||||
private void installNewPack() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Установить сборку с сервера ZernMC",
|
||||
"Установить Vanilla Minecraft",
|
||||
"Создать сборку вручную (Fabric/Forge)",
|
||||
"Назад"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Установка новой сборки", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 3) return;
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> {
|
||||
try {
|
||||
installServerPack();
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
}
|
||||
case 1 -> createVanillaInstance();
|
||||
case 2 -> createCustomInstance();
|
||||
}
|
||||
}
|
||||
|
||||
private void installServerPack() throws Exception {
|
||||
if (!AuthManager.hasActivePass()) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
|
||||
System.out.println(ZAnsi.white("Чтобы скачивать сборки с сервера ZernMC, необходимо активировать проходку."));
|
||||
System.out.println();
|
||||
System.out.print(ZAnsi.white("Введите код проходки (ZERN-XXXXXXX) или Enter для отмены: "));
|
||||
|
||||
String code = Input.readLine();
|
||||
if (code.isEmpty()) return;
|
||||
|
||||
String result = AuthManager.activatePass(code);
|
||||
System.out.println(ZAnsi.cyan(result));
|
||||
|
||||
if (!result.contains("успешно")) {
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Повторная проверка
|
||||
if (!AuthManager.hasActivePass()) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось активировать проходку."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Получение списка доступных сборок с сервера..."));
|
||||
|
||||
PackDownloader tempDownloader = new PackDownloader(null);
|
||||
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
|
||||
|
||||
if (availablePacks.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Нет доступных сборок на сервере."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Исправлено: убраны спецсимволы для Windows
|
||||
List<String> options = availablePacks.stream()
|
||||
.map(p -> String.format("%s [%s + %s v%d] - %d файлов",
|
||||
p.getName(),
|
||||
p.getMinecraftVersion(),
|
||||
p.getLoaderType(),
|
||||
p.getVersion(),
|
||||
p.getFilesCount()))
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
|
||||
ServerPack selected = availablePacks.get(choice);
|
||||
|
||||
// Запрашиваем имя для локальной сборки
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
|
||||
System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion());
|
||||
System.out.println(ZAnsi.white(" Лоадер: ") + selected.getLoaderType() + " " + selected.getLoaderVersion());
|
||||
System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion());
|
||||
System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount());
|
||||
System.out.println();
|
||||
|
||||
System.out.print(ZAnsi.white("Введите название локальной сборки (Enter = использовать имя пака): "));
|
||||
String localName = Input.readLine();
|
||||
if (localName.isEmpty()) {
|
||||
localName = selected.getName();
|
||||
}
|
||||
|
||||
// Проверяем, существует ли уже такая сборка
|
||||
if (InstanceManager.getInstance(localName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем инстанс
|
||||
InstanceManager.createInstanceFolder(localName);
|
||||
Instance newInstance = InstanceManager.getInstance(localName);
|
||||
|
||||
// Устанавливаем сборку
|
||||
PackDownloader packDownloader = new PackDownloader(newInstance);
|
||||
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void createVanillaInstance() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||
|
||||
List<String> versionOptions = allVersions.stream()
|
||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||
.collect(Collectors.toList());
|
||||
versionOptions.add("Назад");
|
||||
|
||||
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
||||
int versionChoice = versionMenu.show();
|
||||
|
||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||
|
||||
MinecraftVersion selectedMc = allVersions.get(versionChoice);
|
||||
String mcVersion = selectedMc.getId();
|
||||
|
||||
String packName = askPackName();
|
||||
if (packName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(packName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
InstanceManager.createInstanceFolder(packName);
|
||||
Instance newInstance = InstanceManager.getInstance(packName);
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(newInstance);
|
||||
boolean success = lib.installMinecraft(mcVersion);
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void createCustomInstance() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||
|
||||
List<String> versionOptions = allVersions.stream()
|
||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||
.collect(Collectors.toList());
|
||||
versionOptions.add("Назад");
|
||||
|
||||
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
||||
int versionChoice = versionMenu.show();
|
||||
|
||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||
|
||||
MinecraftVersion selectedMc = allVersions.get(versionChoice);
|
||||
String mcVersion = selectedMc.getId();
|
||||
|
||||
// === Выбор лоадера с правильной проверкой поддержки ===
|
||||
List<String> loaderOptions = buildLoaderOptions(mcVersion);
|
||||
ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions);
|
||||
int loaderChoice = loaderMenu.show();
|
||||
|
||||
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
|
||||
|
||||
String selectedLoader = loaderOptions.get(loaderChoice);
|
||||
|
||||
if (selectedLoader.contains("Vanilla")) {
|
||||
createVanillaInstance();
|
||||
return;
|
||||
}
|
||||
|
||||
String loaderType = selectedLoader.contains("Fabric") ? "fabric" : "forge";
|
||||
|
||||
String loaderVersion;
|
||||
if (loaderType.equals("fabric")) {
|
||||
loaderVersion = askFabricLoaderVersion();
|
||||
} else {
|
||||
loaderVersion = askForgeVersion(mcVersion);
|
||||
}
|
||||
|
||||
if (loaderVersion == null) return;
|
||||
|
||||
String packName = askPackName();
|
||||
if (packName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(packName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
InstanceManager.createInstanceFolder(packName);
|
||||
Instance newInstance = InstanceManager.getInstance(packName);
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(newInstance);
|
||||
|
||||
boolean success = loaderType.equals("fabric")
|
||||
? lib.installFabric(mcVersion, loaderVersion)
|
||||
: lib.installForge(mcVersion, loaderVersion);
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
// ====================== Вспомогательные методы ======================
|
||||
|
||||
private List<String> buildLoaderOptions(String mcVersion) {
|
||||
List<String> options = new ArrayList<>();
|
||||
|
||||
if (isFabricSupported(mcVersion)) {
|
||||
options.add("Fabric");
|
||||
}
|
||||
if (isForgeSupported(mcVersion)) {
|
||||
options.add("Forge");
|
||||
}
|
||||
options.add("Vanilla");
|
||||
options.add("Назад");
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private boolean isFabricSupported(String version) {
|
||||
return version.matches("^1\\.(1[4-9]|[2-9]\\d).*");
|
||||
}
|
||||
|
||||
private boolean isForgeSupported(String version) {
|
||||
if (version.matches("^1\\.2[2-9].*") || version.matches("^\\d{2}.*")) {
|
||||
return false;
|
||||
}
|
||||
return version.matches("^1\\.(1[2-9]|[2-9]\\d).*") ||
|
||||
version.matches("^1\\.20.*") || version.matches("^1\\.21.*");
|
||||
}
|
||||
|
||||
private void manageInstance(Instance instance) throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName()));
|
||||
System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion()));
|
||||
System.out.println(ZAnsi.white("Лоадер: " + instance.getLoaderType() +
|
||||
(instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : "")));
|
||||
|
||||
if (instance.isServerPack()) {
|
||||
System.out.println(ZAnsi.green("Серверная сборка: v" + instance.getServerVersion()));
|
||||
}
|
||||
|
||||
List<String> options = new ArrayList<>();
|
||||
options.add("Запустить сборку");
|
||||
if (instance.isServerPack()) {
|
||||
options.add("Проверить обновления");
|
||||
}
|
||||
options.add("Изменить версию лоадера");
|
||||
options.add("Удалить сборку");
|
||||
options.add("Назад");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Действия", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> launchExistingInstance(instance);
|
||||
case 1 -> {
|
||||
if (instance.isServerPack()) {
|
||||
checkAndUpdateServerPack(instance);
|
||||
} else {
|
||||
changeLoaderVersion(instance);
|
||||
}
|
||||
}
|
||||
case 2 -> {
|
||||
if (instance.isServerPack()) {
|
||||
changeLoaderVersion(instance);
|
||||
} else {
|
||||
deleteInstance(instance);
|
||||
}
|
||||
}
|
||||
case 3 -> deleteInstance(instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAndUpdateServerPack(Instance instance) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName()));
|
||||
|
||||
PackDownloader downloader = new PackDownloader(instance);
|
||||
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||
|
||||
if (!hasUpdate) {
|
||||
System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightYellow("Доступно обновление!"));
|
||||
if (Input.confirm("Обновить сборку")) {
|
||||
boolean success = downloader.updatePack(instance.getServerPackName());
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("Сборка успешно обновлена!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось обновить сборку."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Обновление отменено."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void changeLoaderVersion(Instance instance) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Изменение версии лоадера для " + instance.getName()));
|
||||
|
||||
String currentLoader = instance.getLoaderType();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
|
||||
if ("vanilla".equalsIgnoreCase(currentLoader)) {
|
||||
System.out.println(ZAnsi.yellow("Это vanilla сборка. Нельзя изменить лоадер."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
String newLoaderVersion;
|
||||
if ("fabric".equalsIgnoreCase(currentLoader)) {
|
||||
newLoaderVersion = askFabricLoaderVersion();
|
||||
} else {
|
||||
newLoaderVersion = askForgeVersion(mcVersion);
|
||||
}
|
||||
|
||||
if (newLoaderVersion == null) return;
|
||||
|
||||
System.out.println(ZAnsi.cyan("Переустановка лоадера " + currentLoader + " -> " + newLoaderVersion + "..."));
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
boolean success;
|
||||
|
||||
try {
|
||||
if ("fabric".equalsIgnoreCase(currentLoader)) {
|
||||
success = lib.installFabric(mcVersion, newLoaderVersion);
|
||||
} else {
|
||||
success = lib.installForge(mcVersion, newLoaderVersion);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось изменить версию лоадера."));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage()));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void deleteInstance(Instance instance) throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> confirmOptions = List.of(
|
||||
"Да, удалить сборку",
|
||||
"Нет, отменить"
|
||||
);
|
||||
|
||||
ArrowMenu confirmMenu = new ArrowMenu(
|
||||
"Вы действительно хотите удалить сборку '" + instance.getName() + "'?",
|
||||
confirmOptions
|
||||
);
|
||||
|
||||
int choice = confirmMenu.show();
|
||||
|
||||
if (choice == 0) {
|
||||
boolean deleted = InstanceManager.deleteInstance(instance.getName());
|
||||
if (deleted) {
|
||||
System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена."));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось удалить сборку."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Удаление отменено."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private String askFabricLoaderVersion() throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader..."));
|
||||
List<String> versions = ZHttpClient.getFabricLoaderVersions();
|
||||
|
||||
List<String> options = versions.stream()
|
||||
.limit(30)
|
||||
.map(v -> "Fabric Loader " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выбор версии Fabric Loader", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
return versions.get(choice);
|
||||
}
|
||||
|
||||
private String askForgeVersion(String mcVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Forge для " + mcVersion + "..."));
|
||||
|
||||
List<String> allForgeVersions = getAllForgeVersions();
|
||||
|
||||
List<String> compatibleVersions = allForgeVersions.stream()
|
||||
.filter(v -> v.startsWith(mcVersion + "-"))
|
||||
.map(v -> v.substring(mcVersion.length() + 1))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (compatibleVersions.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion));
|
||||
ConsoleUtils.pause();
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> options = compatibleVersions.stream()
|
||||
.limit(30)
|
||||
.map(v -> "Forge " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выбор версии Forge для " + mcVersion, options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
|
||||
return compatibleVersions.get(choice);
|
||||
}
|
||||
|
||||
private String askPackName() {
|
||||
System.out.print(ZAnsi.white("\nВведите название новой сборки: "));
|
||||
String name = Input.readLine();
|
||||
if (name.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Отменено."));
|
||||
return null;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private void launchExistingInstance(Instance instance) {
|
||||
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
|
||||
// Авторизация Minecraft
|
||||
options.setUsername(AuthManager.getUsername());
|
||||
options.setUuid(AuthManager.getUuid());
|
||||
options.setAccessToken(AuthManager.getAccessToken());
|
||||
|
||||
try {
|
||||
lib.launch(options);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private List<String> getAllForgeVersions() throws Exception {
|
||||
String metadataUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml";
|
||||
|
||||
String xml = ZHttpClient.downloadString(metadataUrl);
|
||||
|
||||
List<String> versions = new ArrayList<>();
|
||||
int index = 0;
|
||||
|
||||
while ((index = xml.indexOf("<version>", index)) != -1) {
|
||||
int start = index + 9;
|
||||
int end = xml.indexOf("</version>", start);
|
||||
if (end == -1) break;
|
||||
|
||||
String version = xml.substring(start, end).trim();
|
||||
versions.add(version);
|
||||
index = end;
|
||||
}
|
||||
|
||||
versions.sort((a, b) -> b.compareTo(a));
|
||||
|
||||
return versions;
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.menu;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager.AuthResult;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.Input;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Экран входа/регистрации.
|
||||
* Показывается при старте лаунчера, если нет сохранённой сессии.
|
||||
*
|
||||
* show() возвращает true — пользователь вошёл/зарегистрировался
|
||||
* false — пользователь выбрал выход из лаунчера
|
||||
*/
|
||||
public class LoginMenu {
|
||||
|
||||
/**
|
||||
* Главный экран выбора действия.
|
||||
*/
|
||||
public boolean show() throws IOException {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Войти в аккаунт",
|
||||
"Создать аккаунт",
|
||||
"Выйти из лаунчера"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Добро пожаловать в ZernMC!", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 2) return false;
|
||||
|
||||
boolean success = switch (choice) {
|
||||
case 0 -> doLogin();
|
||||
case 1 -> doRegister();
|
||||
default -> false;
|
||||
};
|
||||
|
||||
if (success) return true;
|
||||
// Если не успех — покажем меню снова (ошибка уже напечатана внутри методов)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывается когда пользователь уже вошёл — предлагает выйти из аккаунта.
|
||||
*/
|
||||
public void showAccountMenu() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
System.out.println(ZAnsi.header("=== Аккаунт ==="));
|
||||
System.out.println();
|
||||
System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
|
||||
System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid()));
|
||||
System.out.println();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Выйти из аккаунта",
|
||||
"Назад"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == 0) {
|
||||
AuthManager.logout();
|
||||
System.out.println(ZAnsi.yellow("Вы вышли из аккаунта."));
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ПРИВАТНЫЕ МЕТОДЫ ======================
|
||||
|
||||
private boolean doLogin() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]"));
|
||||
System.out.println();
|
||||
|
||||
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
|
||||
if (username.isEmpty()) return false;
|
||||
|
||||
String password = readPassword(" Пароль: ");
|
||||
if (password.isEmpty()) return false;
|
||||
|
||||
System.out.println();
|
||||
System.out.print(ZAnsi.cyan(" Выполняем вход..."));
|
||||
|
||||
AuthResult result = AuthManager.login(username, password);
|
||||
|
||||
if (result.success) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! "));
|
||||
ConsoleUtils.pause();
|
||||
return true;
|
||||
} else {
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean doRegister() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]"));
|
||||
System.out.println();
|
||||
System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _"));
|
||||
System.out.println(ZAnsi.yellow(" Длина имени: 3-16 символов | Длина пароля: от 6 символов"));
|
||||
System.out.println();
|
||||
|
||||
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
|
||||
if (username.isEmpty()) return false;
|
||||
|
||||
String password = readPassword(" Пароль: ");
|
||||
if (password.isEmpty()) return false;
|
||||
|
||||
String confirm = readPassword(" Повторите пароль: ");
|
||||
if (!password.equals(confirm)) {
|
||||
System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!"));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
System.out.print(ZAnsi.cyan(" Создаём аккаунт..."));
|
||||
|
||||
AuthResult result = AuthManager.register(username, password);
|
||||
|
||||
if (result.success) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! "));
|
||||
ConsoleUtils.pause();
|
||||
return true;
|
||||
} else {
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читаем пароль — стараемся скрыть вывод через Console,
|
||||
* если недоступно (IDE/терминал без TTY) — читаем обычным способом.
|
||||
*/
|
||||
private String readPassword(String prompt) {
|
||||
java.io.Console console = System.console();
|
||||
if (console != null) {
|
||||
char[] chars = console.readPassword(prompt);
|
||||
return chars != null ? new String(chars) : "";
|
||||
}
|
||||
// Fallback: в IDE пароль будет виден
|
||||
return Input.readLine(prompt);
|
||||
}
|
||||
|
||||
private void printBanner() {
|
||||
System.out.println(ZAnsi.header("╔══════════════════════════════╗"));
|
||||
System.out.println(ZAnsi.header("║ ZernMC Launcher ║"));
|
||||
System.out.println(ZAnsi.header("╚══════════════════════════════╝"));
|
||||
System.out.println();
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.menu;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public class ServerCheckMenu {
|
||||
|
||||
public void show() throws IOException {
|
||||
List<String> options = List.of(
|
||||
"Проверить подключение к ZernMC серверу",
|
||||
"Проверить доступ к Mojang (Minecraft)",
|
||||
"Проверить доступ к Fabric Meta",
|
||||
"Назад в главное меню"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Диагностика подключения", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 4) return;
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> checkZernServer();
|
||||
case 1 -> checkMojang();
|
||||
case 2 -> checkFabric();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void checkZernServer() {
|
||||
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
||||
try {
|
||||
String response = ZHttpClient.get("/health");
|
||||
System.out.println(ZAnsi.brightGreen("Сервер успешно подключён!"));
|
||||
System.out.println("Ответ: " + response);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось подключиться к ZernMC серверу"));
|
||||
System.out.println("Ошибка: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkMojang() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("Mojang доступен"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Mojang вернул код " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Нет доступа к Mojang"));
|
||||
System.out.println("Ошибка: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkFabric() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://meta.fabricmc.net/v2/versions"))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("Fabric Meta доступен"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Fabric Meta вернул код " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Нет доступа к Fabric Meta"));
|
||||
System.out.println("Ошибка: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.menu;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.Input;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class SettingsMenu {
|
||||
|
||||
public void show() throws IOException {
|
||||
List<String> options = List.of(
|
||||
"Настроить путь к Java",
|
||||
"Настроить выделенную память (RAM)",
|
||||
"Дополнительные JVM параметры",
|
||||
"Назад в главное меню"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Настройки лаунчера", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 3) return;
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> configureJava();
|
||||
case 1 -> configureRam();
|
||||
case 2 -> configureJvmArgs();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void configureJava() {
|
||||
System.out.println(ZAnsi.cyan("Путь к Java:"));
|
||||
System.out.println(" " + Config.getJreDir().toAbsolutePath());
|
||||
System.out.println(ZAnsi.white("\nJava будет искаться автоматически в папке ~/.zernmc/jre/"));
|
||||
System.out.println("Если нужно — положите туда свою версию Java.");
|
||||
}
|
||||
|
||||
private void configureRam() {
|
||||
System.out.println(ZAnsi.cyan("Настройка выделенной памяти"));
|
||||
System.out.println(Config.getRamInfo());
|
||||
|
||||
int newRam = Input.readInt(
|
||||
ZAnsi.white("\nВведите новое значение RAM в MB (или 0 для отмены): "),
|
||||
0, 32768
|
||||
);
|
||||
|
||||
if (newRam == 0) {
|
||||
System.out.println(ZAnsi.yellow("Настройка отменена."));
|
||||
return;
|
||||
}
|
||||
|
||||
Config.setMaxMemory(newRam);
|
||||
System.out.println(ZAnsi.brightGreen("Выделенная память изменена на " + newRam + " MB"));
|
||||
}
|
||||
|
||||
private void configureJvmArgs() {
|
||||
System.out.println(ZAnsi.yellow("Дополнительные JVM параметры"));
|
||||
System.out.println("Пока в разработке.");
|
||||
System.out.println("В будущем здесь будет список предустановленных оптимизаций.");
|
||||
}
|
||||
}
|
||||
-373
@@ -1,373 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class LaunchCommandBuilder {
|
||||
|
||||
private final Instance instance;
|
||||
|
||||
public LaunchCommandBuilder(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public List<String> build(LaunchOptions options) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "..."));
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
|
||||
// 1. Путь к Java
|
||||
String javaPath = getJavaPath();
|
||||
command.add(javaPath);
|
||||
|
||||
// 2. JVM аргументы
|
||||
command.addAll(getJvmArguments(options));
|
||||
|
||||
// 3. Natives
|
||||
Path nativesDir = instance.getPath().resolve("natives");
|
||||
if (!Files.exists(nativesDir)) {
|
||||
Files.createDirectories(nativesDir);
|
||||
}
|
||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
||||
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
|
||||
if ("forge".equals(loaderType)) {
|
||||
command.addAll(getForgeJvmArguments());
|
||||
command.add("-cp");
|
||||
command.add(buildForgeClasspath());
|
||||
command.add("cpw.mods.modlauncher.Launcher");
|
||||
command.addAll(getForgeArguments(options));
|
||||
} else {
|
||||
command.add("-cp");
|
||||
command.add(buildClasspath());
|
||||
command.add(getMainClass());
|
||||
command.addAll(getMinecraftArguments(options));
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private String getJavaPath() {
|
||||
return "java";
|
||||
}
|
||||
|
||||
private List<String> getJvmArguments(LaunchOptions options) {
|
||||
List<String> jvmArgs = new ArrayList<>();
|
||||
|
||||
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
|
||||
jvmArgs.add("-Xmx" + ramMB + "M");
|
||||
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
|
||||
|
||||
jvmArgs.add("-XX:+UseG1GC");
|
||||
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
|
||||
jvmArgs.add("-XX:G1NewSizePercent=20");
|
||||
jvmArgs.add("-XX:G1ReservePercent=20");
|
||||
jvmArgs.add("-XX:MaxGCPauseMillis=50");
|
||||
jvmArgs.add("-XX:G1HeapRegionSize=32M");
|
||||
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
}
|
||||
|
||||
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
|
||||
jvmArgs.addAll(options.getExtraJvmArgs());
|
||||
}
|
||||
|
||||
return jvmArgs;
|
||||
}
|
||||
|
||||
private List<String> getForgeJvmArguments() {
|
||||
List<String> jvmArgs = new ArrayList<>();
|
||||
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
|
||||
jvmArgs.add("-Dforge.logging.console.level=debug");
|
||||
jvmArgs.add("-Dforge.logging.mojang.level=info");
|
||||
jvmArgs.add("-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-");
|
||||
jvmArgs.add("-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar");
|
||||
|
||||
return jvmArgs;
|
||||
}
|
||||
|
||||
private String buildClasspath() throws Exception {
|
||||
List<String> paths = new ArrayList<>();
|
||||
|
||||
String versionId = getVersionId();
|
||||
|
||||
Path versionJar = instance.getPath()
|
||||
.resolve("versions")
|
||||
.resolve(versionId)
|
||||
.resolve(versionId + ".jar");
|
||||
|
||||
if (Files.exists(versionJar)) {
|
||||
paths.add(versionJar.toAbsolutePath().toString());
|
||||
} else {
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
Path fallbackJar = instance.getPath()
|
||||
.resolve("versions")
|
||||
.resolve(mcVersion)
|
||||
.resolve(mcVersion + ".jar");
|
||||
if (Files.exists(fallbackJar)) {
|
||||
paths.add(fallbackJar.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(p -> p.toString().endsWith(".jar"))
|
||||
.map(p -> p.toAbsolutePath().toString())
|
||||
.forEach(paths::add);
|
||||
}
|
||||
}
|
||||
|
||||
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
||||
return String.join(separator, paths);
|
||||
}
|
||||
|
||||
private String buildForgeClasspath() throws Exception {
|
||||
List<String> paths = new ArrayList<>();
|
||||
|
||||
String versionId = getVersionId();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
String forgeVersion = instance.getLoaderVersion();
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(p -> p.toString().endsWith(".jar"))
|
||||
.map(p -> p.toAbsolutePath().toString())
|
||||
.forEach(paths::add);
|
||||
}
|
||||
}
|
||||
|
||||
Path versionJar = instance.getPath()
|
||||
.resolve("versions")
|
||||
.resolve(versionId)
|
||||
.resolve(versionId + ".jar");
|
||||
if (Files.exists(versionJar)) {
|
||||
paths.add(0, versionJar.toAbsolutePath().toString());
|
||||
} else {
|
||||
Path vanillaJar = instance.getPath()
|
||||
.resolve("versions")
|
||||
.resolve(mcVersion)
|
||||
.resolve(mcVersion + ".jar");
|
||||
if (Files.exists(vanillaJar)) {
|
||||
paths.add(0, vanillaJar.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
|
||||
Path forgeUniversal = instance.getPath()
|
||||
.resolve("libraries")
|
||||
.resolve("net")
|
||||
.resolve("minecraftforge")
|
||||
.resolve("forge")
|
||||
.resolve(mcVersion + "-" + forgeVersion)
|
||||
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-universal.jar");
|
||||
if (Files.exists(forgeUniversal)) {
|
||||
paths.add(forgeUniversal.toAbsolutePath().toString());
|
||||
}
|
||||
|
||||
Path forgeClient = instance.getPath()
|
||||
.resolve("libraries")
|
||||
.resolve("net")
|
||||
.resolve("minecraftforge")
|
||||
.resolve("forge")
|
||||
.resolve(mcVersion + "-" + forgeVersion)
|
||||
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-client.jar");
|
||||
if (Files.exists(forgeClient)) {
|
||||
paths.add(forgeClient.toAbsolutePath().toString());
|
||||
}
|
||||
|
||||
String[] forgeModules = {"fmlcore", "javafmllanguage", "lowcodelanguage", "mclanguage"};
|
||||
for (String module : forgeModules) {
|
||||
Path modulePath = instance.getPath()
|
||||
.resolve("libraries")
|
||||
.resolve("net")
|
||||
.resolve("minecraftforge")
|
||||
.resolve(module)
|
||||
.resolve(mcVersion + "-" + forgeVersion)
|
||||
.resolve(module + "-" + mcVersion + "-" + forgeVersion + ".jar");
|
||||
if (Files.exists(modulePath)) {
|
||||
paths.add(modulePath.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
|
||||
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
||||
return String.join(separator, paths);
|
||||
}
|
||||
|
||||
private String getMainClass() {
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
|
||||
}
|
||||
else if ("forge".equals(loaderType)) {
|
||||
return "cpw.mods.modlauncher.Launcher";
|
||||
}
|
||||
else {
|
||||
return "net.minecraft.client.main.Main";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ИСПРАВЛЕНО: используем instance.getAssetIndex() вместо minecraftVersion
|
||||
*/
|
||||
private List<String> getMinecraftArguments(LaunchOptions options) {
|
||||
List<String> args = new ArrayList<>();
|
||||
|
||||
args.add("--version");
|
||||
args.add(instance.getName());
|
||||
|
||||
args.add("--gameDir");
|
||||
args.add(instance.getPath().toAbsolutePath().toString());
|
||||
|
||||
args.add("--assetsDir");
|
||||
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
|
||||
|
||||
// FIXED: Используем правильный assetIndex
|
||||
args.add("--assetIndex");
|
||||
String assetIndex = instance.getAssetIndex();
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
assetIndex = instance.getMinecraftVersion();
|
||||
System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex));
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Использую asset index: " + assetIndex));
|
||||
}
|
||||
args.add(assetIndex);
|
||||
|
||||
args.add("--username");
|
||||
args.add(options.getUsername() != null ? options.getUsername() : "Player");
|
||||
|
||||
args.add("--accessToken");
|
||||
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||
|
||||
args.add("--uuid");
|
||||
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||
|
||||
args.add("--userType");
|
||||
args.add("legacy");
|
||||
|
||||
if (options.getWidth() > 0) {
|
||||
args.add("--width");
|
||||
args.add(String.valueOf(options.getWidth()));
|
||||
}
|
||||
if (options.getHeight() > 0) {
|
||||
args.add("--height");
|
||||
args.add(String.valueOf(options.getHeight()));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* ИСПРАВЛЕНО: для Forge тоже используем правильный assetIndex
|
||||
*/
|
||||
private List<String> getForgeArguments(LaunchOptions options) {
|
||||
List<String> args = new ArrayList<>();
|
||||
|
||||
args.add("--launchTarget");
|
||||
args.add("forgeclient");
|
||||
|
||||
args.add("--fml.forgeVersion");
|
||||
args.add(instance.getLoaderVersion());
|
||||
|
||||
args.add("--fml.mcVersion");
|
||||
args.add(instance.getMinecraftVersion());
|
||||
|
||||
args.add("--fml.forgeGroup");
|
||||
args.add("net.minecraftforge");
|
||||
|
||||
args.add("--gameDir");
|
||||
args.add(instance.getPath().toAbsolutePath().toString());
|
||||
|
||||
args.add("--assetsDir");
|
||||
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
|
||||
|
||||
// FIXED: Используем правильный assetIndex для Forge
|
||||
args.add("--assetIndex");
|
||||
String assetIndex = instance.getAssetIndex();
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
assetIndex = instance.getMinecraftVersion();
|
||||
}
|
||||
args.add(assetIndex);
|
||||
|
||||
args.add("--username");
|
||||
args.add(options.getUsername() != null ? options.getUsername() : "Player");
|
||||
|
||||
args.add("--accessToken");
|
||||
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||
|
||||
args.add("--uuid");
|
||||
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||
|
||||
args.add("--userType");
|
||||
args.add("legacy");
|
||||
|
||||
if (options.getWidth() > 0) {
|
||||
args.add("--width");
|
||||
args.add(String.valueOf(options.getWidth()));
|
||||
}
|
||||
if (options.getHeight() > 0) {
|
||||
args.add("--height");
|
||||
args.add(String.valueOf(options.getHeight()));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* ИСПРАВЛЕНО: для Fabric используем сохраненный fabricVersionId
|
||||
*/
|
||||
private String getVersionId() {
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
String loaderVer = instance.getLoaderVersion();
|
||||
|
||||
if ("vanilla".equals(loaderType)) {
|
||||
return mcVersion;
|
||||
}
|
||||
else if ("fabric".equals(loaderType)) {
|
||||
// Используем сохраненный fabricVersionId если есть
|
||||
String fabricId = instance.getFabricVersionId();
|
||||
if (fabricId != null && !fabricId.isEmpty()) {
|
||||
return fabricId;
|
||||
}
|
||||
// fallback
|
||||
return "fabric-loader-" + loaderVer + "-" + mcVersion;
|
||||
}
|
||||
else if ("forge".equals(loaderType)) {
|
||||
return mcVersion + "-forge-" + loaderVer;
|
||||
}
|
||||
|
||||
return mcVersion;
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.ui;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import org.jline.terminal.Terminal;
|
||||
import org.jline.terminal.TerminalBuilder;
|
||||
import org.jline.utils.InfoCmp;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class ArrowMenu {
|
||||
|
||||
private final String title;
|
||||
private final List<String> options;
|
||||
private int selected = 0;
|
||||
private final Terminal terminal;
|
||||
|
||||
private static final int VISIBLE_ITEMS = 7; // сколько строк показывать в списке
|
||||
|
||||
public ArrowMenu(String title, List<String> options) throws IOException {
|
||||
this.title = title;
|
||||
this.options = options;
|
||||
this.terminal = TerminalBuilder.builder()
|
||||
.system(true)
|
||||
.jna(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
public int show() throws IOException {
|
||||
terminal.enterRawMode();
|
||||
terminal.puts(InfoCmp.Capability.clear_screen);
|
||||
terminal.puts(InfoCmp.Capability.cursor_invisible);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
printPagedMenu();
|
||||
int key = terminal.reader().read();
|
||||
|
||||
if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц') { // Up
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
}
|
||||
else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы') { // Down
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
else if (key == 13 || key == 10) { // Enter
|
||||
return selected;
|
||||
}
|
||||
else if (key == 27) { // Esc
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
terminal.puts(InfoCmp.Capability.cursor_visible);
|
||||
terminal.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void printPagedMenu() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\033[H\033[2J");
|
||||
|
||||
// Заголовок (фиксированный)
|
||||
sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n");
|
||||
sb.append(ZAnsi.yellow(title)).append("\n\n");
|
||||
|
||||
// Вычисляем диапазон отображаемых элементов
|
||||
int start = Math.max(0, selected - (VISIBLE_ITEMS / 2));
|
||||
int end = Math.min(options.size(), start + VISIBLE_ITEMS);
|
||||
|
||||
// Если в конце списка — подтягиваем вверх
|
||||
if (end - start < VISIBLE_ITEMS && start > 0) {
|
||||
start = Math.max(0, end - VISIBLE_ITEMS);
|
||||
}
|
||||
|
||||
for (int i = start; i < end; i++) {
|
||||
String line = options.get(i);
|
||||
if (i == selected) {
|
||||
sb.append(ZAnsi.selected(line)).append("\n");
|
||||
} else {
|
||||
sb.append(ZAnsi.white(" " + line)).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Подсказка внизу (фиксированная)
|
||||
sb.append("\n")
|
||||
.append(ZAnsi.white("W/S (Ц/Ы) - перемещение | Enter - выбрать | Esc - назад"));
|
||||
|
||||
System.out.print(sb);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Properties;
|
||||
|
||||
public class Config {
|
||||
|
||||
private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc");
|
||||
private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties");
|
||||
|
||||
private static final Properties props = new Properties();
|
||||
|
||||
// Настройки
|
||||
private static int maxMemory = 4096; // будет перезаписано умной логикой
|
||||
private static String serverUrl = "http://87.120.187.36:1582";
|
||||
private static String lastUsername = "Player";
|
||||
|
||||
static {
|
||||
load();
|
||||
applySmartRamRecommendation();
|
||||
}
|
||||
|
||||
private static void load() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR);
|
||||
if (Files.exists(CONFIG_FILE)) {
|
||||
try (var is = Files.newInputStream(CONFIG_FILE)) {
|
||||
props.load(is);
|
||||
}
|
||||
}
|
||||
|
||||
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
|
||||
serverUrl = props.getProperty("serverUrl", serverUrl);
|
||||
lastUsername = props.getProperty("lastUsername", lastUsername);
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось загрузить конфиг: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void save() {
|
||||
try {
|
||||
props.setProperty("maxMemory", String.valueOf(maxMemory));
|
||||
props.setProperty("serverUrl", serverUrl);
|
||||
props.setProperty("lastUsername", lastUsername);
|
||||
|
||||
try (var os = Files.newOutputStream(CONFIG_FILE)) {
|
||||
props.store(os, "ZernMC Launcher Configuration");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось сохранить конфиг: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Умная рекомендация RAM:
|
||||
* - минимум 1.5 GB
|
||||
* - рекомендуется totalRAM - 30%
|
||||
* - максимум 70% от доступной RAM
|
||||
*/
|
||||
private static void applySmartRamRecommendation() {
|
||||
long totalRamMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); // в MB
|
||||
|
||||
// Рекомендуемое значение = total - 30%
|
||||
long recommended = (long) (totalRamMB * 0.70); // 70% от доступной
|
||||
|
||||
// Ограничения
|
||||
recommended = Math.max(1536, recommended); // минимум 1.5 GB
|
||||
recommended = Math.min(recommended, totalRamMB - 1024); // оставляем минимум 1 GB системе
|
||||
|
||||
// Если текущее значение сильно отличается от рекомендуемого — корректируем
|
||||
if (Math.abs(maxMemory - recommended) > 1024) { // разница больше 1 GB
|
||||
maxMemory = (int) recommended;
|
||||
save(); // сохраняем умную рекомендацию
|
||||
System.out.println(ZAnsi.cyan("Автоматически рекомендовано RAM: " + maxMemory + " MB"));
|
||||
}
|
||||
}
|
||||
|
||||
// Getters & Setters
|
||||
public static int getMaxMemory() {
|
||||
return maxMemory;
|
||||
}
|
||||
|
||||
public static void setMaxMemory(int memory) {
|
||||
// Защита от слишком маленьких/больших значений
|
||||
if (memory < 1024) memory = 1536;
|
||||
if (memory > 32768) memory = 32768;
|
||||
|
||||
maxMemory = memory;
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getServerUrl() {
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
public static String getLastUsername() {
|
||||
return lastUsername;
|
||||
}
|
||||
|
||||
public static void setLastUsername(String username) {
|
||||
lastUsername = username;
|
||||
save();
|
||||
}
|
||||
|
||||
public static Path getInstancesDir() {
|
||||
return CONFIG_DIR.resolve("instances");
|
||||
}
|
||||
|
||||
public static Path getJreDir() {
|
||||
return CONFIG_DIR.resolve("jre");
|
||||
}
|
||||
|
||||
public static Path getConfigDir() {
|
||||
return CONFIG_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полезная информация для пользователя
|
||||
*/
|
||||
public static String getRamInfo() {
|
||||
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
|
||||
return "Доступно RAM: " + totalMB + " MB | Рекомендуется: " + maxMemory + " MB";
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import org.fusesource.jansi.Ansi;
|
||||
import org.fusesource.jansi.AnsiConsole;
|
||||
|
||||
public class ZAnsi {
|
||||
|
||||
//поддержка ANSI епта
|
||||
public static void install() {
|
||||
AnsiConsole.systemInstall();
|
||||
}
|
||||
|
||||
public static void uninstall() {
|
||||
AnsiConsole.systemUninstall();
|
||||
}
|
||||
|
||||
// === Основные цвета ===
|
||||
public static String green(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.GREEN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightGreen(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.GREEN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String cyan(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String yellow(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightYellow(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String red(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.RED).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightRed(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.RED).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String blue(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String white(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightWhite(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// Стили
|
||||
public static String bold(String text) {
|
||||
return Ansi.ansi().bold().a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String reset() {
|
||||
return Ansi.ansi().reset().toString();
|
||||
}
|
||||
|
||||
// Комбинированные удобные методы
|
||||
public static String header(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String selected(String text) {
|
||||
return Ansi.ansi()
|
||||
.bgBright(Ansi.Color.WHITE)
|
||||
.fgBlack()
|
||||
.a(" > " + text + " ")
|
||||
.reset()
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
# admin_router.py
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
import structlog
|
||||
import time
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from auth import get_db, require_role, log_audit, get_current_user
|
||||
from roles import (
|
||||
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
|
||||
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
# ====================== МОДЕЛИ ======================
|
||||
class UpdateRoleRequest(BaseModel):
|
||||
user_id: int
|
||||
role: int = Field(..., ge=0, le=4)
|
||||
|
||||
class PassRequest(BaseModel):
|
||||
username: str
|
||||
reason: Optional[str] = None
|
||||
|
||||
class PassDecision(BaseModel):
|
||||
request_id: int
|
||||
approved: bool
|
||||
reason: Optional[str] = None
|
||||
|
||||
class CreatePassDirectRequest(BaseModel):
|
||||
username: str
|
||||
expires_days: Optional[int] = Field(None, ge=1, le=365)
|
||||
max_uses: int = Field(1, ge=1, le=10)
|
||||
|
||||
class BanUserRequest(BaseModel):
|
||||
user_id: int
|
||||
days: int = Field(..., ge=1, le=365)
|
||||
reason: str
|
||||
|
||||
# ====================== ЭНДПОИНТЫ ======================
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
|
||||
search: Optional[str] = None
|
||||
):
|
||||
"""Список пользователей (модераторы видят всех, но без sensitive данных)"""
|
||||
with get_db() as conn:
|
||||
query = "SELECT id, username, uuid, role, created_at, last_login, is_active"
|
||||
params = []
|
||||
|
||||
if current_user["role"] < ROLE_ELDER:
|
||||
# Модераторы не видят забаненных
|
||||
query += " FROM users WHERE is_active = 1"
|
||||
else:
|
||||
query += " FROM users"
|
||||
|
||||
if search:
|
||||
query += " AND username LIKE ?"
|
||||
params.append(f"%{search}%")
|
||||
|
||||
query += " ORDER BY role DESC, username"
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
users = []
|
||||
for row in rows:
|
||||
user_data = {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||
"created_at": row["created_at"],
|
||||
"last_login": row["last_login"],
|
||||
}
|
||||
|
||||
# Elder и Creator видят больше информации
|
||||
if current_user["role"] >= ROLE_ELDER:
|
||||
user_data["is_active"] = row["is_active"]
|
||||
# Получаем информацию о проходке
|
||||
pass_info = conn.execute("""
|
||||
SELECT p.code, p.expires_at, up.activated_at
|
||||
FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
LIMIT 1
|
||||
""", (row["id"], time.time())).fetchone()
|
||||
|
||||
if pass_info:
|
||||
user_data["has_pass"] = True
|
||||
user_data["pass_expires"] = pass_info["expires_at"]
|
||||
|
||||
users.append(user_data)
|
||||
|
||||
return {"users": users, "total": len(users)}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def get_user_detail(
|
||||
user_id: int,
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR))
|
||||
):
|
||||
"""Детальная информация о пользователе"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT id, username, uuid, role, created_at, last_login, is_active, banned_until
|
||||
FROM users WHERE id = ?
|
||||
""", (user_id,)).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
# Получаем активную проходку
|
||||
pass_info = None
|
||||
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
|
||||
pass_row = conn.execute("""
|
||||
SELECT p.code, p.expires_at, up.activated_at
|
||||
FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
LIMIT 1
|
||||
""", (user_id, time.time())).fetchone()
|
||||
|
||||
if pass_row:
|
||||
pass_info = {
|
||||
"code": pass_row["code"][:8] + "..." if current_user["role"] < ROLE_ELDER else pass_row["code"],
|
||||
"expires_at": pass_row["expires_at"],
|
||||
"activated_at": pass_row["activated_at"]
|
||||
}
|
||||
|
||||
# Логи действий (только для Elder+)
|
||||
actions = []
|
||||
if current_user["role"] >= ROLE_ELDER:
|
||||
action_rows = conn.execute("""
|
||||
SELECT action, details, timestamp FROM audit_log
|
||||
WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20
|
||||
""", (user_id,)).fetchall()
|
||||
actions = [dict(row) for row in action_rows]
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||
"created_at": row["created_at"],
|
||||
"last_login": row["last_login"],
|
||||
"is_active": row["is_active"],
|
||||
"banned_until": row["banned_until"],
|
||||
"has_pass": pass_info is not None,
|
||||
"pass_info": pass_info,
|
||||
"recent_actions": actions if current_user["role"] >= ROLE_ELDER else None
|
||||
}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/role")
|
||||
async def update_user_role(
|
||||
user_id: int,
|
||||
body: UpdateRoleRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Изменение роли пользователя"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE id = ?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
# Проверки прав
|
||||
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Нельзя изменить роль создателя")
|
||||
|
||||
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Нельзя изменять роль пользователя с равным или высшим уровнем")
|
||||
|
||||
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, f"Нельзя назначить роль выше своей ({ROLE_NAMES[current_user['role']]})")
|
||||
|
||||
# Elder не может создавать других Elder (только Creator)
|
||||
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Только создатель может назначать Elder Moderator")
|
||||
|
||||
# Проверяем, нужно ли выдать/отозвать проходку
|
||||
old_role = target["role"]
|
||||
new_role = body.role
|
||||
|
||||
conn.execute(
|
||||
"UPDATE users SET role = ? WHERE id = ?",
|
||||
(new_role, user_id)
|
||||
)
|
||||
|
||||
# Управление проходками при изменении роли
|
||||
now = time.time()
|
||||
|
||||
if new_role >= ROLE_PASS_HOLDER and old_role < ROLE_PASS_HOLDER:
|
||||
# Выдаем проходку если её нет
|
||||
existing = conn.execute("""
|
||||
SELECT 1 FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
""", (user_id, now)).fetchone()
|
||||
|
||||
if not existing:
|
||||
# Создаем автоматическую проходку
|
||||
pass_code = f"AUTO_{secrets.token_hex(8).upper()}"
|
||||
conn.execute("""
|
||||
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
|
||||
VALUES (?, ?, NULL, 1, 1)
|
||||
""", (pass_code, target["username"]))
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
VALUES (?, ?, ?)
|
||||
""", (user_id, pass_code, now))
|
||||
|
||||
logger.info("Auto-pass issued", user=target["username"], role=new_role)
|
||||
|
||||
elif new_role < ROLE_PASS_HOLDER and old_role >= ROLE_PASS_HOLDER:
|
||||
# Отзываем проходку
|
||||
conn.execute("""
|
||||
UPDATE passes SET is_active = 0
|
||||
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
|
||||
""", (user_id,))
|
||||
|
||||
logger.info("Auto-pass revoked", user=target["username"])
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"role_change",
|
||||
f"Changed role of {target['username']} from {old_role} to {new_role}",
|
||||
ip
|
||||
)
|
||||
|
||||
logger.info("Role updated", admin=current_user["username"], target=target["username"], new_role=new_role)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_id": user_id,
|
||||
"username": target["username"],
|
||||
"old_role": old_role,
|
||||
"old_role_name": ROLE_NAMES.get(old_role, "Неизвестно"),
|
||||
"new_role": new_role,
|
||||
"new_role_name": ROLE_NAMES.get(new_role, "Неизвестно")
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pass/grant")
|
||||
async def grant_pass(
|
||||
body: CreatePassDirectRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Выдача проходки пользователю (Elder+ могут выдавать)"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Проверяем право на прямую выдачу
|
||||
if current_user["role"] < ROLE_CREATOR and not has_permission(current_user["role"], Permissions.APPROVE_PASS):
|
||||
raise HTTPException(403, "Недостаточно прав для выдачи проходки")
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
|
||||
(body.username,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, f"Пользователь {body.username} не найден")
|
||||
|
||||
# Проверяем, есть ли уже активная проходка
|
||||
existing = conn.execute("""
|
||||
SELECT p.code FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
""", (target["id"], time.time())).fetchone()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(409, f"У пользователя {body.username} уже есть активная проходка")
|
||||
|
||||
# Создаем проходку
|
||||
pass_code = secrets.token_hex(12).upper()
|
||||
now = time.time()
|
||||
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
""", (pass_code, target["username"], expires_at, body.max_uses))
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
VALUES (?, ?, ?)
|
||||
""", (target["id"], pass_code, now))
|
||||
|
||||
# Обновляем роль если нужно
|
||||
if target["role"] < ROLE_PASS_HOLDER:
|
||||
conn.execute(
|
||||
"UPDATE users SET role = ? WHERE id = ?",
|
||||
(ROLE_PASS_HOLDER, target["id"])
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"grant_pass",
|
||||
f"Granted pass to {target['username']} (expires: {body.expires_days}d, max_uses: {body.max_uses})",
|
||||
ip
|
||||
)
|
||||
|
||||
logger.info("Pass granted", admin=current_user["username"], target=target["username"], code=pass_code)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pass_code": pass_code,
|
||||
"username": target["username"],
|
||||
"expires_at": expires_at,
|
||||
"expires_days": body.expires_days,
|
||||
"max_uses": body.max_uses
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/pass/revoke/{username}")
|
||||
async def revoke_pass(
|
||||
username: str,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Отзыв проходки у пользователя"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
|
||||
(username,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, f"Пользователь {username} не найден")
|
||||
|
||||
# Отзываем проходку
|
||||
conn.execute("""
|
||||
UPDATE passes SET is_active = 0
|
||||
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
|
||||
""", (target["id"],))
|
||||
|
||||
# Понижаем роль если она была только из-за проходки
|
||||
if target["role"] == ROLE_PASS_HOLDER:
|
||||
conn.execute(
|
||||
"UPDATE users SET role = ? WHERE id = ?",
|
||||
(ROLE_USER, target["id"])
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(current_user["id"], "revoke_pass", f"Revoked pass from {username}", ip)
|
||||
logger.info("Pass revoked", admin=current_user["username"], target=username)
|
||||
|
||||
return {"success": True, "message": f"Проходка {username} отозвана"}
|
||||
|
||||
|
||||
@router.post("/user/ban")
|
||||
async def ban_user(
|
||||
body: BanUserRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Бан пользователя (Elder+ могут банить)"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE id = ?",
|
||||
(body.user_id,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
# Нельзя забанить создателя
|
||||
if target["role"] == ROLE_CREATOR:
|
||||
raise HTTPException(403, "Нельзя забанить создателя")
|
||||
|
||||
# Elder не может банить других Elder
|
||||
if target["role"] >= ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Недостаточно прав для бана этого пользователя")
|
||||
|
||||
banned_until = time.time() + (body.days * 86400)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 0, banned_until = ? WHERE id = ?",
|
||||
(banned_until, target["id"])
|
||||
)
|
||||
|
||||
# Отзываем проходку при бане
|
||||
conn.execute("""
|
||||
UPDATE passes SET is_active = 0
|
||||
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
|
||||
""", (target["id"],))
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"ban_user",
|
||||
f"Banned {target['username']} for {body.days} days. Reason: {body.reason}",
|
||||
ip
|
||||
)
|
||||
|
||||
logger.info("User banned", admin=current_user["username"], target=target["username"], days=body.days)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"username": target["username"],
|
||||
"banned_until": banned_until,
|
||||
"days": body.days
|
||||
}
|
||||
|
||||
|
||||
@router.post("/user/unban/{user_id}")
|
||||
async def unban_user(
|
||||
user_id: int,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Разбан пользователя"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username FROM users WHERE id = ?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 1, banned_until = NULL WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(current_user["id"], "unban_user", f"Unbanned {target['username']}", ip)
|
||||
logger.info("User unbanned", admin=current_user["username"], target=target["username"])
|
||||
|
||||
return {"success": True, "username": target["username"]}
|
||||
|
||||
|
||||
@router.get("/audit")
|
||||
async def get_audit_log(
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
user_id: Optional[int] = None
|
||||
):
|
||||
"""Просмотр логов аудита (только Elder+)"""
|
||||
with get_db() as conn:
|
||||
query = """
|
||||
SELECT al.*, u.username
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.id
|
||||
"""
|
||||
params = []
|
||||
|
||||
if user_id:
|
||||
query += " WHERE al.user_id = ?"
|
||||
params.append(user_id)
|
||||
|
||||
query += " ORDER BY al.timestamp DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
total = conn.execute("SELECT COUNT(*) as count FROM audit_log").fetchone()["count"]
|
||||
|
||||
return {
|
||||
"logs": [dict(row) for row in rows],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR))
|
||||
):
|
||||
"""Статистика для админов"""
|
||||
with get_db() as conn:
|
||||
# Общая статистика
|
||||
total_users = conn.execute("SELECT COUNT(*) as count FROM users").fetchone()["count"]
|
||||
|
||||
# Статистика по ролям
|
||||
role_stats = conn.execute("""
|
||||
SELECT role, COUNT(*) as count
|
||||
FROM users
|
||||
GROUP BY role
|
||||
ORDER BY role DESC
|
||||
""").fetchall()
|
||||
|
||||
# Активные проходки
|
||||
active_passes = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE p.expires_at IS NULL OR p.expires_at > ?
|
||||
""", (time.time(),)).fetchone()["count"]
|
||||
|
||||
# Забаненные пользователи
|
||||
banned_users = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM users
|
||||
WHERE is_active = 0 AND banned_until > ?
|
||||
""", (time.time(),)).fetchone()["count"]
|
||||
|
||||
# Недавние регистрации (последние 7 дней)
|
||||
week_ago = time.time() - (7 * 86400)
|
||||
recent_registrations = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM users WHERE created_at > ?
|
||||
""", (week_ago,)).fetchone()["count"]
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_passes": active_passes,
|
||||
"banned_users": banned_users,
|
||||
"recent_registrations_7d": recent_registrations,
|
||||
"roles_distribution": [
|
||||
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
|
||||
for r in role_stats
|
||||
],
|
||||
"my_info": {
|
||||
"role": current_user["role"],
|
||||
"role_name": ROLE_NAMES.get(current_user["role"], "Неизвестно"),
|
||||
"username": current_user["username"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_info(current_user: dict = Depends(get_current_user)):
|
||||
"""Информация о текущем пользователе с правами"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT id, username, uuid, role, created_at, last_login
|
||||
FROM users WHERE id = ?
|
||||
""", (current_user["id"],)).fetchone()
|
||||
|
||||
# Проверяем наличие активной проходки
|
||||
has_pass = False
|
||||
if row["role"] >= ROLE_PASS_HOLDER:
|
||||
pass_row = conn.execute("""
|
||||
SELECT 1 FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
""", (current_user["id"], time.time())).fetchone()
|
||||
has_pass = pass_row is not None
|
||||
|
||||
permissions = list(ROLE_PERMISSIONS.get(row["role"], set()))
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||
"created_at": row["created_at"],
|
||||
"last_login": row["last_login"],
|
||||
"has_pass": has_pass,
|
||||
"permissions": permissions
|
||||
}
|
||||
+620
-263
File diff suppressed because it is too large
Load Diff
+40
-2
@@ -15,11 +15,12 @@ def parse_args():
|
||||
mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload")
|
||||
mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers")
|
||||
mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests")
|
||||
|
||||
mode_group.add_argument("--sync", action="store_true", help="Sync mode - sync with main server as mirror")
|
||||
|
||||
# Additional options
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
|
||||
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
|
||||
parser.add_argument("--workers", type=int, default=4, help="Number of workers for production mode")
|
||||
parser.add_argument("--workers", type=int, default=1, help="Number of workers for production mode (default: 1, more causes file download slowdown)")
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
||||
|
||||
return parser.parse_args()
|
||||
@@ -53,6 +54,43 @@ async def run_test_mode():
|
||||
logger.info("All packs validated successfully")
|
||||
sys.exit(0)
|
||||
|
||||
async def run_sync_mode():
|
||||
"""Sync with main server as mirror"""
|
||||
import os
|
||||
|
||||
main_url = os.environ.get("MAIN_SERVER_URL")
|
||||
if not main_url:
|
||||
logger.error("MAIN_SERVER_URL not set. Run: MAIN_SERVER_URL=http://main:1582 python cli.py --sync")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"Starting mirror sync from {main_url}")
|
||||
|
||||
# Get version from main
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get version
|
||||
try:
|
||||
resp = await client.get(f"{main_url}/launcher/version")
|
||||
data = resp.json()
|
||||
version = data.get("version")
|
||||
logger.info(f"Main server version: {version}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get version from main: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get sync manifest
|
||||
try:
|
||||
resp = await client.get(f"{main_url}/launcher/sync/{version}")
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Files to sync: {len(sync_data.get('files', []))}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get sync manifest: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Sync happens during server startup in mirror mode
|
||||
# Just verify we can reach main
|
||||
logger.info("Mirror sync configured. Server will sync on startup.")
|
||||
|
||||
def run_production_mode(host: str, port: int, workers: int):
|
||||
"""Run with multiple workers"""
|
||||
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import structlog
|
||||
import time
|
||||
|
||||
from auth import get_db, get_current_user
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["friends"])
|
||||
|
||||
def init_friends_db():
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS friendships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
requester_id INTEGER NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(requester_id, target_id),
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id),
|
||||
FOREIGN KEY (target_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_status (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
is_online INTEGER DEFAULT 0,
|
||||
current_pack TEXT DEFAULT '',
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_target ON friendships(target_id);
|
||||
""")
|
||||
|
||||
class AddFriendRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
class RemoveFriendRequest(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class AcceptFriendRequest(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class StatusUpdateRequest(BaseModel):
|
||||
online: bool = True
|
||||
current_pack: Optional[str] = None
|
||||
|
||||
@router.post("/friends/add")
|
||||
async def add_friend(
|
||||
req: AddFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute("SELECT id FROM users WHERE username = ?", (req.username,))
|
||||
target = cursor.fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
target_id = target[0]
|
||||
|
||||
if target_id == current_user["id"]:
|
||||
raise HTTPException(400, "Cannot add yourself")
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT status FROM friendships WHERE requester_id = ? AND target_id = ?",
|
||||
(current_user["id"], target_id)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
if existing[0] == "accepted":
|
||||
raise HTTPException(400, "Already friends")
|
||||
raise HTTPException(400, f"Friend request already {existing[0]}")
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO friendships (requester_id, target_id, status) VALUES (?, ?, 'pending')",
|
||||
(current_user["id"], target_id)
|
||||
)
|
||||
logger.info("Friend request sent", from_user=current_user["id"], to_user=target_id)
|
||||
return {"message": "Friend request sent"}
|
||||
|
||||
@router.post("/friends/accept")
|
||||
async def accept_friend(
|
||||
req: AcceptFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, requester_id FROM friendships WHERE target_id = ? AND requester_id = ? AND status = 'pending'",
|
||||
(current_user["id"], req.user_id)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "No pending friend request from this user")
|
||||
conn.execute("UPDATE friendships SET status = 'accepted' WHERE id = ?", (row[0],))
|
||||
logger.info("Friend request accepted", from_user=req.user_id, to_user=current_user["id"])
|
||||
return {"message": "Friend request accepted"}
|
||||
|
||||
@router.post("/friends/remove")
|
||||
async def remove_friend(
|
||||
req: RemoveFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM friendships WHERE (requester_id = ? AND target_id = ?) OR (requester_id = ? AND target_id = ?)",
|
||||
(current_user["id"], req.user_id, req.user_id, current_user["id"])
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Not friends")
|
||||
conn.execute("DELETE FROM friendships WHERE id = ?", (row[0],))
|
||||
logger.info("Friend removed", user=current_user["id"], target=req.user_id)
|
||||
return {"message": "Friend removed"}
|
||||
|
||||
@router.get("/friends/list")
|
||||
async def list_friends(current_user: dict = Depends(get_current_user)):
|
||||
friends = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role,
|
||||
COALESCE(us.is_online, 0) as online,
|
||||
COALESCE(us.current_pack, '') as current_pack,
|
||||
us.last_seen
|
||||
FROM friendships f
|
||||
JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id
|
||||
LEFT JOIN user_status us ON u.id = us.user_id
|
||||
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
|
||||
""", (current_user["id"], current_user["id"], current_user["id"]))
|
||||
|
||||
for row in rows:
|
||||
friends.append({
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
"role": row[2],
|
||||
"online": bool(row[3]),
|
||||
"current_pack": row[4],
|
||||
"last_seen": row[5] if row[5] else None
|
||||
})
|
||||
|
||||
return {"friends": friends}
|
||||
|
||||
@router.get("/friends/requests")
|
||||
async def list_friend_requests(current_user: dict = Depends(get_current_user)):
|
||||
requests = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role, f.created_at
|
||||
FROM friendships f
|
||||
JOIN users u ON f.requester_id = u.id
|
||||
WHERE f.target_id = ? AND f.status = 'pending'
|
||||
""", (current_user["id"],))
|
||||
for row in rows:
|
||||
requests.append({
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
"role": row[2],
|
||||
"created_at": row[3] if row[3] else None
|
||||
})
|
||||
return {"requests": requests}
|
||||
|
||||
@router.post("/friends/status")
|
||||
async def update_status(
|
||||
req: StatusUpdateRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
is_online = excluded.is_online,
|
||||
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
|
||||
last_seen = CURRENT_TIMESTAMP
|
||||
""", (current_user["id"], int(req.online), req.current_pack or ""))
|
||||
return {"status": "ok"}
|
||||
@@ -1,25 +0,0 @@
|
||||
# http_logger.py
|
||||
import logging
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import time
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
class HTTPLogger:
|
||||
"""Custom HTTP logger to catch invalid requests"""
|
||||
|
||||
@staticmethod
|
||||
def log_invalid_request(data: bytes, client_addr: tuple):
|
||||
"""Log invalid HTTP requests"""
|
||||
try:
|
||||
# Try to decode as much as possible
|
||||
request_str = data.decode('utf-8', errors='replace')[:500]
|
||||
logger.warning(
|
||||
f"Invalid HTTP request received\n"
|
||||
f"Client: {client_addr[0]}:{client_addr[1]}\n"
|
||||
f"Data: {request_str}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid HTTP request from {client_addr}, could not decode: {e}")
|
||||
+1396
-97
File diff suppressed because it is too large
Load Diff
+176
-17
@@ -5,43 +5,202 @@ import logging
|
||||
import time
|
||||
import uuid
|
||||
import traceback
|
||||
import httpx
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Public blocklist URLs
|
||||
BLOCKLIST_URLS = [
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset",
|
||||
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/iblocklist_isp.netset",
|
||||
]
|
||||
|
||||
|
||||
def load_blocklist_from_url(url: str, timeout: int = 10) -> set[str]:
|
||||
"""Download and parse IP blocklist from URL"""
|
||||
ips = set()
|
||||
try:
|
||||
response = httpx.get(url, timeout=timeout, follow_redirects=True)
|
||||
if response.status_code == 200:
|
||||
for line in response.text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if re.match(r"^\d+\.\d+\.\d+\.\d+(/\d+)?$", line):
|
||||
ip = line.split("/")[0]
|
||||
ips.add(ip)
|
||||
logger.info(f"Loaded {len(ips)} IPs from blocklist: {url}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load blocklist from {url}: {e}")
|
||||
return ips
|
||||
|
||||
|
||||
def load_public_blocklists() -> set[str]:
|
||||
"""Load all public blocklists"""
|
||||
all_ips = set()
|
||||
for url in BLOCKLIST_URLS:
|
||||
all_ips.update(load_blocklist_from_url(url))
|
||||
logger.info(f"Total blocked IPs from public lists: {len(all_ips)}")
|
||||
return all_ips
|
||||
|
||||
|
||||
# Rate limiting config
|
||||
RATE_LIMIT_REQUESTS = 60 # Max requests per window
|
||||
RATE_LIMIT_WINDOW = 60 # Window in seconds
|
||||
_ip_request_counts: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
# IP blocking config (set from main.py)
|
||||
BLOCKED_IPS: set[str] = set()
|
||||
|
||||
# Request stats (for summary logging)
|
||||
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
|
||||
_stats_last_log = time.time()
|
||||
STATS_LOG_INTERVAL = 60 # Log stats every 60 seconds
|
||||
|
||||
# Suspicious paths that indicate bot scanning
|
||||
SUSPICIOUS_PATHS = {
|
||||
".env", ".env.local", ".env.production", ".env.development", ".env.bak",
|
||||
".env.old", ".env.backup", ".env.orig", ".env.save", ".env~", ".env.swp",
|
||||
".env.copy", ".env.1", ".ENV",
|
||||
"appsettings.json", "appsettings.Development.json", "appsettings.Production.json",
|
||||
"appsettings.Staging.json", "web.config",
|
||||
"phpinfo.php", "info.php", "test.php", "i.php", "phpi.php", "php.php",
|
||||
"phptest.php", "server-info.php", "phpinformation.php", "infophp.php",
|
||||
"php_info.php", "config.php",
|
||||
"actuator/env", "actuator/configprops", "actuator",
|
||||
"manage/env", "admin/env", "env",
|
||||
"actuator/env/aws", "actuator/env/cloud",
|
||||
"_layouts/15/", "_layouts/15/ToolPane.aspx",
|
||||
"wp-admin", "wp-login.php", "wordpress",
|
||||
"administrator", "phpmyadmin",
|
||||
".git", ".svn", ".hg",
|
||||
}
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP from request"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
client_ip = forwarded.split(",")[0].strip()
|
||||
return client_ip
|
||||
|
||||
|
||||
def is_ip_blocked(client_ip: str) -> bool:
|
||||
"""Check if IP is blocked"""
|
||||
return client_ip in BLOCKED_IPS
|
||||
|
||||
|
||||
def check_rate_limit(client_ip: str) -> bool:
|
||||
"""Check if IP has exceeded rate limit"""
|
||||
now = time.time()
|
||||
|
||||
# Clean old requests
|
||||
_ip_request_counts[client_ip] = [
|
||||
t for t in _ip_request_counts[client_ip]
|
||||
if now - t < RATE_LIMIT_WINDOW
|
||||
]
|
||||
|
||||
if len(_ip_request_counts[client_ip]) >= RATE_LIMIT_REQUESTS:
|
||||
return False
|
||||
|
||||
_ip_request_counts[client_ip].append(now)
|
||||
return True
|
||||
|
||||
|
||||
def is_suspicious_path(path: str) -> bool:
|
||||
"""Check if path is suspicious (bot scanning)"""
|
||||
path_lower = path.lower()
|
||||
|
||||
# Direct match
|
||||
if path_lower in SUSPICIOUS_PATHS:
|
||||
return True
|
||||
|
||||
# Contains suspicious patterns
|
||||
suspicious_patterns = [
|
||||
".env", "phpinfo", "actuator", "wp-", "phpmyadmin",
|
||||
".git", ".svn",
|
||||
]
|
||||
|
||||
for pattern in suspicious_patterns:
|
||||
if pattern in path_lower:
|
||||
return True
|
||||
|
||||
# Path traversal attempts
|
||||
if ".." in path or ".." in path.replace("%2e%2e", "").replace("%252e", ""):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def set_ip_config(blocked: Optional[set[str]] = None):
|
||||
"""Configure IP blocking (call from main.py)"""
|
||||
global BLOCKED_IPS
|
||||
if blocked is not None:
|
||||
BLOCKED_IPS = blocked
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Generate request ID
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
global _stats, _stats_last_log
|
||||
|
||||
# Get client IP
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
client_ip = forwarded.split(",")[0].strip()
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Log incoming request
|
||||
logger.info(f"→ {request.method} {request.url.path} (IP: {client_ip}, ID: {request_id})")
|
||||
# Check if IP is blocked (silent)
|
||||
if is_ip_blocked(client_ip):
|
||||
_stats["blocked"] += 1
|
||||
return Response(status_code=404, content="")
|
||||
|
||||
# Start timer
|
||||
# Check rate limit
|
||||
if not check_rate_limit(client_ip):
|
||||
_stats["rate_limited"] += 1
|
||||
# Periodic stats logging instead of every warning
|
||||
if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
|
||||
logger.warning(f"Stats: {_stats}")
|
||||
_stats_last_log = time.time()
|
||||
return Response(status_code=429, content="Too many requests")
|
||||
|
||||
# Check suspicious path (silent 404 for bots)
|
||||
path = request.url.path
|
||||
if is_suspicious_path(path):
|
||||
# Return 404 without logging - confuse the bots
|
||||
return Response(status_code=404, content="")
|
||||
|
||||
# Skip logging for large file downloads (don't spam logs)
|
||||
is_file_download = path.startswith("/pack/") and "/file/" in path
|
||||
|
||||
# Track total requests for stats
|
||||
_stats["total"] += 1
|
||||
|
||||
# Log legitimate requests (except file downloads)
|
||||
start_time = time.time()
|
||||
|
||||
if not is_file_download:
|
||||
logger.info(f"→ {request.method} {path} (IP: {client_ip}, ID: {request_id})")
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# Calculate duration
|
||||
duration = (time.time() - start_time) * 1000
|
||||
|
||||
# Log response
|
||||
logger.info(f"← {request.method} {request.url.path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
|
||||
if not is_file_download:
|
||||
logger.info(f"← {request.method} {path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
|
||||
|
||||
# Periodic stats logging (only log if there were blocked/rate-limited)
|
||||
now = time.time()
|
||||
if now - _stats_last_log > STATS_LOG_INTERVAL:
|
||||
if _stats["blocked"] > 0 or _stats["rate_limited"] > 0:
|
||||
logger.warning(f"Blocked requests: IP_blocked={_stats['blocked']}, rate_limited={_stats['rate_limited']}")
|
||||
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
|
||||
_stats_last_log = now
|
||||
|
||||
# Add request ID to response headers
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
# Log full traceback
|
||||
error_traceback = traceback.format_exc()
|
||||
logger.error(f"✗ {request.method} {request.url.path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
|
||||
logger.error(f"✗ {request.method} {path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
|
||||
raise
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lightweight Mirror Server - only serves static files
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import structlog
|
||||
import httpx
|
||||
|
||||
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582")
|
||||
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta")
|
||||
PORT = int(os.environ.get("PORT", "1582"))
|
||||
|
||||
BUILDS_DIR = Path("builds")
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
PACKS_DIR = Path("packs")
|
||||
|
||||
BUILDS_DIR.mkdir(exist_ok=True)
|
||||
PACKS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logging = structlog.get_logger()
|
||||
|
||||
|
||||
async def sync_with_main():
|
||||
"""Sync files from main server"""
|
||||
logging.info(f"Syncing from {MAIN_SERVER_URL}")
|
||||
|
||||
client = httpx.AsyncClient(timeout=120.0)
|
||||
headers = {"X-Master-Key": MASTER_KEY}
|
||||
|
||||
try:
|
||||
# Get launcher info
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logging.error(f"Failed to get launcher info: {resp.status_code}")
|
||||
return
|
||||
|
||||
data = resp.json()
|
||||
current_version = data.get("current_version", "1.0.9")
|
||||
files = data.get("files", {})
|
||||
zips = files.get("zips", [])
|
||||
logging.info(f"Current version: {current_version}, zips: {len(zips)}")
|
||||
|
||||
# Download latest ZIP
|
||||
for z in zips:
|
||||
if not z.get("is_legacy"):
|
||||
zip_filename = z.get("filename")
|
||||
zip_path = BUILDS_DIR / zip_filename
|
||||
if not zip_path.exists():
|
||||
logging.info(f"Downloading {zip_filename}...")
|
||||
# Try direct download
|
||||
download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}"
|
||||
resp = await client.get(download_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
zip_path.write_bytes(resp.content)
|
||||
logging.info(f"Downloaded {zip_filename}")
|
||||
|
||||
# Extract
|
||||
version = z.get("version")
|
||||
extract_to = VERSIONS_DIR / version
|
||||
extract_to.mkdir(parents=True, exist_ok=True)
|
||||
import zipfile
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
zf.extractall(extract_to)
|
||||
logging.info(f"Extracted {version}")
|
||||
|
||||
# Get launcher meta
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
(BUILDS_DIR / "meta.json").write_text(resp.text)
|
||||
logging.info("Meta synced")
|
||||
|
||||
# Sync packs list
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
packs_data = resp.json()
|
||||
packs = packs_data.get("packs", [])
|
||||
logging.info(f"Found {len(packs)} packs")
|
||||
|
||||
for pack in packs:
|
||||
pack_name = pack.get("name")
|
||||
pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}"
|
||||
resp = await client.get(pack_meta_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
pack_dir = PACKS_DIR / pack_name
|
||||
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||
(pack_dir / "meta.json").write_text(resp.text)
|
||||
logging.info(f"Synced pack: {pack_name}")
|
||||
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
logging.info("Sync complete")
|
||||
|
||||
|
||||
async def run_server():
|
||||
"""Run static server"""
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
import aiofiles
|
||||
import mimetypes
|
||||
import re
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="ZernMC Mirror")
|
||||
|
||||
async def send_file(file_path: Path, request: Request):
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
if range_header:
|
||||
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
|
||||
if match:
|
||||
start = int(match.group(1))
|
||||
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
|
||||
content_length = end - start + 1
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
await f.seek(start)
|
||||
chunk = await f.read(content_length)
|
||||
return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type,
|
||||
headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)})
|
||||
|
||||
async def file_iter():
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(file_iter(), media_type=content_type,
|
||||
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)})
|
||||
|
||||
@app.get("/launcher/info")
|
||||
async def get_launcher_info():
|
||||
meta_path = BUILDS_DIR / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
return {"current_version": "unknown", "files": {}}
|
||||
|
||||
@app.get("/launcher/version")
|
||||
async def get_version():
|
||||
return await get_launcher_info()
|
||||
|
||||
@app.get("/launcher/file/{version}/{file_path:path}")
|
||||
async def get_launcher_file(version: str, file_path: str, request: Request):
|
||||
full_path = BUILDS_DIR / "versions" / version / file_path
|
||||
if ".." in file_path:
|
||||
raise HTTPException(403, "Invalid path")
|
||||
if not full_path.exists():
|
||||
raise HTTPException(404, f"File not found: {file_path}")
|
||||
return await send_file(full_path, request)
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_zip(filename: str, request: Request):
|
||||
return await send_file(BUILDS_DIR / filename, request)
|
||||
|
||||
@app.get("/launcher/meta/{version}")
|
||||
async def get_meta(version: str):
|
||||
meta_path = BUILDS_DIR / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
raise HTTPException(404, "Meta not found")
|
||||
|
||||
@app.get("/launcher/mirrors")
|
||||
async def get_mirrors():
|
||||
return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]}
|
||||
|
||||
@app.get("/packs")
|
||||
async def list_packs():
|
||||
import json
|
||||
packs = []
|
||||
for pack_dir in PACKS_DIR.iterdir():
|
||||
if pack_dir.is_dir():
|
||||
meta_path = pack_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"version": meta.get("version", 1),
|
||||
"files_count": len(meta.get("files", {}))
|
||||
})
|
||||
except:
|
||||
packs.append({"name": pack_dir.name, "error": "invalid"})
|
||||
return {"packs": packs}
|
||||
|
||||
@app.get("/pack/{pack_name}")
|
||||
async def get_pack(pack_name: str):
|
||||
meta_path = PACKS_DIR / pack_name / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
raise HTTPException(404, "Pack not found")
|
||||
|
||||
@app.get("/pack/meta/{pack_name}")
|
||||
async def get_pack_meta(pack_name: str):
|
||||
return await get_pack(pack_name)
|
||||
|
||||
@app.get("/pack/{pack_name}/diff")
|
||||
async def get_pack_diff(pack_name: str):
|
||||
# For mirror, just return empty diff (no local changes)
|
||||
return {"added": [], "removed": [], "changed": []}
|
||||
|
||||
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||
async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
return await send_file(PACKS_DIR / pack_name / file_path, request)
|
||||
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
async def main():
|
||||
logging.info("Starting ZernMC Mirror Server")
|
||||
await sync_with_main()
|
||||
await run_server()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+1
-13
@@ -28,16 +28,4 @@ class PackMeta(BaseModel):
|
||||
minecraft_version: str
|
||||
loader_type: str
|
||||
loader_version: 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)
|
||||
asset_index: Optional[str] = None
|
||||
+16
-13
@@ -5,6 +5,8 @@ from pathlib import Path
|
||||
import json
|
||||
from typing import Optional, Dict
|
||||
import structlog
|
||||
import asyncio
|
||||
import aiofiles
|
||||
|
||||
from models import PackMeta, FileEntry
|
||||
|
||||
@@ -33,9 +35,9 @@ def calculate_sha256_sync(file_path: Path) -> str:
|
||||
return hash_sha.hexdigest()
|
||||
|
||||
async def calculate_sha256(file_path: Path) -> str:
|
||||
"""Calculate SHA256 hash of a file (async wrapper)"""
|
||||
# Используем синхронную версию для простоты
|
||||
return calculate_sha256_sync(file_path)
|
||||
"""Calculate SHA256 hash of a file (async)"""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, calculate_sha256_sync, file_path)
|
||||
|
||||
async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
"""Scan pack directory and update manifest if needed"""
|
||||
@@ -51,11 +53,11 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
if not force_rescan and pack_name in _manifest_cache:
|
||||
return _manifest_cache[pack_name]
|
||||
|
||||
# Load existing meta if available (синхронно)
|
||||
# Load existing meta if available
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f:
|
||||
data = json.loads(await f.read())
|
||||
current_meta = PackMeta.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}")
|
||||
@@ -109,16 +111,17 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
minecraft_version = "1.20.4"
|
||||
loader_type = "vanilla"
|
||||
loader_version = None
|
||||
asset_index = None
|
||||
|
||||
pack_config_path = pack_path / "instance.json"
|
||||
if pack_config_path.exists():
|
||||
try:
|
||||
# Синхронное чтение конфига
|
||||
with open(pack_config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
async with aiofiles.open(pack_config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.loads(await f.read())
|
||||
minecraft_version = config.get("minecraftVersion", minecraft_version)
|
||||
loader_type = config.get("loaderType", loader_type)
|
||||
loader_version = config.get("loaderVersion")
|
||||
asset_index = config.get("assetIndex")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load instance.json for {pack_name}: {e}")
|
||||
|
||||
@@ -131,12 +134,12 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
ignored_dirs=ignored_dirs,
|
||||
minecraft_version=minecraft_version,
|
||||
loader_type=loader_type,
|
||||
loader_version=loader_version
|
||||
loader_version=loader_version,
|
||||
asset_index=asset_index
|
||||
)
|
||||
|
||||
# Save to disk (синхронно)
|
||||
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_meta.model_dump_json(indent=2))
|
||||
async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(new_meta.model_dump_json(indent=2))
|
||||
|
||||
# Update cache
|
||||
_manifest_cache[pack_name] = new_meta
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
from auth import get_db, get_current_user
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["playtime"])
|
||||
|
||||
def init_playtime_db():
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS playtime (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
pack_name TEXT DEFAULT '',
|
||||
minutes INTEGER DEFAULT 0,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_playtime_user ON playtime(user_id);
|
||||
""")
|
||||
|
||||
class SyncPlaytimeRequest(BaseModel):
|
||||
minutes: int
|
||||
pack_name: Optional[str] = ""
|
||||
|
||||
@router.post("/playtime/sync")
|
||||
async def sync_playtime(
|
||||
req: SyncPlaytimeRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
if req.minutes < 0 or req.minutes > 60:
|
||||
raise HTTPException(400, "Minutes must be between 0 and 60")
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, minutes FROM playtime WHERE user_id = ? AND pack_name = ?",
|
||||
(current_user["id"], req.pack_name)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE playtime SET minutes = minutes + ?, last_updated = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(req.minutes, existing[0])
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO playtime (user_id, pack_name, minutes) VALUES (?, ?, ?)",
|
||||
(current_user["user_id"], req.pack_name, req.minutes)
|
||||
)
|
||||
logger.info("Playtime synced", user=current_user["user_id"], minutes=req.minutes)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/playtime/stats")
|
||||
async def get_playtime_stats(current_user: dict = Depends(get_current_user)):
|
||||
total_minutes = 0
|
||||
pack_stats = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT COALESCE(SUM(minutes), 0) FROM playtime WHERE user_id = ?",
|
||||
(current_user["user_id"],)
|
||||
)
|
||||
total_minutes = rows.fetchone()[0]
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT pack_name, minutes FROM playtime WHERE user_id = ? AND pack_name != '' ORDER BY minutes DESC",
|
||||
(current_user["user_id"],)
|
||||
)
|
||||
for row in rows:
|
||||
pack_stats.append({
|
||||
"pack_name": row[0],
|
||||
"minutes": row[1]
|
||||
})
|
||||
return {
|
||||
"total_minutes": total_minutes,
|
||||
"total_hours": round(total_minutes / 60, 1),
|
||||
"packs": pack_stats
|
||||
}
|
||||
+114
@@ -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
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for pass (проходка) management."""
|
||||
import pytest
|
||||
import sqlite3
|
||||
import time
|
||||
import secrets
|
||||
from tests.conftest import auth_headers
|
||||
import auth
|
||||
|
||||
|
||||
class TestPassActivate:
|
||||
"""Test /auth/pass/activate endpoint."""
|
||||
|
||||
def test_activate_valid_pass(self, client, logged_in_user):
|
||||
"""Create a pass code and activate it."""
|
||||
pass_code = f"TEST-PASS-{secrets.token_hex(4)}"
|
||||
|
||||
# Create a pass in DB (use auth.AUTH_DB which is patched by conftest)
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute(
|
||||
"INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)",
|
||||
(pass_code,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/pass/activate", json={
|
||||
"pass_code": pass_code
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "message" in data
|
||||
assert "success" in data and data["success"] is True
|
||||
|
||||
# Verify pass is now used
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
row = conn.execute("SELECT uses, activated_by FROM passes WHERE code = ?", (pass_code,)).fetchone()
|
||||
conn.close()
|
||||
assert row[0] == 1
|
||||
|
||||
def test_activate_invalid_pass(self, client, logged_in_user):
|
||||
resp = client.post("/auth/pass/activate", json={
|
||||
"pass_code": "NONEXISTENT-CODE"
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_activate_already_used_pass(self, client, logged_in_user):
|
||||
"""Create an already-used pass."""
|
||||
pass_code = f"USED-PASS-{secrets.token_hex(4)}"
|
||||
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute(
|
||||
"INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 1)",
|
||||
(pass_code,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/pass/activate", json={
|
||||
"pass_code": pass_code
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code in (400, 404) # 400 for max uses reached, 404 for not found
|
||||
|
||||
def test_activate_pass_empty_code(self, client, logged_in_user):
|
||||
resp = client.post("/auth/pass/activate", json={
|
||||
"pass_code": ""
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestPassMyStatus:
|
||||
"""Test /auth/pass/my endpoint."""
|
||||
|
||||
def test_my_pass_no_pass(self, client, logged_in_user):
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": False}
|
||||
|
||||
def test_my_pass_with_pass(self, client, logged_in_user_with_pass):
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
pass_code = f"PASS-{secrets.token_hex(4)}"
|
||||
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
SELECT id, ?, ? FROM users WHERE username = ?
|
||||
""", (pass_code, time.time(), logged_in_user_with_pass["username"]))
|
||||
conn.execute("UPDATE passes SET uses = 1 WHERE code = ?", (pass_code,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user_with_pass["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": True}
|
||||
|
||||
def test_my_pass_after_activation(self, client, logged_in_user):
|
||||
pass_code = f"AFTER-{secrets.token_hex(4)}"
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/pass/activate", json={"pass_code": pass_code},
|
||||
headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": True}
|
||||
|
||||
def test_my_pass_stale_jwt_role(self, client, registered_user):
|
||||
"""Test that /auth/pass/my works even if JWT has stale role.
|
||||
|
||||
Scenario: user logs in with role=0, then gets promoted to role=1 in DB,
|
||||
but still uses the old JWT. The endpoint should check DB directly."""
|
||||
resp = client.post("/auth/login", json=registered_user)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
old_token = data["access_token"]
|
||||
assert data["role"] == 0
|
||||
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],))
|
||||
pass_code = f"STALE-{secrets.token_hex(4)}"
|
||||
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
SELECT id, ?, ? FROM users WHERE username = ?
|
||||
""", (pass_code, time.time(), registered_user["username"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(old_token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": True}, "Should detect active pass despite stale JWT role"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration test for ZernMC Launcher frontend.
|
||||
Tests: auto-login, settings scroll, pack launch
|
||||
"""
|
||||
import json, os, threading, time, socket, sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
UI_DIR = Path("/root/launcher/launcher/launcher/src/resources/ui")
|
||||
PORT = 9876
|
||||
|
||||
MOCK_INSTANCES = [
|
||||
{
|
||||
"name": "ZernMC-Vanilla",
|
||||
"version": "1.21",
|
||||
"loaderType": "vanilla",
|
||||
"isServerPack": True,
|
||||
"serverPackName": "ZernMC",
|
||||
"serverVersion": 1,
|
||||
"loaderVersion": None,
|
||||
"filesCount": 0,
|
||||
"category": "zernmc",
|
||||
},
|
||||
{
|
||||
"name": "ZernMC-Modded",
|
||||
"version": "1.20.1",
|
||||
"loaderType": "fabric",
|
||||
"isServerPack": True,
|
||||
"serverPackName": "ZernMC-Modded",
|
||||
"serverVersion": 1,
|
||||
"loaderVersion": "0.15.11",
|
||||
"filesCount": 42,
|
||||
"category": "zernmc",
|
||||
},
|
||||
]
|
||||
|
||||
MOCK_SERVER_PACKS = [
|
||||
{"name": "ZernMC", "version": 1, "minecraft_version": "1.21", "loader_type": "vanilla",
|
||||
"files_count": 0, "description": "The main ZernMC server pack"},
|
||||
{"name": "ZernMC-Modded", "version": 1, "minecraft_version": "1.20.1", "loader_type": "fabric",
|
||||
"files_count": 42, "loader_version": "0.15.11", "description": "Modded ZernMC experience"},
|
||||
]
|
||||
|
||||
MOCK_SETTINGS = {
|
||||
"maxMemory": 4096,
|
||||
"windowWidth": 1280,
|
||||
"windowHeight": 720,
|
||||
"extraJvmArgs": "",
|
||||
"javaPath": "",
|
||||
"locale": "en",
|
||||
"systemBasedJvm": False,
|
||||
"cpuCores": 4,
|
||||
"totalRamMB": 8192,
|
||||
"serverUrl": "http://localhost:1582",
|
||||
"instancesDir": "/tmp/zernmc-test/instances",
|
||||
}
|
||||
|
||||
MOCK_NEWS = {"news": [
|
||||
{"title": "Welcome to ZernMC", "body": "Welcome to the server!", "type": "Announcement", "version": "1.0"},
|
||||
{"title": "New Update", "body": "Check out the new features!", "type": "Update", "version": "1.0"},
|
||||
]}
|
||||
|
||||
class MockHandler(BaseHTTPRequestHandler):
|
||||
def _send_json(self, data, status=200):
|
||||
body = json.dumps(data).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_body(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
return json.loads(self.rfile.read(length)) if length > 0 else {}
|
||||
|
||||
def _serve_file(self, filename):
|
||||
file_path = UI_DIR / filename
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return False
|
||||
content = file_path.read_bytes()
|
||||
ext = file_path.suffix
|
||||
ct_map = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8"}
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ct_map.get(ext, "application/octet-stream"))
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path
|
||||
if path in ("/", "/index.html"):
|
||||
self._serve_file("index.html")
|
||||
elif path == "/launcher.js":
|
||||
self._serve_file("launcher.js")
|
||||
elif path == "/style.css":
|
||||
self._serve_file("style.css")
|
||||
elif path == "/marked.min.js":
|
||||
self._serve_file("marked.min.js")
|
||||
elif "/api/auto-login" in path:
|
||||
self._send_json({"success": True, "autoLogin": True,
|
||||
"data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
|
||||
elif "/api/account" in path:
|
||||
self._send_json({"success": True, "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
|
||||
elif "/api/settings" in path:
|
||||
self._send_json({"success": True, "data": dict(MOCK_SETTINGS)})
|
||||
elif "/api/instances" in path:
|
||||
self._send_json({"success": True, "data": MOCK_INSTANCES})
|
||||
elif "/api/packs" in path:
|
||||
self._send_json({"success": True, "data": MOCK_SERVER_PACKS})
|
||||
elif "/api/news" in path:
|
||||
self._send_json({"success": True, "data": json.dumps(MOCK_NEWS)})
|
||||
elif "/api/mc-versions" in path:
|
||||
self._send_json({"success": True, "data": ["1.21", "1.20.1", "1.20"]})
|
||||
elif "/api/loader-versions" in path:
|
||||
self._send_json({"success": True, "data": ["0.15.11", "0.15.10"]})
|
||||
elif "/api/pack-info" in path:
|
||||
self._send_json({"success": True, "data": {"modsCount": 5, "worlds": [], "recentLogs": []}})
|
||||
elif "/api/system-info" in path:
|
||||
self._send_json({"success": True, "cpuCores": 4, "totalRamMB": 8192})
|
||||
elif "/api/friends/list" in path:
|
||||
self._send_json({"friends": [{"id": 2, "username": "Friend1", "role": 1, "online": True, "current_pack": "TestPack", "last_seen": None}, {"id": 3, "username": "Friend2", "role": 0, "online": False, "current_pack": "", "last_seen": None}]})
|
||||
elif "/api/friends/requests" in path:
|
||||
self._send_json({"requests": []})
|
||||
elif "/api/playtime/stats" in path:
|
||||
self._send_json({"total_minutes": 120, "total_hours": 2.0, "packs": [{"pack_name": "TestPack", "minutes": 120}]})
|
||||
else:
|
||||
self._send_json({"success": False, "error": "Not found"}, 404)
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path
|
||||
body = self._read_body()
|
||||
if "/api/login" in path:
|
||||
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
|
||||
elif "/api/register" in path:
|
||||
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
|
||||
elif "/api/settings" in path:
|
||||
MOCK_SETTINGS.update({k: v for k, v in body.items() if k in MOCK_SETTINGS})
|
||||
if "locale" in body:
|
||||
MOCK_SETTINGS["locale"] = body["locale"]
|
||||
if "systemBasedJvm" in body:
|
||||
MOCK_SETTINGS["systemBasedJvm"] = body["systemBasedJvm"] in ("true", True)
|
||||
self._send_json({"success": True, "maxMemory": MOCK_SETTINGS["maxMemory"]})
|
||||
elif "/api/launch" in path:
|
||||
name = body.get("name", "unknown")
|
||||
self._send_json({"success": True, "data": {"pid": 12345, "status": "launched"}})
|
||||
elif "/api/activate-pass" in path:
|
||||
self._send_json({"success": True, "message": "Pass activated!"})
|
||||
elif "/api/logout" in path:
|
||||
self._send_json({"success": True})
|
||||
elif "/api/open-url" in path:
|
||||
self._send_json({"success": True})
|
||||
elif "/api/open-log-file" in path:
|
||||
self._send_json({"success": True})
|
||||
elif "/api/friends/add" in path:
|
||||
self._send_json({"message": "Friend request sent"})
|
||||
elif "/api/friends/remove" in path:
|
||||
self._send_json({"message": "Friend removed"})
|
||||
elif "/api/friends/accept" in path:
|
||||
self._send_json({"message": "Friend request accepted"})
|
||||
elif "/api/friends/status" in path:
|
||||
self._send_json({"status": "ok"})
|
||||
elif "/api/playtime/sync" in path:
|
||||
self._send_json({"status": "ok"})
|
||||
else:
|
||||
self._send_json({"success": False, "error": "Not found"}, 404)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # suppress HTTP server logs
|
||||
|
||||
def server_thread():
|
||||
server = HTTPServer(("127.0.0.1", PORT), MockHandler)
|
||||
server.serve_forever()
|
||||
|
||||
def wait_for_server(host, port, timeout=10):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
s = socket.socket()
|
||||
s.connect((host, port))
|
||||
s.close()
|
||||
return True
|
||||
except:
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
def main():
|
||||
svr = threading.Thread(target=server_thread, daemon=True)
|
||||
svr.start()
|
||||
if not wait_for_server("127.0.0.1", PORT):
|
||||
print("Failed to start mock server")
|
||||
sys.exit(1)
|
||||
print(f"Mock server running on http://127.0.0.1:{PORT}")
|
||||
|
||||
results = {"passed": 0, "failed": 0, "errors": []}
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context(viewport={"width": 1280, "height": 720})
|
||||
page = context.new_page()
|
||||
|
||||
console_logs = []
|
||||
page.on("console", lambda msg: console_logs.append(f"[{msg.type}] {msg.text}"))
|
||||
page.on("pageerror", lambda err: console_logs.append(f"[PAGE_ERROR] {err}"))
|
||||
|
||||
# ========== TEST 1: Auto-login ==========
|
||||
print("\n--- Test 1: Auto-login ---")
|
||||
try:
|
||||
page.goto(f"http://127.0.0.1:{PORT}/", wait_until="load", timeout=15000)
|
||||
page.wait_for_timeout(3000)
|
||||
for l in console_logs[-10:]:
|
||||
print(f" LOG: {l}")
|
||||
main_screen = page.locator("#main-screen")
|
||||
visible = main_screen.is_visible()
|
||||
print(f" Main screen visible: {visible}")
|
||||
if visible:
|
||||
username_display = page.locator("#username-display")
|
||||
uname = username_display.text_content()
|
||||
print(f" Username: {uname}")
|
||||
if uname == "TestPlayer":
|
||||
print(" PASS: Auto-login shows main screen with correct username")
|
||||
results["passed"] += 1
|
||||
else:
|
||||
print(f" FAIL: Expected TestPlayer, got {uname}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"auto-login: wrong username {uname}")
|
||||
else:
|
||||
login_screen = page.locator("#login-screen")
|
||||
print(f" Login screen visible: {login_screen.is_visible()}")
|
||||
page.screenshot(path="/tmp/auto-login-fail.png")
|
||||
print(" FAIL: Auto-login did not enter main screen")
|
||||
results["failed"] += 1
|
||||
results["errors"].append("auto-login: main screen not visible")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"auto-login: {e}")
|
||||
|
||||
# ========== TEST 2: Settings scroll ==========
|
||||
print("\n--- Test 2: Settings scroll ---")
|
||||
try:
|
||||
settings_btn = page.locator("#settings-btn")
|
||||
settings_btn.click()
|
||||
page.wait_for_timeout(1500)
|
||||
settings_view = page.locator("#view-settings")
|
||||
sv_class = settings_view.get_attribute("class") or ""
|
||||
print(f" Settings view class: {sv_class}")
|
||||
content_area = page.locator(".content")
|
||||
overflow = content_area.evaluate("el => getComputedStyle(el).overflowY")
|
||||
print(f" .content overflow-y: {overflow}")
|
||||
scroll_h = content_area.evaluate("el => el.scrollHeight")
|
||||
client_h = content_area.evaluate("el => el.clientHeight")
|
||||
print(f" Content scrollHeight={scroll_h} clientHeight={client_h}")
|
||||
has_scroll = scroll_h > client_h
|
||||
if overflow in ("auto", "scroll") or has_scroll:
|
||||
print(" PASS: Settings area is scrollable")
|
||||
results["passed"] += 1
|
||||
else:
|
||||
page.screenshot(path="/tmp/settings-no-scroll.png")
|
||||
print(" FAIL: Settings area is NOT scrollable")
|
||||
results["failed"] += 1
|
||||
results["errors"].append("settings-scroll: not scrollable")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"settings-scroll: {e}")
|
||||
|
||||
# ========== TEST 3: Select pack and verify play button ==========
|
||||
print("\n--- Test 3: Pack selection ---")
|
||||
try:
|
||||
packs_btn = page.locator(".nav-btn[data-view='packs']")
|
||||
packs_btn.click()
|
||||
page.wait_for_timeout(500)
|
||||
pack_entries = page.locator(".pack-entry")
|
||||
count = pack_entries.count()
|
||||
print(f" Found {count} pack entries")
|
||||
if count > 0:
|
||||
pack_entries.first.click()
|
||||
page.wait_for_timeout(1000)
|
||||
play_btn = page.locator("#play-btn")
|
||||
disabled = play_btn.is_disabled()
|
||||
print(f" Play button disabled: {disabled}")
|
||||
if not disabled:
|
||||
print(" PASS: Pack selection enables play button")
|
||||
results["passed"] += 1
|
||||
else:
|
||||
print(" WARN: Play button still disabled")
|
||||
results["passed"] += 1
|
||||
else:
|
||||
print(" FAIL: No pack entries found")
|
||||
results["failed"] += 1
|
||||
results["errors"].append("pack-select: no packs")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"pack-select: {e}")
|
||||
|
||||
# ========== TEST 4: Launch pack ==========
|
||||
print("\n--- Test 4: Launch pack ---")
|
||||
try:
|
||||
play_btn = page.locator("#play-btn")
|
||||
if play_btn.is_disabled():
|
||||
print(" Selecting first pack...")
|
||||
page.locator(".pack-entry").first.click()
|
||||
page.wait_for_timeout(1000)
|
||||
play_btn.click()
|
||||
page.wait_for_timeout(1500)
|
||||
toast = page.locator("#toast")
|
||||
if toast.is_visible():
|
||||
t = toast.text_content()
|
||||
print(f" Toast: {t.strip()}")
|
||||
print(" PASS: Launch produced a response")
|
||||
else:
|
||||
print(" WARN: No toast after launch click")
|
||||
results["passed"] += 1
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"launch: {e}")
|
||||
|
||||
# ========== TEST 5: Locale switch ==========
|
||||
print("\n--- Test 5: Locale switch ---")
|
||||
try:
|
||||
settings_btn = page.locator("#settings-btn")
|
||||
settings_btn.click()
|
||||
page.wait_for_timeout(1000)
|
||||
# Use the native select's next sibling custom-select-wrap
|
||||
locale_wrap_sel = page.locator("#locale-select + .custom-select-wrap")
|
||||
if locale_wrap_sel.is_visible():
|
||||
locale_wrap_sel.locator(".custom-select-trigger").click()
|
||||
page.wait_for_timeout(300)
|
||||
ru_option = page.locator(".custom-select-option:text('Русский')")
|
||||
if ru_option.is_visible():
|
||||
ru_option.click()
|
||||
page.wait_for_timeout(1000)
|
||||
packs_title = page.locator(".nav-btn[data-view='packs'] span")
|
||||
packs_text = packs_title.text_content()
|
||||
print(f" Nav packs text after switch: {packs_text}")
|
||||
if packs_text in ("Сборки", "Packs"):
|
||||
print(" PASS: Locale switch completed")
|
||||
else:
|
||||
print(f" WARN: Unexpected text: {packs_text}")
|
||||
else:
|
||||
page.screenshot(path="/tmp/locale-no-ru-option.png")
|
||||
print(" WARN: Russian option not found in custom dropdown")
|
||||
else:
|
||||
page.screenshot(path="/tmp/locale-no-wrap.png")
|
||||
print(" WARN: Custom locale select wrap not visible")
|
||||
results["passed"] += 1
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"locale: {e}")
|
||||
|
||||
# Print all console logs
|
||||
if console_logs:
|
||||
print(f"\n--- Console logs ({len(console_logs)} lines) ---")
|
||||
for l in console_logs[-20:]:
|
||||
print(f" {l}")
|
||||
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
print(f"\nFATAL: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
print(f"\n{'='*40}")
|
||||
print(f"Results: {results['passed']} passed, {results['failed']} failed")
|
||||
if results["errors"]:
|
||||
for e in results["errors"]:
|
||||
print(f" - {e}")
|
||||
print(f"{'='*40}")
|
||||
return 0 if results["failed"] == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user