Compare commits
40 Commits
UI
..
59480217aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+2
-1
@@ -2,6 +2,8 @@ logs/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
./.venv/
|
./.venv/
|
||||||
launcher/target
|
launcher/target
|
||||||
|
bootstrap/target
|
||||||
|
src/target
|
||||||
server/builds
|
server/builds
|
||||||
server/packs
|
server/packs
|
||||||
server/data
|
server/data
|
||||||
@@ -10,4 +12,3 @@ jre
|
|||||||
dependency-reduced-pom.xml
|
dependency-reduced-pom.xml
|
||||||
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
|
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
|
||||||
telegram-bot/
|
telegram-bot/
|
||||||
.env
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
- Графического интерфейса (GUI) — только TUI
|
- Графического интерфейса (GUI) — только TUI
|
||||||
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
|
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
|
||||||
- Поддержки **Forge** (в разработке) (технически уже есть вместе с NeoForge (science PR№4))
|
- Поддержки **Forge** (в разработке)
|
||||||
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
|
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
|
||||||
- Раздела новостей об обновлениях Minecraft и лаунчера
|
- Раздела новостей об обновлениях Minecraft и лаунчера
|
||||||
- Выбора готовых пресетов оптимизации JVM
|
- Выбора готовых пресетов оптимизации JVM
|
||||||
|
|||||||
@@ -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,410 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.file.*;
|
||||||
|
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 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 Path baseDir;
|
||||||
|
private static Path binDir;
|
||||||
|
private static Path logDir;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
log("=== ZernMC Launcher ===");
|
||||||
|
|
||||||
|
// Определяем режим запуска
|
||||||
|
List<String> argList = Arrays.asList(args);
|
||||||
|
boolean cliMode = argList.contains("--cli");
|
||||||
|
boolean jfxMode = !cliMode; // по умолчанию JFX
|
||||||
|
|
||||||
|
// Проверка и обновление лаунчера
|
||||||
|
String currentVersion = readCurrentVersion();
|
||||||
|
String serverVersion = getServerVersion();
|
||||||
|
|
||||||
|
log("Локальная версия: " + currentVersion);
|
||||||
|
log("Версия на сервере: " + serverVersion);
|
||||||
|
|
||||||
|
if (isNewer(serverVersion, currentVersion)) {
|
||||||
|
log("Доступно обновление!");
|
||||||
|
downloadUpdate(serverVersion);
|
||||||
|
} else {
|
||||||
|
log("Версия актуальна");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск в выбранном режиме
|
||||||
|
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 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("Ошибка чтения манифеста: " + 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("Ошибка получения версии: " + 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("Проверка обновлений...");
|
||||||
|
|
||||||
|
// Получаем мета с сервера
|
||||||
|
Map<String, FileMeta> serverFiles = fetchServerMeta(newVersion);
|
||||||
|
if (serverFiles.isEmpty()) {
|
||||||
|
log("Не удалось получить мета с сервера");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сканируем локальные файлы
|
||||||
|
Map<String, String> localFiles = scanLocalFiles();
|
||||||
|
log("Локальных файлов: " + localFiles.size());
|
||||||
|
log("Файлов на сервере: " + serverFiles.size());
|
||||||
|
|
||||||
|
// Сравниваем и скачиваем
|
||||||
|
int downloaded = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localHash != null) {
|
||||||
|
log("Обновление: " + filePath);
|
||||||
|
} else {
|
||||||
|
log("Скачивание: " + filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile(newVersion, filePath, serverMeta.size);
|
||||||
|
downloaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Обновлено файлов: " + downloaded + ", пропущено: " + skipped);
|
||||||
|
log("Обновлено до v" + newVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("Ошибка получения мета: " + 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 {
|
||||||
|
URL url = new URL(BASE_URL + "/launcher/file/" + version + "/" + filePath);
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setConnectTimeout(10000);
|
||||||
|
conn.setReadTimeout(60000);
|
||||||
|
|
||||||
|
if (conn.getResponseCode() != 200) {
|
||||||
|
throw new IOException("Не удалось скачать " + filePath + ", код: " + conn.getResponseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
Path outPath = baseDir.resolve(filePath);
|
||||||
|
Files.createDirectories(outPath.getParent());
|
||||||
|
|
||||||
|
long downloaded = 0;
|
||||||
|
try (InputStream in = conn.getInputStream();
|
||||||
|
OutputStream out = new FileOutputStream(outPath.toFile())) {
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
int len;
|
||||||
|
while ((len = in.read(buf)) > 0) {
|
||||||
|
out.write(buf, 0, len);
|
||||||
|
downloaded += len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем хеш
|
||||||
|
String actualHash = calculateFileHash(outPath);
|
||||||
|
String expectedHash = expectedSize > 0 ? "" : "";
|
||||||
|
if (downloaded != expectedSize) {
|
||||||
|
log("Предупреждение: размер " + filePath + " не совпадает");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выводим прогресс
|
||||||
|
System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class FileMeta {
|
||||||
|
String hash;
|
||||||
|
long size;
|
||||||
|
FileMeta(String hash, long size) {
|
||||||
|
this.hash = hash;
|
||||||
|
this.size = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchJFX() throws Exception {
|
||||||
|
Path javaBin = findJava();
|
||||||
|
Path jarPath = getLauncherJar();
|
||||||
|
|
||||||
|
log("Запуск JFX режима...");
|
||||||
|
log("Java: " + javaBin);
|
||||||
|
log("JAR: " + jarPath);
|
||||||
|
|
||||||
|
// JVM аргументы для UTF-8 и JavaFX
|
||||||
|
List<String> jvmArgs = List.of(
|
||||||
|
"-Dfile.encoding=UTF-8",
|
||||||
|
"-Dsun.stdout.encoding=UTF-8",
|
||||||
|
"-Dsun.stderr.encoding=UTF-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Путь к JavaFX модулям
|
||||||
|
Path javafxPath = baseDir.resolve("lib").resolve("javafx");
|
||||||
|
if (Files.exists(javafxPath)) {
|
||||||
|
jvmArgs = List.of(
|
||||||
|
"-Dfile.encoding=UTF-8",
|
||||||
|
"-Dsun.stdout.encoding=UTF-8",
|
||||||
|
"-Dsun.stderr.encoding=UTF-8",
|
||||||
|
"-Dlauncher.server=" + BASE_URL,
|
||||||
|
"--module-path", javafxPath.toAbsolutePath().toString(),
|
||||||
|
"--add-modules", "javafx.controls,javafx.web"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jvmArgs = List.of(
|
||||||
|
"-Dfile.encoding=UTF-8",
|
||||||
|
"-Dsun.stdout.encoding=UTF-8",
|
||||||
|
"-Dsun.stderr.encoding=UTF-8",
|
||||||
|
"-Dlauncher.server=" + BASE_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(javaBin.toAbsolutePath().toString());
|
||||||
|
cmd.addAll(jvmArgs);
|
||||||
|
cmd.add("-jar");
|
||||||
|
cmd.add(jarPath.toAbsolutePath().toString());
|
||||||
|
cmd.add("--jfx");
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
pb.directory(baseDir.toFile());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process p = pb.start();
|
||||||
|
int code = p.waitFor();
|
||||||
|
log("Завершено с кодом: " + code);
|
||||||
|
System.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchCLI() throws Exception {
|
||||||
|
Path javaBin = findJava();
|
||||||
|
Path jarPath = getLauncherJar();
|
||||||
|
|
||||||
|
log("Запуск CLI режима...");
|
||||||
|
log("Java: " + javaBin);
|
||||||
|
log("JAR: " + jarPath);
|
||||||
|
|
||||||
|
// JVM аргументы для UTF-8
|
||||||
|
List<String> jvmArgs = List.of(
|
||||||
|
"-Dfile.encoding=UTF-8",
|
||||||
|
"-Dsun.stdout.encoding=UTF-8",
|
||||||
|
"-Dsun.stderr.encoding=UTF-8",
|
||||||
|
"-Dlauncher.server=" + BASE_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(javaBin.toAbsolutePath().toString());
|
||||||
|
cmd.addAll(jvmArgs);
|
||||||
|
cmd.add("-jar");
|
||||||
|
cmd.add(jarPath.toAbsolutePath().toString());
|
||||||
|
cmd.add("--cli");
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
pb.directory(baseDir.toFile());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process p = pb.start();
|
||||||
|
int code = p.waitFor();
|
||||||
|
log("Завершено с кодом: " + code);
|
||||||
|
System.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path findJava() {
|
||||||
|
String os = System.getProperty("os.name").toLowerCase();
|
||||||
|
String javaExe = os.contains("windows") ? "java.exe" : "java";
|
||||||
|
|
||||||
|
// Сначала ищем jre21/bin/java рядом с лаунчером
|
||||||
|
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
|
||||||
|
|
||||||
|
// Если нет, пробуем системную Java
|
||||||
|
if (!Files.exists(javaBin)) {
|
||||||
|
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если и это не найдено - ищем java в PATH
|
||||||
|
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 не найдена. Убедитесь, что jre21 присутствует в папке с лаунчером или Java установлена в системе");
|
||||||
|
}
|
||||||
|
|
||||||
|
return javaBin;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,49 +33,6 @@
|
|||||||
</manifestEntries>
|
</manifestEntries>
|
||||||
</transformer>
|
</transformer>
|
||||||
</transformers>
|
</transformers>
|
||||||
<filters>
|
|
||||||
<filter>
|
|
||||||
<artifact>*:*</artifact>
|
|
||||||
<excludes>
|
|
||||||
<exclude>META-INF/*.SF</exclude>
|
|
||||||
<exclude>META-INF/*.DSA</exclude>
|
|
||||||
<exclude>META-INF/*.RSA</exclude>
|
|
||||||
</excludes>
|
|
||||||
</filter>
|
|
||||||
<filter>
|
|
||||||
<artifact>org.openjfx:*</artifact>
|
|
||||||
<excludes>
|
|
||||||
<exclude>**/*</exclude>
|
|
||||||
</excludes>
|
|
||||||
</filter>
|
|
||||||
</filters>
|
|
||||||
<dependencySet>
|
|
||||||
<outputDirectory>/</outputDirectory>
|
|
||||||
<useProjectArtifact>false</useProjectArtifact>
|
|
||||||
<unpack>true</unpack>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
<excludes>
|
|
||||||
<exclude>org.openjfx:*</exclude>
|
|
||||||
</excludes>
|
|
||||||
</dependencySet>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
<plugin>
|
|
||||||
<artifactId>maven-dependency-plugin</artifactId>
|
|
||||||
<version>3.6.0</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>copy-javafx</id>
|
|
||||||
<phase>package</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>copy-dependencies</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<outputDirectory>${project.build.directory}/lib-javafx</outputDirectory>
|
|
||||||
<includeScope>runtime</includeScope>
|
|
||||||
<includeGroupIds>org.openjfx</includeGroupIds>
|
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
@@ -94,16 +51,12 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
|
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
|
||||||
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
||||||
<headerType>gui</headerType>
|
<headerType>console</headerType>
|
||||||
<dontWrapJar>false</dontWrapJar>
|
<dontWrapJar>false</dontWrapJar>
|
||||||
|
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||||
<jre>
|
<jre>
|
||||||
<path>jre21</path>
|
<path>jre21</path>
|
||||||
<minVersion>21</minVersion>
|
<minVersion>21</minVersion>
|
||||||
<opts>
|
|
||||||
<opt>--module-path=lib-javafx</opt>
|
|
||||||
<opt>--add-modules=javafx.controls,javafx.web</opt>
|
|
||||||
<opt>--add-reads=javafx.graphics=ALL-UNNAMED</opt>
|
|
||||||
</opts>
|
|
||||||
</jre>
|
</jre>
|
||||||
<versionInfo>
|
<versionInfo>
|
||||||
<fileVersion>${project.version}.0</fileVersion>
|
<fileVersion>${project.version}.0</fileVersion>
|
||||||
@@ -119,9 +72,6 @@
|
|||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
<configuration>
|
|
||||||
<skip>${skip.launch4j}</skip>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<artifactId>maven-antrun-plugin</artifactId>
|
<artifactId>maven-antrun-plugin</artifactId>
|
||||||
@@ -135,12 +85,15 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
<target>
|
<target>
|
||||||
<echo>${project.version}</echo>
|
<echo>${project.version}</echo>
|
||||||
|
<delete />
|
||||||
|
<mkdir />
|
||||||
<copy>
|
<copy>
|
||||||
<fileset />
|
<fileset>
|
||||||
</copy>
|
<include />
|
||||||
<copy>
|
<include />
|
||||||
<fileset />
|
</fileset>
|
||||||
</copy>
|
</copy>
|
||||||
|
<move />
|
||||||
<zip />
|
<zip />
|
||||||
</target>
|
</target>
|
||||||
</configuration>
|
</configuration>
|
||||||
@@ -166,22 +119,6 @@
|
|||||||
<server.url>http://87.120.187.36:1582</server.url>
|
<server.url>http://87.120.187.36:1582</server.url>
|
||||||
</properties>
|
</properties>
|
||||||
</profile>
|
</profile>
|
||||||
<profile>
|
|
||||||
<id>win</id>
|
|
||||||
<properties>
|
|
||||||
<os.suffix>win</os.suffix>
|
|
||||||
<javafx.classifier>win</javafx.classifier>
|
|
||||||
<skip.launch4j>false</skip.launch4j>
|
|
||||||
</properties>
|
|
||||||
</profile>
|
|
||||||
<profile>
|
|
||||||
<id>linux</id>
|
|
||||||
<properties>
|
|
||||||
<os.suffix>linux</os.suffix>
|
|
||||||
<javafx.classifier>linux</javafx.classifier>
|
|
||||||
<skip.launch4j>true</skip.launch4j>
|
|
||||||
</properties>
|
|
||||||
</profile>
|
|
||||||
</profiles>
|
</profiles>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -210,10 +147,7 @@
|
|||||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<project.organization.name>ZernMC</project.organization.name>
|
<project.organization.name>ZernMC</project.organization.name>
|
||||||
<javafx.classifier>win</javafx.classifier>
|
|
||||||
<skip.launch4j>false</skip.launch4j>
|
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
<os.suffix>win</os.suffix>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.inceptionYear>2026</project.inceptionYear>
|
<project.inceptionYear>2026</project.inceptionYear>
|
||||||
</properties>
|
</properties>
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<?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>
|
||||||
|
<execution>
|
||||||
|
<id>l4j</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>launch4j</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<outfile>../../server/builds/zernmc-${project.version}.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</fileDescription>
|
||||||
|
<productVersion>${project.version}.0</productVersion>
|
||||||
|
<txtProductVersion>${project.version}</txtProductVersion>
|
||||||
|
<productName>ZernMC</productName>
|
||||||
|
<companyName>ZernMC</companyName>
|
||||||
|
<internalName>zernmc</internalName>
|
||||||
|
<originalFilename>zernmc-${project.version}.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>
|
||||||
|
|
||||||
|
<!-- Переименовываем exe для zip -->
|
||||||
|
<move file="../../server/builds/zernmc-${project.version}.exe"
|
||||||
|
tofile="../../server/builds/zernmc.exe" overwrite="true"/>
|
||||||
|
|
||||||
|
<!-- Создаем папку 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>
|
||||||
|
|
||||||
|
<!-- Создаём zip -->
|
||||||
|
<zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
|
||||||
|
basedir="../../server/builds"
|
||||||
|
includes="zernmc.exe,bin/**,assets/**,lib/**"
|
||||||
|
excludes="build.version,*-${project.version}.*,zernmclauncher.jar,zernmc-bootstrap.jar"/>
|
||||||
|
</target>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
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; // по умолчанию JFX
|
||||||
|
|
||||||
|
// Проверка и обновление лаунчера
|
||||||
|
String currentVersion = readCurrentVersion();
|
||||||
|
String serverVersion = getServerVersion();
|
||||||
|
|
||||||
|
log("Локальная версия: " + currentVersion);
|
||||||
|
log("Версия на сервере: " + serverVersion);
|
||||||
|
|
||||||
|
if (isNewer(serverVersion, currentVersion)) {
|
||||||
|
log("Доступно обновление!");
|
||||||
|
downloadUpdate(serverVersion);
|
||||||
|
} else {
|
||||||
|
log("Версия актуальна");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск в выбранном режиме
|
||||||
|
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.replace("download?type=jar", "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("\rСкачано: " + (total/1024/1024) + " MB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("Скачано");
|
||||||
|
|
||||||
|
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("Обновлено до v" + newVersion);
|
||||||
|
} else {
|
||||||
|
throw new IOException("Сервер вернул код: " + conn.getResponseCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchJFX() throws Exception {
|
||||||
|
Path javaBin = findJava();
|
||||||
|
Path jarPath = baseDir.resolve(JAR_NAME);
|
||||||
|
|
||||||
|
log("Запуск JFX режима...");
|
||||||
|
log("Java: " + javaBin);
|
||||||
|
log("JAR: " + jarPath);
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
|
javaBin.toAbsolutePath().toString(),
|
||||||
|
"-jar",
|
||||||
|
jarPath.toAbsolutePath().toString(),
|
||||||
|
"--jfx"
|
||||||
|
);
|
||||||
|
pb.directory(baseDir.toFile());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process p = pb.start();
|
||||||
|
int code = p.waitFor();
|
||||||
|
log("Завершено с кодом: " + code);
|
||||||
|
System.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchCLI() throws Exception {
|
||||||
|
Path javaBin = findJava();
|
||||||
|
Path jarPath = baseDir.resolve(JAR_NAME);
|
||||||
|
|
||||||
|
log("Запуск CLI режима...");
|
||||||
|
log("Java: " + javaBin);
|
||||||
|
log("JAR: " + jarPath);
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
|
javaBin.toAbsolutePath().toString(),
|
||||||
|
"-jar",
|
||||||
|
jarPath.toAbsolutePath().toString(),
|
||||||
|
"--cli"
|
||||||
|
);
|
||||||
|
pb.directory(baseDir.toFile());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process p = pb.start();
|
||||||
|
int code = p.waitFor();
|
||||||
|
log("Завершено с кодом: " + code);
|
||||||
|
System.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path findJava() {
|
||||||
|
String os = System.getProperty("os.name").toLowerCase();
|
||||||
|
String javaExe = os.contains("windows") ? "java.exe" : "java";
|
||||||
|
|
||||||
|
// Сначала ищем jre21/bin/java рядом с лаунчером
|
||||||
|
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
|
||||||
|
|
||||||
|
// Если нет, пробуем системную Java
|
||||||
|
if (!Files.exists(javaBin)) {
|
||||||
|
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если и это не найдено - ищем java в PATH
|
||||||
|
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 не найдена. Убедитесь, что jre21 присутствует в папке с лаунчером или Java установлена в системе");
|
||||||
|
}
|
||||||
|
|
||||||
|
return javaBin;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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 {
|
||||||
|
// Настройка кодировки для Windows и Linux
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Для Windows CMD - пытаемся переключить в UTF-8 режим
|
||||||
|
try {
|
||||||
|
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||||
|
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
ZAnsi.install();
|
||||||
|
System.out.print("\033[H\033[2J");
|
||||||
|
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
|
||||||
|
|
||||||
|
// Определяем режим запуска
|
||||||
|
List<String> argList = List.of(args);
|
||||||
|
boolean jfxMode = argList.contains("--jfx");
|
||||||
|
boolean cliMode = argList.contains("--cli");
|
||||||
|
|
||||||
|
if (jfxMode) {
|
||||||
|
launchJFX();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI режим (по умолчанию или с --cli)
|
||||||
|
startCLI();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchJFX() {
|
||||||
|
System.out.println(ZAnsi.cyan("Запуск JFX интерфейса..."));
|
||||||
|
try {
|
||||||
|
// Устанавливаем параметры для JavaFX (важно для Windows)
|
||||||
|
System.setProperty("javafx.runtime.version", "21");
|
||||||
|
|
||||||
|
JFXLauncher.main(new String[]{});
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println(ZAnsi.brightRed("Ошибка запуска JFX: " + e.getMessage()));
|
||||||
|
// Проверяем, связано ли это с отсутствием JavaFX
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
|
||||||
|
System.err.println(ZAnsi.yellow("JavaFX недоступен. Возможно, отсутствуют нативные библиотеки."));
|
||||||
|
System.err.println(ZAnsi.yellow("Попробуйте использовать CLI режим: --cli"));
|
||||||
|
}
|
||||||
|
e.printStackTrace();
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void startCLI() throws IOException {
|
||||||
|
// Проверка всех сервисов при старте
|
||||||
|
ZHttpClient.checkAllServicesOnStartup();
|
||||||
|
|
||||||
|
// === АВТОРИЗАЦИЯ (используем новый API) ===
|
||||||
|
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
|
||||||
|
var sessionResponse = api.checkSession();
|
||||||
|
|
||||||
|
if (!sessionResponse.isSuccess()) {
|
||||||
|
LoginMenu loginMenu = new LoginMenu();
|
||||||
|
boolean loggedIn = loginMenu.show();
|
||||||
|
if (!loggedIn) {
|
||||||
|
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||||
|
ZAnsi.uninstall();
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var sessionInfo = sessionResponse.getData();
|
||||||
|
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.getUsername() + "!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ГЛАВНЫЙ ЦИКЛ ===
|
||||||
|
try {
|
||||||
|
mainLoop();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage()));
|
||||||
|
e.printStackTrace();
|
||||||
|
} finally {
|
||||||
|
ZAnsi.uninstall();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
|
||||||
|
private static void mainLoop() throws Exception {
|
||||||
|
if (Config.isZernMCBuild()) {
|
||||||
|
zernMCFlow();
|
||||||
|
} else {
|
||||||
|
globalFlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== ZERNMC FLOW ======================
|
||||||
|
private static void zernMCFlow() throws Exception {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
|
||||||
|
|
||||||
|
// 1. Проверка подключения к серверу
|
||||||
|
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
||||||
|
try {
|
||||||
|
String response = ZHttpClient.get("/health");
|
||||||
|
System.out.println(ZAnsi.brightGreen("✓ Сервер доступен"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу"));
|
||||||
|
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage()));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Авторизация
|
||||||
|
boolean sessionRestored = AuthManager.loadSavedSession();
|
||||||
|
if (!sessionRestored) {
|
||||||
|
LoginMenu loginMenu = new LoginMenu();
|
||||||
|
boolean loggedIn = loginMenu.show();
|
||||||
|
if (!loggedIn) {
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow)
|
||||||
|
LaunchMenu launchMenu = new LaunchMenu();
|
||||||
|
launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== GLOBAL FLOW ======================
|
||||||
|
private static void globalFlow() throws Exception {
|
||||||
|
while (true) {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
|
||||||
|
|
||||||
|
List<String> options = List.of(
|
||||||
|
"Запустить игру",
|
||||||
|
"Проверка обновлений",
|
||||||
|
"Настройки",
|
||||||
|
"Проверка подключения к серверам",
|
||||||
|
"Выход"
|
||||||
|
);
|
||||||
|
|
||||||
|
ArrowMenu menu = new ArrowMenu("Главное меню", options);
|
||||||
|
int choice = menu.show();
|
||||||
|
|
||||||
|
if (choice == -1 || choice == 4) {
|
||||||
|
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
|
||||||
|
case 1 -> new UpdateMenu().show();
|
||||||
|
case 2 -> new SettingsMenu().show();
|
||||||
|
case 3 -> new ServerCheckMenu().show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-7
@@ -2,7 +2,6 @@ package me.sashegdev.zernmc.launcher.api;
|
|||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
|
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
|
||||||
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
||||||
import me.sashegdev.zernmc.launcher.api.install.InstallService;
|
|
||||||
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
|
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -16,13 +15,11 @@ public class LauncherAPI {
|
|||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final InstanceService instanceService;
|
private final InstanceService instanceService;
|
||||||
private final LaunchService launchService;
|
private final LaunchService launchService;
|
||||||
private final InstallService installService;
|
|
||||||
|
|
||||||
public LauncherAPI() {
|
public LauncherAPI() {
|
||||||
this.authService = new AuthService();
|
this.authService = new AuthService();
|
||||||
this.instanceService = new InstanceService();
|
this.instanceService = new InstanceService();
|
||||||
this.launchService = new LaunchService();
|
this.launchService = new LaunchService();
|
||||||
this.installService = new InstallService();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AuthService auth() {
|
public AuthService auth() {
|
||||||
@@ -37,10 +34,6 @@ public class LauncherAPI {
|
|||||||
return launchService;
|
return launchService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public InstallService install() {
|
|
||||||
return installService;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== Удобные методы ======================
|
// ====================== Удобные методы ======================
|
||||||
|
|
||||||
public boolean isLoggedIn() {
|
public boolean isLoggedIn() {
|
||||||
+10
-2
@@ -37,7 +37,9 @@ public class AuthService {
|
|||||||
SessionInfo info = new SessionInfo(
|
SessionInfo info = new SessionInfo(
|
||||||
AuthManager.getUsername(),
|
AuthManager.getUsername(),
|
||||||
AuthManager.getAccessToken(),
|
AuthManager.getAccessToken(),
|
||||||
AuthManager.hasActivePass()
|
AuthManager.hasActivePass(),
|
||||||
|
AuthManager.getRole(),
|
||||||
|
AuthManager.getRoleName()
|
||||||
);
|
);
|
||||||
return ApiResponse.success(info);
|
return ApiResponse.success(info);
|
||||||
}
|
}
|
||||||
@@ -120,15 +122,21 @@ public class AuthService {
|
|||||||
private String username;
|
private String username;
|
||||||
private String token;
|
private String token;
|
||||||
private boolean passActive;
|
private boolean passActive;
|
||||||
|
private int role;
|
||||||
|
private String roleName;
|
||||||
|
|
||||||
public SessionInfo(String username, String token, boolean passActive) {
|
public SessionInfo(String username, String token, boolean passActive, int role, String roleName) {
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.passActive = passActive;
|
this.passActive = passActive;
|
||||||
|
this.role = role;
|
||||||
|
this.roleName = roleName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getUsername() { return username; }
|
public String getUsername() { return username; }
|
||||||
public String getToken() { return token; }
|
public String getToken() { return token; }
|
||||||
public boolean isPassActive() { return passActive; }
|
public boolean isPassActive() { return passActive; }
|
||||||
|
public int getRole() { return role; }
|
||||||
|
public String getRoleName() { return roleName; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-11
@@ -68,14 +68,12 @@ public class InstanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private InstanceInfo toInstanceInfo(Instance instance) {
|
private InstanceInfo toInstanceInfo(Instance instance) {
|
||||||
return new InstanceInfo(
|
return new InstanceInfo(
|
||||||
instance.getName(),
|
instance.getName(),
|
||||||
instance.getPath().toString(),
|
instance.getPath().toString(),
|
||||||
instance.getMinecraftVersion(),
|
instance.getMinecraftVersion(),
|
||||||
instance.getLoaderType(),
|
instance.getLoaderType()
|
||||||
instance.isServerPack(),
|
|
||||||
instance.getServerPackName()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,23 +82,17 @@ private InstanceInfo toInstanceInfo(Instance instance) {
|
|||||||
private String path;
|
private String path;
|
||||||
private String version;
|
private String version;
|
||||||
private String loaderType;
|
private String loaderType;
|
||||||
private boolean isServerPack;
|
|
||||||
private String serverPackName;
|
|
||||||
|
|
||||||
public InstanceInfo(String name, String path, String version, String loaderType, boolean isServerPack, String serverPackName) {
|
public InstanceInfo(String name, String path, String version, String loaderType) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.path = path;
|
this.path = path;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
this.loaderType = loaderType;
|
this.loaderType = loaderType;
|
||||||
this.isServerPack = isServerPack;
|
|
||||||
this.serverPackName = serverPackName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public String getPath() { return path; }
|
public String getPath() { return path; }
|
||||||
public String getVersion() { return version; }
|
public String getVersion() { return version; }
|
||||||
public String getLoaderType() { return loaderType; }
|
public String getLoaderType() { return loaderType; }
|
||||||
public boolean isServerPack() { return isServerPack; }
|
|
||||||
public String getServerPackName() { return serverPackName; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+47
-1
@@ -1,12 +1,16 @@
|
|||||||
package me.sashegdev.zernmc.launcher.api.launch;
|
package me.sashegdev.zernmc.launcher.api.launch;
|
||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
|
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -42,16 +46,58 @@ public class LaunchService {
|
|||||||
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JFXLauncher.initGameLog(instance.getPath());
|
||||||
|
|
||||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||||
LaunchOptions options = new LaunchOptions();
|
LaunchOptions options = new LaunchOptions();
|
||||||
|
|
||||||
|
// Set auth info
|
||||||
|
options.setUsername(AuthManager.getUsername());
|
||||||
|
options.setAccessToken(AuthManager.getAccessToken());
|
||||||
|
options.setUuid(AuthManager.getUuid());
|
||||||
|
|
||||||
List<String> command = builder.build(options);
|
List<String> command = builder.build(options);
|
||||||
|
System.out.println("[LAUNCH] Generated command for " + instanceName + ":");
|
||||||
|
command.forEach(arg -> System.out.println(" " + arg));
|
||||||
|
|
||||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||||
processBuilder.directory(instance.getPath().toFile());
|
processBuilder.directory(instance.getPath().toFile());
|
||||||
processBuilder.inheritIO();
|
processBuilder.redirectErrorStream(true);
|
||||||
|
|
||||||
Process process = processBuilder.start();
|
Process process = processBuilder.start();
|
||||||
|
System.out.println("[LAUNCH] Process started, pid=" + process.pid());
|
||||||
|
|
||||||
|
// Capture output (stdout)
|
||||||
|
Thread outThread = new Thread(() -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
System.out.println("[STDOUT] " + line);
|
||||||
|
JFXLauncher.appendGameLog(line);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[STDOUT ERROR] " + e.getMessage());
|
||||||
|
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
outThread.setDaemon(true);
|
||||||
|
outThread.start();
|
||||||
|
|
||||||
|
// Capture errors (stderr)
|
||||||
|
Thread errThread = new Thread(() -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
System.out.println("[STDERR] " + line);
|
||||||
|
JFXLauncher.appendGameLog("[ERR] " + line);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[STDERR ERROR] " + e.getMessage());
|
||||||
|
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
errThread.setDaemon(true);
|
||||||
|
errThread.start();
|
||||||
|
|
||||||
ProcessInfo info = new ProcessInfo(
|
ProcessInfo info = new ProcessInfo(
|
||||||
instanceName,
|
instanceName,
|
||||||
+13
-1
@@ -10,6 +10,7 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
|||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -213,6 +214,13 @@ public class AuthManager {
|
|||||||
return session != null ? session.role : ROLE_USER;
|
return session != null ? session.role : ROLE_USER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String getRoleName() {
|
||||||
|
if (userInfo != null && userInfo.role_name != null) {
|
||||||
|
return userInfo.role_name;
|
||||||
|
}
|
||||||
|
return "USER";
|
||||||
|
}
|
||||||
|
|
||||||
// ====================== POST ======================
|
// ====================== POST ======================
|
||||||
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
|
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
|
||||||
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
||||||
@@ -245,12 +253,16 @@ public class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int statusCode = conn.getResponseCode();
|
int statusCode = conn.getResponseCode();
|
||||||
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
InputStream is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||||
|
|
||||||
String responseBody;
|
String responseBody;
|
||||||
|
if (is != null) {
|
||||||
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
||||||
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
responseBody = "No response body (status " + statusCode + ")";
|
||||||
|
}
|
||||||
|
|
||||||
return new SimpleHttpResponse(statusCode, responseBody);
|
return new SimpleHttpResponse(statusCode, responseBody);
|
||||||
|
|
||||||
+7
-27
@@ -25,18 +25,12 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
public class LaunchMenu {
|
public class LaunchMenu {
|
||||||
|
|
||||||
public static class ExitToMainMenuException extends Exception {}
|
|
||||||
|
|
||||||
public void show() throws Exception {
|
public void show() throws Exception {
|
||||||
try {
|
|
||||||
if (Config.isZernMCBuild()) {
|
if (Config.isZernMCBuild()) {
|
||||||
showZernMCOnly();
|
showZernMCOnly();
|
||||||
} else {
|
} else {
|
||||||
showGlobal();
|
showGlobal();
|
||||||
}
|
}
|
||||||
} catch (ExitToMainMenuException e) {
|
|
||||||
// Возвращаемся в главное меню - ничего не делаем, просто выходим
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================== ZERNMC BUILD ======================
|
// ====================== ZERNMC BUILD ======================
|
||||||
@@ -288,15 +282,6 @@ public class LaunchMenu {
|
|||||||
// ====================== manageInstance — полностью восстановлен ======================
|
// ====================== manageInstance — полностью восстановлен ======================
|
||||||
private void manageInstance(Instance instance) throws Exception {
|
private void manageInstance(Instance instance) throws Exception {
|
||||||
while (true) {
|
while (true) {
|
||||||
// Проверяем, существует ли сборка (на случай если она была удалена вручную)
|
|
||||||
Instance currentInstance = InstanceManager.getInstance(instance.getName());
|
|
||||||
if (currentInstance == null) {
|
|
||||||
System.out.println(ZAnsi.yellow("Сборка была удалена или не существует."));
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
throw new ExitToMainMenuException(); // Выходим в главное меню
|
|
||||||
}
|
|
||||||
instance = currentInstance; // Обновляем ссылку на актуальный объект
|
|
||||||
|
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName()));
|
System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName()));
|
||||||
System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion()));
|
System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion()));
|
||||||
@@ -335,13 +320,9 @@ public class LaunchMenu {
|
|||||||
changeLoaderVersion(instance);
|
changeLoaderVersion(instance);
|
||||||
} else {
|
} else {
|
||||||
deleteInstance(instance);
|
deleteInstance(instance);
|
||||||
throw new ExitToMainMenuException(); // Выходим в главное меню
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 3 -> {
|
case 3 -> deleteInstance(instance);
|
||||||
deleteInstance(instance);
|
|
||||||
throw new ExitToMainMenuException(); // Выходим в главное меню после удаления
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,8 +332,7 @@ public class LaunchMenu {
|
|||||||
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName()));
|
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName()));
|
||||||
|
|
||||||
PackDownloader downloader = new PackDownloader(instance);
|
PackDownloader downloader = new PackDownloader(instance);
|
||||||
int serverVersion = downloader.checkForUpdates(instance.getServerPackName());
|
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||||
boolean hasUpdate = serverVersion > 0;
|
|
||||||
|
|
||||||
if (!hasUpdate) {
|
if (!hasUpdate) {
|
||||||
System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")"));
|
System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")"));
|
||||||
@@ -443,15 +423,14 @@ public class LaunchMenu {
|
|||||||
boolean deleted = InstanceManager.deleteInstance(instance.getName());
|
boolean deleted = InstanceManager.deleteInstance(instance.getName());
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена."));
|
System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена."));
|
||||||
// НЕ делаем pause(), сразу возвращаемся в manageInstance для выхода в меню сборок
|
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.brightRed("Не удалось удалить сборку."));
|
System.out.println(ZAnsi.brightRed("Не удалось удалить сборку."));
|
||||||
ConsoleUtils.pause();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow("Удаление отменено."));
|
System.out.println(ZAnsi.yellow("Удаление отменено."));
|
||||||
ConsoleUtils.pause();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ConsoleUtils.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void launchExistingInstance(Instance instance) {
|
private void launchExistingInstance(Instance instance) {
|
||||||
@@ -646,8 +625,9 @@ public class LaunchMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isNeoForgeSupported(String version) {
|
private boolean isNeoForgeSupported(String version) {
|
||||||
// ВРЕМЕННО ОТКЛЮЧЕНО: в разработке
|
return version.matches("^1\\.20\\.[1-9].*") ||
|
||||||
return false;
|
version.matches("^1\\.21.*") ||
|
||||||
|
version.matches("^\\d{2}\\..*");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String askFabricLoaderVersion() throws Exception {
|
private String askFabricLoaderVersion() throws Exception {
|
||||||
+2
-2
@@ -166,9 +166,9 @@ public class LoginMenu {
|
|||||||
|
|
||||||
if (key == 27) {
|
if (key == 27) {
|
||||||
// Escape sequence — consume remaining bytes (arrow keys, etc.)
|
// Escape sequence — consume remaining bytes (arrow keys, etc.)
|
||||||
int next = passTerminal.reader().read(50);
|
int next = passTerminal.reader().read();
|
||||||
if (next == 91) { // '[' — arrow key sequence
|
if (next == 91) { // '[' — arrow key sequence
|
||||||
passTerminal.reader().read(50); // consume 'A'/'B'/'C'/'D'
|
passTerminal.reader().read(); // consume 'A'/'B'/'C'/'D'
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
+1
-2
@@ -66,8 +66,7 @@ public class UpdateMenu {
|
|||||||
PackDownloader downloader = new PackDownloader(instance);
|
PackDownloader downloader = new PackDownloader(instance);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
int serverVersion = downloader.checkForUpdates(instance.getServerPackName());
|
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||||
boolean hasUpdate = serverVersion > 0;
|
|
||||||
if (hasUpdate) {
|
if (hasUpdate) {
|
||||||
System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!"));
|
System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!"));
|
||||||
updatableInstances.add(instance);
|
updatableInstances.add(instance);
|
||||||
+35
-3
@@ -6,10 +6,14 @@ import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
|
|||||||
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
|
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -114,15 +118,43 @@ public class MinecraftLib {
|
|||||||
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(command);
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
pb.directory(instance.getPath().toFile());
|
pb.directory(instance.getPath().toFile());
|
||||||
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
|
||||||
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
|
|
||||||
pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
|
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
|
|
||||||
Process process = pb.start();
|
Process process = pb.start();
|
||||||
|
|
||||||
|
// 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("[Ошибка чтения вывода: " + 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("[Ошибка чтения ошибок: " + e.getMessage() + "]");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
errThread.setDaemon(true);
|
||||||
|
errThread.start();
|
||||||
|
|
||||||
int exitCode = process.waitFor();
|
int exitCode = process.waitFor();
|
||||||
|
outThread.join(1000);
|
||||||
|
errThread.join(1000);
|
||||||
|
|
||||||
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
|
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
|
||||||
}
|
}
|
||||||
+17
-81
@@ -29,55 +29,12 @@ public class PackDownloader {
|
|||||||
private final Instance instance;
|
private final Instance instance;
|
||||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||||
private ProgressCallback progressCallback;
|
//private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
|
||||||
|
|
||||||
public interface ProgressCallback {
|
|
||||||
void onProgress(ProgressInfo info);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class ProgressInfo {
|
|
||||||
private String phase;
|
|
||||||
private int totalFiles;
|
|
||||||
private int downloadedFiles;
|
|
||||||
private String currentFile;
|
|
||||||
private long fileSize;
|
|
||||||
private long downloadedBytes;
|
|
||||||
private int filePercent;
|
|
||||||
private int totalPercent;
|
|
||||||
private String eta;
|
|
||||||
|
|
||||||
public ProgressInfo(String phase, int totalFiles, int downloadedFiles, String currentFile,
|
|
||||||
long fileSize, long downloadedBytes, int filePercent, int totalPercent, String eta) {
|
|
||||||
this.phase = phase;
|
|
||||||
this.totalFiles = totalFiles;
|
|
||||||
this.downloadedFiles = downloadedFiles;
|
|
||||||
this.currentFile = currentFile;
|
|
||||||
this.fileSize = fileSize;
|
|
||||||
this.downloadedBytes = downloadedBytes;
|
|
||||||
this.filePercent = filePercent;
|
|
||||||
this.totalPercent = totalPercent;
|
|
||||||
this.eta = eta;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getPhase() { return phase; }
|
|
||||||
public int getTotalFiles() { return totalFiles; }
|
|
||||||
public int getDownloadedFiles() { return downloadedFiles; }
|
|
||||||
public String getCurrentFile() { return currentFile; }
|
|
||||||
public long getFileSize() { return fileSize; }
|
|
||||||
public long getDownloadedBytes() { return downloadedBytes; }
|
|
||||||
public int getFilePercent() { return filePercent; }
|
|
||||||
public int getTotalPercent() { return totalPercent; }
|
|
||||||
public String getEta() { return eta; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public PackDownloader(Instance instance) {
|
public PackDownloader(Instance instance) {
|
||||||
this.instance = instance;
|
this.instance = instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setProgressCallback(ProgressCallback callback) {
|
|
||||||
this.progressCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить список доступных паков с сервера
|
* Получить список доступных паков с сервера
|
||||||
*/
|
*/
|
||||||
@@ -268,16 +225,15 @@ public class PackDownloader {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверить наличие обновлений для серверной сборки
|
* Проверить наличие обновлений для серверной сборки
|
||||||
* @return версия на сервере, или 0 если нет обновлений
|
|
||||||
*/
|
*/
|
||||||
public int checkForUpdates(String packName) throws Exception {
|
public boolean checkForUpdates(String packName) throws Exception {
|
||||||
if (!instance.isServerPack()) return 0;
|
if (!instance.isServerPack()) return false;
|
||||||
|
|
||||||
PackManifest manifest = getPackManifest(packName);
|
PackManifest manifest = getPackManifest(packName);
|
||||||
int serverVersion = manifest.getVersion();
|
int serverVersion = manifest.getVersion();
|
||||||
int localVersion = instance.getServerVersion();
|
int localVersion = instance.getServerVersion();
|
||||||
|
|
||||||
return serverVersion > localVersion ? serverVersion : 0;
|
return serverVersion > localVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -316,7 +272,7 @@ public class PackDownloader {
|
|||||||
/**
|
/**
|
||||||
* Сканирование локальных файлов и вычисление хешей
|
* Сканирование локальных файлов и вычисление хешей
|
||||||
*/
|
*/
|
||||||
public Map<String, String> scanLocalFiles() throws IOException {
|
private Map<String, String> scanLocalFiles() throws IOException {
|
||||||
Map<String, String> files = new HashMap<>();
|
Map<String, String> files = new HashMap<>();
|
||||||
Path instancePath = instance.getPath();
|
Path instancePath = instance.getPath();
|
||||||
|
|
||||||
@@ -356,9 +312,9 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправить diff запрос на сервер (получить список файлов для обновления)
|
* Отправить diff запрос на сервер
|
||||||
*/
|
*/
|
||||||
public DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
||||||
String json = gson.toJson(localFiles);
|
String json = gson.toJson(localFiles);
|
||||||
|
|
||||||
// Получаем токен авторизации
|
// Получаем токен авторизации
|
||||||
@@ -442,10 +398,7 @@ public class PackDownloader {
|
|||||||
System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов");
|
System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов");
|
||||||
System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов");
|
System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов");
|
||||||
|
|
||||||
if (progressCallback != null) {
|
// Создаем директории если нужно
|
||||||
progressCallback.onProgress(new ProgressInfo("starting", diff.getToDownload().size(), 0, "", 0, 0, 0, 0, ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(instance.getPath());
|
Files.createDirectories(instance.getPath());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@@ -453,6 +406,7 @@ public class PackDownloader {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Удаляем файлы
|
||||||
for (String filePath : diff.getToDelete()) {
|
for (String filePath : diff.getToDelete()) {
|
||||||
Path fullPath = instance.getPath().resolve(filePath);
|
Path fullPath = instance.getPath().resolve(filePath);
|
||||||
try {
|
try {
|
||||||
@@ -464,6 +418,7 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Скачиваем файлы
|
||||||
AtomicInteger downloaded = new AtomicInteger(0);
|
AtomicInteger downloaded = new AtomicInteger(0);
|
||||||
int total = diff.getToDownload().size();
|
int total = diff.getToDownload().size();
|
||||||
|
|
||||||
@@ -472,10 +427,13 @@ public class PackDownloader {
|
|||||||
Path fullPath = instance.getPath().resolve(path);
|
Path fullPath = instance.getPath().resolve(path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Создаем директории
|
||||||
Files.createDirectories(fullPath.getParent());
|
Files.createDirectories(fullPath.getParent());
|
||||||
|
|
||||||
downloadFile(file, fullPath, progressCallback, downloaded.get(), total);
|
// Скачиваем файл
|
||||||
|
downloadFile(file, fullPath);
|
||||||
|
|
||||||
|
// Проверяем хеш
|
||||||
String actualHash = calculateHash(fullPath);
|
String actualHash = calculateHash(fullPath);
|
||||||
if (!actualHash.equals(file.getHash())) {
|
if (!actualHash.equals(file.getHash())) {
|
||||||
throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() +
|
throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() +
|
||||||
@@ -487,11 +445,6 @@ public class PackDownloader {
|
|||||||
ProgressBar.show("Скачивание", downloaded.get(), total, "файлов");
|
ProgressBar.show("Скачивание", downloaded.get(), total, "файлов");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progressCallback != null) {
|
|
||||||
progressCallback.onProgress(new ProgressInfo("downloading", total, downloaded.get(), path,
|
|
||||||
file.getSize(), file.getSize(), 100, (downloaded.get() * 100) / total, ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage()));
|
System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage()));
|
||||||
return false;
|
return false;
|
||||||
@@ -502,21 +455,13 @@ public class PackDownloader {
|
|||||||
ProgressBar.finish("Скачивание");
|
ProgressBar.finish("Скачивание");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progressCallback != null) {
|
|
||||||
progressCallback.onProgress(new ProgressInfo("complete", total, total, "", 0, 0, 100, 100, ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Скачать один файл с сервера
|
* Скачать один файл с сервера
|
||||||
*/
|
*/
|
||||||
private void downloadFile(FileInfo file, Path destination) throws Exception {
|
private void downloadFile(FileInfo file, Path destination) throws Exception {
|
||||||
downloadFile(file, destination, null, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void downloadFile(FileInfo file, Path destination, ProgressCallback callback, int downloadedFiles, int totalFiles) throws Exception {
|
|
||||||
String url = ZHttpClient.getBaseUrl() + file.getUrl();
|
String url = ZHttpClient.getBaseUrl() + file.getUrl();
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
@@ -531,6 +476,7 @@ public class PackDownloader {
|
|||||||
throw new IOException("HTTP " + response.statusCode());
|
throw new IOException("HTTP " + response.statusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Скачиваем с прогрессом
|
||||||
try (InputStream in = response.body();
|
try (InputStream in = response.body();
|
||||||
FileOutputStream out = new FileOutputStream(destination.toFile())) {
|
FileOutputStream out = new FileOutputStream(destination.toFile())) {
|
||||||
|
|
||||||
@@ -538,29 +484,19 @@ public class PackDownloader {
|
|||||||
int bytesRead;
|
int bytesRead;
|
||||||
long totalRead = 0;
|
long totalRead = 0;
|
||||||
long fileSize = file.getSize();
|
long fileSize = file.getSize();
|
||||||
long lastCallbackTime = 0;
|
|
||||||
|
|
||||||
while ((bytesRead = in.read(buffer)) != -1) {
|
while ((bytesRead = in.read(buffer)) != -1) {
|
||||||
out.write(buffer, 0, bytesRead);
|
out.write(buffer, 0, bytesRead);
|
||||||
totalRead += bytesRead;
|
totalRead += bytesRead;
|
||||||
|
|
||||||
if (fileSize > 0) {
|
if (fileSize > 0 && totalRead % 8192 == 0) {
|
||||||
ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize);
|
ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize);
|
||||||
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
if (callback != null && now - lastCallbackTime > 200) {
|
|
||||||
int filePercent = (int) ((totalRead * 100) / fileSize);
|
|
||||||
int totalPercent = totalFiles > 0 ? ((downloadedFiles * 100 + filePercent) / totalFiles) : 0;
|
|
||||||
callback.onProgress(new ProgressInfo("downloading", totalFiles, downloadedFiles, file.getPath(),
|
|
||||||
fileSize, totalRead, filePercent, totalPercent, ""));
|
|
||||||
lastCallbackTime = now;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ProgressBar.clearLine();
|
ProgressBar.clearLine();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вычисление SHA256 хеша файла
|
* Вычисление SHA256 хеша файла
|
||||||
+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("Установка NeoForge " + neoForgeVersion + " для Minecraft " + mcVersion));
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
|
||||||
|
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||||
|
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||||
|
|
||||||
|
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||||
|
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.setAssetIndex(assetIndex);
|
||||||
|
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("Скачивание NeoForge Installer..."));
|
||||||
|
downloadFileWithProgress(installerUrl, installerJar);
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.cyan("Запуск NeoForge Installer..."));
|
||||||
|
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n"));
|
||||||
|
|
||||||
|
boolean success = runNeoForgeInstaller(installerJar);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
try {
|
||||||
|
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " успешно установлен!"));
|
||||||
|
instance.setMinecraftVersion(mcVersion);
|
||||||
|
instance.setLoaderType("neoforge");
|
||||||
|
instance.setLoaderVersion(neoForgeVersion);
|
||||||
|
|
||||||
|
Files.deleteIfExists(installerJar);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.brightRed("\nОшибка при установке 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("Создан launcher_profiles.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new IOException("HTTP " + response.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
|
||||||
|
|
||||||
|
try (InputStream in = response.body();
|
||||||
|
FileOutputStream out = new FileOutputStream(target.toFile())) {
|
||||||
|
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int bytesRead;
|
||||||
|
long totalRead = 0;
|
||||||
|
int lastPercent = -1;
|
||||||
|
|
||||||
|
while ((bytesRead = in.read(buffer)) != -1) {
|
||||||
|
out.write(buffer, 0, bytesRead);
|
||||||
|
totalRead += bytesRead;
|
||||||
|
|
||||||
|
if (contentLength > 0) {
|
||||||
|
int percent = (int) ((totalRead * 100) / contentLength);
|
||||||
|
if (percent != lastPercent) {
|
||||||
|
String downloaded = ProgressBar.formatBytes(totalRead);
|
||||||
|
String total = ProgressBar.formatBytes(contentLength);
|
||||||
|
ProgressBar.show("NeoForge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
|
||||||
|
lastPercent = percent;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
char[] spinner = {'|', '/', '-', '\\'};
|
||||||
|
int idx = (int) (totalRead / 1024) % 4;
|
||||||
|
System.out.print("\rСкачивание NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean runNeoForgeInstaller(Path installerJar) throws IOException, InterruptedException {
|
||||||
|
int maxRetries = 3;
|
||||||
|
int attempt = 1;
|
||||||
|
|
||||||
|
while (attempt <= maxRetries) {
|
||||||
|
System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + 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("Ошибка при установке. Повторная попытка через 5 секунд..."));
|
||||||
|
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 завершился с кодом ошибки: " + exitCode));
|
||||||
|
|
||||||
|
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. Попробуйте установить другую версию NeoForge"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
|
||||||
|
System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек..."));
|
||||||
|
|
||||||
|
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("Докачка: " + 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-2
@@ -36,15 +36,37 @@ public class LaunchCommandBuilder {
|
|||||||
}
|
}
|
||||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
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();
|
VersionManifest manifest = resolveVersionManifest();
|
||||||
if (manifest != null) {
|
|
||||||
|
// 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("-cp");
|
||||||
command.add(buildClasspathFromManifest(manifest));
|
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 пустой, использую 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);
|
String mainClass = resolveMainClass(manifest);
|
||||||
command.add(mainClass);
|
command.add(mainClass);
|
||||||
|
|
||||||
command.addAll(resolveGameArguments(manifest, options));
|
command.addAll(resolveGameArguments(manifest, options));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
command.add("-cp");
|
command.add("-cp");
|
||||||
command.add(buildVanillaClasspath());
|
command.add(buildVanillaClasspath());
|
||||||
@@ -63,6 +85,10 @@ public class LaunchCommandBuilder {
|
|||||||
JSONObject json = new JSONObject(content);
|
JSONObject json = new JSONObject(content);
|
||||||
System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName()));
|
System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName()));
|
||||||
return new VersionManifest(json);
|
return new VersionManifest(json);
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.yellow("version.json не найден для " + 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) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage()));
|
System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage()));
|
||||||
@@ -76,6 +102,38 @@ public class LaunchCommandBuilder {
|
|||||||
String mcVersion = instance.getMinecraftVersion();
|
String mcVersion = instance.getMinecraftVersion();
|
||||||
String loaderVersion = instance.getLoaderVersion();
|
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)) {
|
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
|
||||||
String[] candidates = {
|
String[] candidates = {
|
||||||
getVersionId(),
|
getVersionId(),
|
||||||
@@ -152,6 +210,10 @@ public class LaunchCommandBuilder {
|
|||||||
String loaderType = instance.getLoaderType().toLowerCase();
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
if ("fabric".equals(loaderType)) {
|
if ("fabric".equals(loaderType)) {
|
||||||
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
|
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";
|
return "net.minecraft.client.main.Main";
|
||||||
}
|
}
|
||||||
@@ -258,6 +320,8 @@ public class LaunchCommandBuilder {
|
|||||||
List<String> paths = new ArrayList<>();
|
List<String> paths = new ArrayList<>();
|
||||||
Path librariesDir = instance.getPath().resolve("libraries");
|
Path librariesDir = instance.getPath().resolve("libraries");
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getLibraries().size() + " libraries in manifest"));
|
||||||
|
|
||||||
for (VersionManifest.Library lib : manifest.getLibraries()) {
|
for (VersionManifest.Library lib : manifest.getLibraries()) {
|
||||||
Path libPath = librariesDir.resolve(lib.artifactPath);
|
Path libPath = librariesDir.resolve(lib.artifactPath);
|
||||||
if (Files.exists(libPath)) {
|
if (Files.exists(libPath)) {
|
||||||
@@ -273,9 +337,12 @@ public class LaunchCommandBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
|
||||||
|
|
||||||
Path versionJar = findVersionJar();
|
Path versionJar = findVersionJar();
|
||||||
if (versionJar != null) {
|
if (versionJar != null) {
|
||||||
paths.add(0, versionJar.toAbsolutePath().toString());
|
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") ? ";" : ":";
|
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
||||||
+10
-1
@@ -99,8 +99,17 @@ public class VersionManifest {
|
|||||||
if (rule.has("features")) {
|
if (rule.has("features")) {
|
||||||
JSONObject features = rule.getJSONObject("features");
|
JSONObject features = rule.getJSONObject("features");
|
||||||
for (String key : features.keySet()) {
|
for (String key : features.keySet()) {
|
||||||
if (key.startsWith("is_demo_user") || key.startsWith("has_custom_resolution")) continue;
|
if (key.startsWith("has_custom_resolution")) {
|
||||||
|
continue; // Лаунчер сам обрабатывает разрешение
|
||||||
|
}
|
||||||
|
if (key.startsWith("is_demo_user")) {
|
||||||
|
// Лаунчер не использует demo режим, считаем фичу false
|
||||||
matches = false;
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Неизвестная фича — считаем false
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+2
-2
@@ -48,9 +48,9 @@ public class ArrowMenu {
|
|||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
else if (key == 27) { // Esc or arrow escape seq
|
else if (key == 27) { // Esc or arrow escape seq
|
||||||
int next = terminal.reader().read(50);
|
int next = terminal.reader().read();
|
||||||
if (next == 91) { // '[' — start of arrow escape sequence
|
if (next == 91) { // '[' — start of arrow escape sequence
|
||||||
int arrow = terminal.reader().read(50);
|
int arrow = terminal.reader().read();
|
||||||
if (arrow == 65) { // 'A' — Up arrow
|
if (arrow == 65) { // 'A' — Up arrow
|
||||||
selected = (selected - 1 + options.size()) % options.size();
|
selected = (selected - 1 + options.size()) % options.size();
|
||||||
} else if (arrow == 66) { // 'B' — Down arrow
|
} else if (arrow == 66) { // 'B' — Down arrow
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.ui.jfx;
|
||||||
|
|
||||||
|
import javafx.application.Application;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
import javafx.scene.web.WebView;
|
||||||
|
import javafx.scene.web.WebEngine;
|
||||||
|
import javafx.stage.Stage;
|
||||||
|
import javafx.concurrent.Worker;
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.jar.JarEntry;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
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.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.Headers;
|
||||||
|
|
||||||
|
public class JFXLauncher extends Application {
|
||||||
|
private static final int PORT = 8080;
|
||||||
|
private static final String APP_TITLE = "ZernMC Launcher";
|
||||||
|
private static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "http://87.120.187.36:1582");
|
||||||
|
private final LauncherAPI api = new LauncherAPI();
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
private HttpServer server;
|
||||||
|
private StringBuilder logBuffer = new StringBuilder();
|
||||||
|
private static StringBuilder gameLogBuffer = new StringBuilder();
|
||||||
|
private static Path gameLogFile;
|
||||||
|
private Stage mainStage;
|
||||||
|
|
||||||
|
public static void appendGameLog(String log) {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
gameLogBuffer.append(log).append("\n");
|
||||||
|
|
||||||
|
if (gameLogFile != null) {
|
||||||
|
try {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
|
||||||
|
Files.writeString(gameLogFile, "[" + timestamp + "] " + log + "\n",
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void initGameLog(Path instanceDir) {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
gameLogBuffer.setLength(0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path logsDir = instanceDir.resolve("logs");
|
||||||
|
Files.createDirectories(logsDir);
|
||||||
|
gameLogFile = logsDir.resolve("game.log");
|
||||||
|
Files.writeString(gameLogFile, "=== Game Log " + LocalDateTime.now() + " ===\n",
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getGameLogs() {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
return gameLogBuffer.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
launch(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void extractAssets() {
|
||||||
|
try {
|
||||||
|
Path assetsDir = Paths.get("assets");
|
||||||
|
if (Files.exists(assetsDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String serverVersion = getServerVersion();
|
||||||
|
if (serverVersion != null && !serverVersion.isEmpty()) {
|
||||||
|
System.out.println("[JFX] Загрузка assets через мета для версии " + serverVersion);
|
||||||
|
if (downloadAssetsFromMeta(serverVersion)) {
|
||||||
|
System.out.println("[JFX] Assets загружены через мета");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
System.out.println("[JFX] Мета недоступна, использую fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("[JFX] Извлечение assets из JAR...");
|
||||||
|
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||||
|
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
|
||||||
|
try (JarFile jar = new JarFile(jarPath.toFile())) {
|
||||||
|
var entries = jar.entries();
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
JarEntry entry = entries.nextElement();
|
||||||
|
if (entry.getName().startsWith("assets/")) {
|
||||||
|
Path outPath = assetsDir.resolve(entry.getName().substring(7));
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
Files.createDirectories(outPath);
|
||||||
|
} else {
|
||||||
|
Files.createDirectories(outPath.getParent());
|
||||||
|
try (InputStream is = jar.getInputStream(entry)) {
|
||||||
|
Files.copy(is, outPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("[JFX] Assets извлечены из JAR");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[JFX] Ошибка извлечения assets: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getServerVersion() {
|
||||||
|
try {
|
||||||
|
URL url = new URL(LAUNCHER_SERVER + "/launcher/version");
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setConnectTimeout(3000);
|
||||||
|
conn.setReadTimeout(3000);
|
||||||
|
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 ignored) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean downloadAssetsFromMeta(String version) {
|
||||||
|
try {
|
||||||
|
URL metaUrl = new URL(LAUNCHER_SERVER + "/launcher/meta/" + version);
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) metaUrl.openConnection();
|
||||||
|
conn.setConnectTimeout(5000);
|
||||||
|
conn.setReadTimeout(10000);
|
||||||
|
if (conn.getResponseCode() != 200) return false;
|
||||||
|
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) sb.append(line);
|
||||||
|
org.json.JSONObject meta = new org.json.JSONObject(sb.toString());
|
||||||
|
|
||||||
|
Path assetsDir = Paths.get("assets");
|
||||||
|
Files.createDirectories(assetsDir);
|
||||||
|
|
||||||
|
for (Object fileObj : meta.getJSONArray("files")) {
|
||||||
|
org.json.JSONObject file = (org.json.JSONObject) fileObj;
|
||||||
|
String path = file.getString("path");
|
||||||
|
if (path.startsWith("assets/")) {
|
||||||
|
String downloadUrl = LAUNCHER_SERVER + "/launcher/file/" + version + "/" + path;
|
||||||
|
Path outPath = assetsDir.resolve(path.substring(7));
|
||||||
|
Files.createDirectories(outPath.getParent());
|
||||||
|
|
||||||
|
URL fileUrl = new URL(downloadUrl);
|
||||||
|
HttpURLConnection fileConn = (HttpURLConnection) fileUrl.openConnection();
|
||||||
|
fileConn.setConnectTimeout(10000);
|
||||||
|
fileConn.setReadTimeout(30000);
|
||||||
|
if (fileConn.getResponseCode() == 200) {
|
||||||
|
try (InputStream is = fileConn.getInputStream()) {
|
||||||
|
Files.copy(is, outPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileConn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[JFX] Ошибка загрузки через мета: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Stage stage) {
|
||||||
|
this.mainStage = stage;
|
||||||
|
|
||||||
|
try {
|
||||||
|
extractAssets();
|
||||||
|
log("Запуск JFX UI...");
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
WebView webView = new WebView();
|
||||||
|
WebEngine engine = webView.getEngine();
|
||||||
|
engine.setJavaScriptEnabled(true);
|
||||||
|
|
||||||
|
engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
|
||||||
|
log("[UI] Load state: " + oldState + " -> " + newState);
|
||||||
|
if (newState == Worker.State.SUCCEEDED) {
|
||||||
|
log("Страница загружена");
|
||||||
|
} else if (newState == Worker.State.FAILED) {
|
||||||
|
log("[UI] Load FAILED: " + engine.getLoadWorker().getException());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.setOnAlert(e -> log("[UI] Alert: " + e.getData()));
|
||||||
|
|
||||||
|
String url = "http://localhost:" + PORT + "/assets/ui/index.html";
|
||||||
|
engine.load(url);
|
||||||
|
|
||||||
|
stage.setTitle(APP_TITLE);
|
||||||
|
stage.setWidth(1200);
|
||||||
|
stage.setHeight(800);
|
||||||
|
stage.setScene(new Scene(webView));
|
||||||
|
stage.show();
|
||||||
|
|
||||||
|
log("Окно отображено");
|
||||||
|
|
||||||
|
stage.setOnCloseRequest(e -> {
|
||||||
|
log("Закрытие...");
|
||||||
|
stopServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("Ошибка: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startServer() throws Exception {
|
||||||
|
server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0);
|
||||||
|
|
||||||
|
server.createContext("/api/login", this::handleLogin);
|
||||||
|
server.createContext("/api/account", this::handleAccount);
|
||||||
|
server.createContext("/api/instances", this::handleInstances);
|
||||||
|
server.createContext("/api/launch", this::handleLaunch);
|
||||||
|
server.createContext("/api/install", this::handleInstall);
|
||||||
|
server.createContext("/api/logs", this::handleLogs);
|
||||||
|
server.createContext("/api/game-logs", this::handleGameLogs);
|
||||||
|
server.createContext("/api/exit", this::handleExit);
|
||||||
|
server.createContext("/assets/", this::handleStatic);
|
||||||
|
|
||||||
|
server.setExecutor(Executors.newCachedThreadPool());
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
log("HTTP сервер на порту " + PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopServer() {
|
||||||
|
if (server != null) server.stop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLogin(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!"POST".equals(exchange.getRequestMethod())) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Метод не поддерживается"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||||
|
String username = body.get("username");
|
||||||
|
String password = body.get("password");
|
||||||
|
|
||||||
|
var result = api.login(username, password);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("username", result.getData().getUsername());
|
||||||
|
data.put("token", result.getData().getToken());
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", data));
|
||||||
|
log("Вход: " + username);
|
||||||
|
} else {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleAccount(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!api.isLoggedIn()) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("username", api.getCurrentUsername());
|
||||||
|
data.put("passActive", AuthManager.hasActivePass());
|
||||||
|
data.put("role", AuthManager.getRole());
|
||||||
|
data.put("roleName", AuthManager.getRoleName());
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", data));
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleInstances(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
var result = api.getAllInstances();
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", result.isSuccess());
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
response.put("data", result.getData());
|
||||||
|
} else {
|
||||||
|
response.put("error", result.getError());
|
||||||
|
}
|
||||||
|
sendJson(exchange, response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLaunch(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!api.isLoggedIn()) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||||
|
String name = body.get("name");
|
||||||
|
|
||||||
|
var result = api.launch(name);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("pid", result.getData().getPid());
|
||||||
|
data.put("status", result.getData().getStatus());
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", data));
|
||||||
|
log("Запущено: " + name);
|
||||||
|
} else {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleInstall(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!api.isLoggedIn()) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||||
|
String name = body.get("name");
|
||||||
|
String version = body.get("version");
|
||||||
|
String loader = body.get("loader");
|
||||||
|
|
||||||
|
log("Установка: " + name + " " + version + " " + loader);
|
||||||
|
|
||||||
|
var createResult = api.instances().createInstance(name);
|
||||||
|
if (!createResult.isSuccess()) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", createResult.getError()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Instance instance = InstanceManager.getInstance(name);
|
||||||
|
if (instance != null) {
|
||||||
|
instance.setMinecraftVersion(version);
|
||||||
|
instance.setLoaderType(loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", true));
|
||||||
|
log("Установлено: " + name);
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLogs(HttpExchange exchange) {
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", logBuffer.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleGameLogs(HttpExchange exchange) {
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleExit(HttpExchange exchange) {
|
||||||
|
log("Выход...");
|
||||||
|
if (mainStage != null) mainStage.close();
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleStatic(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
String path = exchange.getRequestURI().getPath();
|
||||||
|
log("[UI] Request: " + path);
|
||||||
|
|
||||||
|
String relativePath = path.startsWith("/") ? path.substring(1) : path;
|
||||||
|
Path file = Paths.get(relativePath).toAbsolutePath();
|
||||||
|
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
log("[UI] File not found: " + file);
|
||||||
|
exchange.sendResponseHeaders(404, 0);
|
||||||
|
exchange.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] content = Files.readAllBytes(file);
|
||||||
|
log("[UI] Loaded " + content.length + " bytes: " + path);
|
||||||
|
String ct = getContentType(path);
|
||||||
|
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", ct);
|
||||||
|
exchange.sendResponseHeaders(200, content.length);
|
||||||
|
exchange.getResponseBody().write(content);
|
||||||
|
exchange.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("[UI] Error serving: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getContentType(String path) {
|
||||||
|
if (path.endsWith(".html")) return "text/html; charset=utf-8";
|
||||||
|
if (path.endsWith(".css")) return "text/css; charset=utf-8";
|
||||||
|
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
|
||||||
|
return "text/plain";
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, String> parseJson(InputStream body) {
|
||||||
|
try {
|
||||||
|
return gson.fromJson(new String(body.readAllBytes(), StandardCharsets.UTF_8), Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendJson(HttpExchange exchange, Map<String, Object> response) {
|
||||||
|
try {
|
||||||
|
String json = gson.toJson(response);
|
||||||
|
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||||
|
exchange.sendResponseHeaders(200, bytes.length);
|
||||||
|
exchange.getResponseBody().write(bytes);
|
||||||
|
exchange.close();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void log(String msg) {
|
||||||
|
String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n";
|
||||||
|
logBuffer.append(entry);
|
||||||
|
System.out.println("[JFX] " + msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-4
@@ -34,8 +34,9 @@ public class Version {
|
|||||||
public static boolean isNewer(String current, String server) {
|
public static boolean isNewer(String current, String server) {
|
||||||
if (current == null || server == null) return false;
|
if (current == null || server == null) return false;
|
||||||
|
|
||||||
current = current.replace("-SNAPSHOT", "").trim();
|
// Нормализуем версии - убираем суффиксы типа -any, -alpha, -beta, -SNAPSHOT
|
||||||
server = server.replace("-SNAPSHOT", "").trim();
|
current = normalizeVersion(current);
|
||||||
|
server = normalizeVersion(server);
|
||||||
|
|
||||||
if (current.equals(server)) return false;
|
if (current.equals(server)) return false;
|
||||||
|
|
||||||
@@ -45,12 +46,29 @@ public class Version {
|
|||||||
int max = Math.max(cParts.length, sParts.length);
|
int max = Math.max(cParts.length, sParts.length);
|
||||||
|
|
||||||
for (int i = 0; i < max; i++) {
|
for (int i = 0; i < max; i++) {
|
||||||
int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0;
|
int c = i < cParts.length ? parseVersionPart(cParts[i]) : 0;
|
||||||
int s = i < sParts.length ? Integer.parseInt(sParts[i]) : 0;
|
int s = i < sParts.length ? parseVersionPart(sParts[i]) : 0;
|
||||||
|
|
||||||
if (s > c) return true;
|
if (s > c) return true;
|
||||||
if (s < c) return false;
|
if (s < c) return false;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalizeVersion(String version) {
|
||||||
|
if (version == null) return "0.0.0";
|
||||||
|
|
||||||
|
// Убираем суффиксы: -any, -alpha1, -beta2, -SNAPSHOT, -rc1 и т.д.
|
||||||
|
return version.split("-")[0].split("\\+")[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parseVersionPart(String part) {
|
||||||
|
try {
|
||||||
|
// Убираем всё, что не является цифрой (на случай если суффикс остался)
|
||||||
|
String numeric = part.replaceAll("[^0-9]", "");
|
||||||
|
return numeric.isEmpty() ? 0 : Integer.parseInt(numeric);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ZernMC Launcher</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Экран логина -->
|
||||||
|
<div id="login-screen" class="screen">
|
||||||
|
<div class="login-container">
|
||||||
|
<h1 class="logo">ZernMC</h1>
|
||||||
|
<p class="subtitle">Private Launcher</p>
|
||||||
|
<form id="login-form">
|
||||||
|
<input type="text" id="username" placeholder="Никнейм" required>
|
||||||
|
<input type="password" id="password" placeholder="Пароль" required>
|
||||||
|
<button type="submit" class="btn-primary">Войти</button>
|
||||||
|
</form>
|
||||||
|
<div id="login-error" class="error hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Главное меню -->
|
||||||
|
<div id="main-screen" class="screen hidden">
|
||||||
|
<!-- Хедер -->
|
||||||
|
<header class="header">
|
||||||
|
<h1 class="logo">ZernMC Launcher</h1>
|
||||||
|
<div class="account-info">
|
||||||
|
<span id="account-name">-</span>
|
||||||
|
<span id="account-status" class="badge">-</span>
|
||||||
|
<span id="account-role" class="badge role-badge">-</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Слева: выбор сборки -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<h2>Сборки</h2>
|
||||||
|
<div id="instances-list" class="instances-container">
|
||||||
|
<!-- Динамически заполняется через JS -->
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- По центру: логи -->
|
||||||
|
<section class="logs-panel">
|
||||||
|
<h2>Логи</h2>
|
||||||
|
<div id="logs-container"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Низ: управление -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="instance-info">
|
||||||
|
<span id="selected-name">-</span>
|
||||||
|
<span id="selected-version">-</span>
|
||||||
|
<span id="selected-loader">-</span>
|
||||||
|
</div>
|
||||||
|
<button id="play-btn" class="btn-play" disabled>Выберите сборку</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно установки -->
|
||||||
|
<div id="install-modal" class="modal hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Установка сборки</h2>
|
||||||
|
<form id="install-form">
|
||||||
|
<label>Версия Minecraft
|
||||||
|
<select id="install-mc-version">
|
||||||
|
<option value="1.20.4">1.20.4</option>
|
||||||
|
<option value="1.20.2">1.20.2</option>
|
||||||
|
<option value="1.20.1">1.20.1</option>
|
||||||
|
<option value="1.19.2">1.19.2</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Загрузчик
|
||||||
|
<select id="install-loader">
|
||||||
|
<option value="vanilla">Vanilla</option>
|
||||||
|
<option value="fabric">Fabric</option>
|
||||||
|
<option value="forge">Forge</option>
|
||||||
|
<option value="neoforge">NeoForge</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Имя сборки
|
||||||
|
<input type="text" id="install-name" placeholder="MyServer" required>
|
||||||
|
</label>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button type="button" class="btn-secondary" onclick="closeInstallModal()">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary">Установить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="launcher.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
const API_BASE = 'http://localhost:8080/api';
|
||||||
|
|
||||||
|
let state = {
|
||||||
|
loggedIn: false,
|
||||||
|
account: null,
|
||||||
|
instances: [],
|
||||||
|
selectedInstance: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ API ============
|
||||||
|
|
||||||
|
async function apiCall(endpoint, options = {}) {
|
||||||
|
const url = `${API_BASE}${endpoint}`;
|
||||||
|
const config = {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
log('Ошибка соединения с сервером: ' + e.message, 'error');
|
||||||
|
return { success: false, error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Login ============
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
log('Выполняется вход...', 'info');
|
||||||
|
const result = await apiCall('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
state.loggedIn = true;
|
||||||
|
state.account = result.data;
|
||||||
|
log('Вход выполнен: ' + result.data.username, 'success');
|
||||||
|
showMainScreen();
|
||||||
|
await loadInstances();
|
||||||
|
} else {
|
||||||
|
log('Ошибка входа: ' + result.error, 'error');
|
||||||
|
showError(result.error);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const el = document.getElementById('login-error');
|
||||||
|
el.textContent = message;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
document.getElementById('login-error').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Account ============
|
||||||
|
|
||||||
|
async function loadAccountInfo() {
|
||||||
|
const result = await apiCall('/account');
|
||||||
|
if (result.success) {
|
||||||
|
state.account = result.data;
|
||||||
|
state.loggedIn = true;
|
||||||
|
document.getElementById('account-name').textContent = result.data.username;
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('account-status');
|
||||||
|
statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE';
|
||||||
|
statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive');
|
||||||
|
|
||||||
|
const roleEl = document.getElementById('account-role');
|
||||||
|
if (roleEl && result.data.roleName) {
|
||||||
|
roleEl.textContent = result.data.roleName;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showLoginScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Instances ============
|
||||||
|
|
||||||
|
async function loadInstances() {
|
||||||
|
log('Загрузка списка сборок...', 'info');
|
||||||
|
const result = await apiCall('/instances');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
state.instances = result.data;
|
||||||
|
renderInstances();
|
||||||
|
log('Загружено ' + result.data.length + ' сборок', 'success');
|
||||||
|
} else {
|
||||||
|
log('Ошибка загрузки: ' + result.error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInstances() {
|
||||||
|
const container = document.getElementById('instances-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
state.instances.forEach(inst => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'instance-card';
|
||||||
|
card.dataset.name = inst.name;
|
||||||
|
card.onclick = () => selectInstance(inst.name);
|
||||||
|
|
||||||
|
let details = `
|
||||||
|
<span class="instance-version">${inst.version || '?'}</span>
|
||||||
|
<span class="instance-loader">${inst.loaderType || 'vanilla'}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (inst.isServerPack) {
|
||||||
|
details += `<span class="instance-server-version">v${inst.serverVersion}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="instance-name">${inst.name}</div>
|
||||||
|
<div class="instance-details">${details}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectInstance(name) {
|
||||||
|
state.selectedInstance = state.instances.find(i => i.name === name);
|
||||||
|
|
||||||
|
document.querySelectorAll('.instance-card').forEach(c => {
|
||||||
|
c.classList.toggle('selected', c.dataset.name === name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = document.getElementById('play-btn');
|
||||||
|
const inst = state.selectedInstance;
|
||||||
|
|
||||||
|
if (inst) {
|
||||||
|
document.getElementById('selected-name').textContent = inst.name;
|
||||||
|
document.getElementById('selected-version').textContent = inst.version || '-';
|
||||||
|
document.getElementById('selected-loader').textContent = inst.loaderType || 'vanilla';
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Играть';
|
||||||
|
btn.classList.remove('update');
|
||||||
|
} else {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Выберите сборку';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Launch ============
|
||||||
|
|
||||||
|
async function launchInstance() {
|
||||||
|
if (!state.selectedInstance) return;
|
||||||
|
|
||||||
|
const name = state.selectedInstance.name;
|
||||||
|
log('Запуск сборки: ' + name, 'info');
|
||||||
|
|
||||||
|
const result = await apiCall('/launch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
log('Сборка запущена! PID: ' + result.data.pid, 'success');
|
||||||
|
} else {
|
||||||
|
log('Ошибка запуска: ' + result.error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Install ============
|
||||||
|
|
||||||
|
function openInstallModal() {
|
||||||
|
document.getElementById('install-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInstallModal() {
|
||||||
|
document.getElementById('install-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installInstance(formData) {
|
||||||
|
log('Установка сборки...', 'info');
|
||||||
|
const result = await apiCall('/install', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
log('Сборка установлена!', 'success');
|
||||||
|
closeInstallModal();
|
||||||
|
await loadInstances();
|
||||||
|
} else {
|
||||||
|
log('Ошибка установки: ' + result.error, 'error');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Logs ============
|
||||||
|
|
||||||
|
function log(message, type = 'info') {
|
||||||
|
const container = document.getElementById('logs-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'log-line ' + type;
|
||||||
|
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
|
||||||
|
container.appendChild(line);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLogs() {
|
||||||
|
document.getElementById('logs-container').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Screens ============
|
||||||
|
|
||||||
|
function showLoginScreen() {
|
||||||
|
document.getElementById('login-screen').classList.remove('hidden');
|
||||||
|
document.getElementById('main-screen').classList.add('hidden');
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMainScreen() {
|
||||||
|
document.getElementById('login-screen').classList.add('hidden');
|
||||||
|
document.getElementById('main-screen').classList.remove('hidden');
|
||||||
|
|
||||||
|
if (state.account) {
|
||||||
|
document.getElementById('account-name').textContent = state.account.username;
|
||||||
|
const statusEl = document.getElementById('account-status');
|
||||||
|
statusEl.textContent = state.account.passActive ? 'PRO' : 'FREE';
|
||||||
|
statusEl.className = 'badge ' + (state.account.passActive ? 'active' : 'inactive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Init ============
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
log('Запуск лаунчера...', 'info');
|
||||||
|
|
||||||
|
await loadAccountInfo();
|
||||||
|
|
||||||
|
if (!state.loggedIn) {
|
||||||
|
showLoginScreen();
|
||||||
|
} else {
|
||||||
|
showMainScreen();
|
||||||
|
await loadInstances();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling for server logs
|
||||||
|
startLogPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastLogLength = 0;
|
||||||
|
let lastGameLogLength = 0;
|
||||||
|
function startLogPolling() {
|
||||||
|
setInterval(async () => {
|
||||||
|
// Launcher logs
|
||||||
|
const result = await apiCall('/logs');
|
||||||
|
if (result.success && result.data && result.data.length > lastLogLength) {
|
||||||
|
const newLogs = result.data.substring(lastLogLength);
|
||||||
|
const lines = newLogs.split('\n').filter(l => l.trim());
|
||||||
|
lines.forEach(line => {
|
||||||
|
if (line.includes('[JFX]')) {
|
||||||
|
log(line.replace('[JFX] ', ''), 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lastLogLength = result.data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game logs
|
||||||
|
const gameResult = await apiCall('/game-logs');
|
||||||
|
if (gameResult.success && gameResult.data && gameResult.data.length > lastGameLogLength) {
|
||||||
|
const newLogs = gameResult.data.substring(lastGameLogLength);
|
||||||
|
const lines = newLogs.split('\n').filter(l => l.trim());
|
||||||
|
lines.forEach(line => {
|
||||||
|
log('[GAME] ' + line, 'info');
|
||||||
|
});
|
||||||
|
lastGameLogLength = gameResult.data.length;
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Form Handlers ============
|
||||||
|
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
await login(username, password);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('play-btn').addEventListener('click', async () => {
|
||||||
|
await launchInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('install-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
name: document.getElementById('install-name').value,
|
||||||
|
version: document.getElementById('install-mc-version').value,
|
||||||
|
loader: document.getElementById('install-loader').value
|
||||||
|
};
|
||||||
|
|
||||||
|
await installInstance(formData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose functions globally for inline handlers
|
||||||
|
window.closeInstallModal = closeInstallModal;
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a2e;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-tertiary: #0f3460;
|
||||||
|
--accent: #e94560;
|
||||||
|
--accent-hover: #ff6b6b;
|
||||||
|
--text-primary: #eaeaea;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--success: #4ade80;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--error: #ef4444;
|
||||||
|
--border: #2d2d4a;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screens */
|
||||||
|
.screen {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Screen */
|
||||||
|
#login-screen {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 25px 50px var(--shadow);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Screen */
|
||||||
|
#main-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.active {
|
||||||
|
background: rgba(74, 222, 128, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.inactive {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar h2 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instances-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-card.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(233, 69, 96, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-version, .instance-loader {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-server-version {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logs Panel */
|
||||||
|
.logs-panel {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-panel h2 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logs-container {
|
||||||
|
flex: 1;
|
||||||
|
background: #0d0d1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line.info { color: var(--text-primary); }
|
||||||
|
.log-line.success { color: var(--success); }
|
||||||
|
.log-line.warning { color: var(--warning); }
|
||||||
|
.log-line.error { color: var(--error); }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-info span {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selected-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play {
|
||||||
|
background: var(--success);
|
||||||
|
color: #0a0a0a;
|
||||||
|
border: none;
|
||||||
|
padding: 0.875rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play:hover:not(:disabled) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 20px rgba(74, 222, 128, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play:disabled {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play.update {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#install-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#install-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#install-form select, #install-form input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
Binary file not shown.
+28
-195
@@ -6,8 +6,16 @@
|
|||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
<groupId>me.sashegdev</groupId>
|
<groupId>me.sashegdev</groupId>
|
||||||
<artifactId>ZernMCLauncher</artifactId>
|
<artifactId>ZernMCLauncher</artifactId>
|
||||||
<version>1.0.8</version>
|
<version>1.0.9</version>
|
||||||
<packaging>jar</packaging>
|
<packaging>pom</packaging>
|
||||||
|
|
||||||
|
<name>ZernMC Launcher Parent</name>
|
||||||
|
<description>ZernMC Launcher - Multi-module project</description>
|
||||||
|
|
||||||
|
<modules>
|
||||||
|
<module>bootstrap</module>
|
||||||
|
<module>launcher</module>
|
||||||
|
</modules>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
@@ -15,13 +23,10 @@
|
|||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.organization.name>ZernMC</project.organization.name>
|
<project.organization.name>ZernMC</project.organization.name>
|
||||||
<project.inceptionYear>2026</project.inceptionYear>
|
<project.inceptionYear>2026</project.inceptionYear>
|
||||||
<project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
|
<project.description>ZernMC Launcher - Multi-module project</project.description>
|
||||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
|
||||||
<javafx.classifier>win</javafx.classifier>
|
|
||||||
<os.suffix>win</os.suffix>
|
|
||||||
<skip.launch4j>false</skip.launch4j>
|
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.httpcomponents</groupId>
|
<groupId>org.apache.httpcomponents</groupId>
|
||||||
@@ -63,49 +68,39 @@
|
|||||||
<artifactId>commons-io</artifactId>
|
<artifactId>commons-io</artifactId>
|
||||||
<version>2.15.1</version>
|
<version>2.15.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<!-- JavaFX for Windows -->
|
||||||
<groupId>io.javalin</groupId>
|
|
||||||
<artifactId>javalin</artifactId>
|
|
||||||
<version>6.1.3</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.slf4j</groupId>
|
|
||||||
<artifactId>slf4j-simple</artifactId>
|
|
||||||
<version>2.0.11</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.openjfx</groupId>
|
<groupId>org.openjfx</groupId>
|
||||||
<artifactId>javafx-controls</artifactId>
|
<artifactId>javafx-controls</artifactId>
|
||||||
<version>21.0.2</version>
|
<version>21</version>
|
||||||
<classifier>win</classifier>
|
<classifier>win</classifier>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.openjfx</groupId>
|
<groupId>org.openjfx</groupId>
|
||||||
<artifactId>javafx-web</artifactId>
|
<artifactId>javafx-web</artifactId>
|
||||||
<version>21.0.2</version>
|
<version>21</version>
|
||||||
<classifier>win</classifier>
|
<classifier>win</classifier>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.openjfx</groupId>
|
<groupId>org.openjfx</groupId>
|
||||||
<artifactId>javafx-controls</artifactId>
|
<artifactId>javafx-graphics</artifactId>
|
||||||
<version>21.0.2</version>
|
<version>21</version>
|
||||||
<classifier>linux</classifier>
|
<classifier>win</classifier>
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.openjfx</groupId>
|
<groupId>org.openjfx</groupId>
|
||||||
<artifactId>javafx-web</artifactId>
|
<artifactId>javafx-base</artifactId>
|
||||||
<version>21.0.2</version>
|
<version>21</version>
|
||||||
<classifier>linux</classifier>
|
<classifier>win</classifier>
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.openjfx</groupId>
|
||||||
<artifactId>junit-jupiter</artifactId>
|
<artifactId>javafx-media</artifactId>
|
||||||
<version>5.10.1</version>
|
<version>21</version>
|
||||||
<scope>test</scope>
|
<classifier>win</classifier>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
@@ -127,7 +122,7 @@
|
|||||||
<goal>shade</goal>
|
<goal>shade</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<outputFile>../server/builds/ZernMCLauncher.jar</outputFile>
|
<outputFile>../../server/builds/ZernMCLauncher.jar</outputFile>
|
||||||
<transformers>
|
<transformers>
|
||||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
<mainClass>${mainClass}</mainClass>
|
<mainClass>${mainClass}</mainClass>
|
||||||
@@ -140,141 +135,9 @@
|
|||||||
</manifestEntries>
|
</manifestEntries>
|
||||||
</transformer>
|
</transformer>
|
||||||
</transformers>
|
</transformers>
|
||||||
<filters>
|
|
||||||
<filter>
|
|
||||||
<artifact>*:*</artifact>
|
|
||||||
<excludes>
|
|
||||||
<exclude>META-INF/*.SF</exclude>
|
|
||||||
<exclude>META-INF/*.DSA</exclude>
|
|
||||||
<exclude>META-INF/*.RSA</exclude>
|
|
||||||
</excludes>
|
|
||||||
</filter>
|
|
||||||
<!-- Исключаем JavaFX из shade полностью (он будет в lib-javafx) -->
|
|
||||||
<filter>
|
|
||||||
<artifact>org.openjfx:*</artifact>
|
|
||||||
<excludes>
|
|
||||||
<exclude>**/*</exclude>
|
|
||||||
</excludes>
|
|
||||||
</filter>
|
|
||||||
</filters>
|
|
||||||
<dependencySet>
|
|
||||||
<outputDirectory>/</outputDirectory>
|
|
||||||
<useProjectArtifact>false</useProjectArtifact>
|
|
||||||
<unpack>true</unpack>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
<excludes>
|
|
||||||
<exclude>org.openjfx:*</exclude>
|
|
||||||
</excludes>
|
|
||||||
</dependencySet>
|
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
|
||||||
|
|
||||||
<!-- Copy JavaFX dependencies -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
|
||||||
<artifactId>maven-dependency-plugin</artifactId>
|
|
||||||
<version>3.6.0</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>copy-javafx</id>
|
|
||||||
<phase>package</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>copy-dependencies</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<outputDirectory>${project.build.directory}/lib-javafx</outputDirectory>
|
|
||||||
<includeScope>runtime</includeScope>
|
|
||||||
<includeGroupIds>org.openjfx</includeGroupIds>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
|
|
||||||
<!-- Launch4j для создания .exe (только для Windows) -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>com.akathist.maven.plugins.launch4j</groupId>
|
|
||||||
<artifactId>launch4j-maven-plugin</artifactId>
|
|
||||||
<version>2.5.0</version>
|
|
||||||
<configuration>
|
|
||||||
<skip>${skip.launch4j}</skip>
|
|
||||||
</configuration>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>l4j</id>
|
|
||||||
<phase>package</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>launch4j</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
|
|
||||||
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
|
||||||
<headerType>gui</headerType>
|
|
||||||
<dontWrapJar>false</dontWrapJar>
|
|
||||||
<jre>
|
|
||||||
<path>jre21</path>
|
|
||||||
<minVersion>21</minVersion>
|
|
||||||
<opts>
|
|
||||||
<opt>--module-path=lib-javafx</opt>
|
|
||||||
<opt>--add-modules=javafx.controls,javafx.web</opt>
|
|
||||||
<opt>--add-reads=javafx.graphics=ALL-UNNAMED</opt>
|
|
||||||
</opts>
|
|
||||||
</jre>
|
|
||||||
<versionInfo>
|
|
||||||
<fileVersion>${project.version}.0</fileVersion>
|
|
||||||
<txtFileVersion>${project.version}</txtFileVersion>
|
|
||||||
<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-${project.version}.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>
|
|
||||||
|
|
||||||
<!-- Копируем JavaFX JAR в builds -->
|
|
||||||
<copy todir="../server/builds/lib-javafx" overwrite="true">
|
|
||||||
<fileset dir="${project.build.directory}/lib-javafx"/>
|
|
||||||
</copy>
|
|
||||||
|
|
||||||
<!-- Копируем shell script для Linux -->
|
|
||||||
<copy file="${project.basedir}/src/main/resources/launcher.sh"
|
|
||||||
todir="../server/builds"
|
|
||||||
overwrite="true"/>
|
|
||||||
<chmod file="../server/builds/launcher.sh" perm="+x"/>
|
|
||||||
|
|
||||||
<!-- Создаём zip с .exe, jre21, lib-javafx и launcher.sh (без .jar и build.version) -->
|
|
||||||
<zip destfile="../server/builds/ZernMCLauncher-${project.version}-${os.suffix}.zip"
|
|
||||||
basedir="../server/builds"
|
|
||||||
includes="ZernMCLauncher.exe,ZernMCLauncher.jar,jre21/**,lib-javafx/**,launcher.sh"
|
|
||||||
excludes="build.version"/>
|
|
||||||
</target>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
@@ -302,35 +165,5 @@
|
|||||||
<server.url>http://87.120.187.36:1582</server.url>
|
<server.url>http://87.120.187.36:1582</server.url>
|
||||||
</properties>
|
</properties>
|
||||||
</profile>
|
</profile>
|
||||||
|
|
||||||
<!-- ==================== WINDOWS BUILD ==================== -->
|
|
||||||
<profile>
|
|
||||||
<id>win</id>
|
|
||||||
<activation>
|
|
||||||
<os>
|
|
||||||
<family>windows</family>
|
|
||||||
</os>
|
|
||||||
</activation>
|
|
||||||
<properties>
|
|
||||||
<javafx.classifier>win</javafx.classifier>
|
|
||||||
<os.suffix>win</os.suffix>
|
|
||||||
<skip.launch4j>false</skip.launch4j>
|
|
||||||
</properties>
|
|
||||||
</profile>
|
|
||||||
|
|
||||||
<!-- ==================== LINUX BUILD ==================== -->
|
|
||||||
<profile>
|
|
||||||
<id>linux</id>
|
|
||||||
<activation>
|
|
||||||
<os>
|
|
||||||
<family>unix</family>
|
|
||||||
</os>
|
|
||||||
</activation>
|
|
||||||
<properties>
|
|
||||||
<javafx.classifier>linux</javafx.classifier>
|
|
||||||
<os.suffix>linux</os.suffix>
|
|
||||||
<skip.launch4j>true</skip.launch4j>
|
|
||||||
</properties>
|
|
||||||
</profile>
|
|
||||||
</profiles>
|
</profiles>
|
||||||
</project>
|
</project>
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
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.utils.*;
|
|
||||||
import me.sashegdev.zernmc.launcher.web.UIWindow;
|
|
||||||
import me.sashegdev.zernmc.launcher.web.WebServer;
|
|
||||||
|
|
||||||
import java.awt.GraphicsEnvironment;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.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.Arrays;
|
|
||||||
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 Exception {
|
|
||||||
boolean cliMode = Arrays.asList(args).contains("--cli") || Arrays.asList(args).contains("-c");
|
|
||||||
|
|
||||||
if (cliMode) {
|
|
||||||
runTUI(args);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
startWebUI(args);
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println(ZAnsi.red("UI не запустился: " + e.getMessage()));
|
|
||||||
e.printStackTrace();
|
|
||||||
System.out.println(ZAnsi.yellow("Переключаюсь на режим TUI..."));
|
|
||||||
runTUI(args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void startWebUI(String[] args) throws Exception {
|
|
||||||
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
|
||||||
System.setProperty("file.encoding", "UTF-8");
|
|
||||||
|
|
||||||
int startPort = 8080;
|
|
||||||
for (int i = 0; i < args.length - 1; i++) {
|
|
||||||
if (args[i].equals("--port") || args[i].equals("-p")) {
|
|
||||||
startPort = Integer.parseInt(args[i + 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("Запуск Web UI..."));
|
|
||||||
System.out.println(ZAnsi.cyan("Поиск свободного порта..."));
|
|
||||||
|
|
||||||
int port = WebServer.findFreePort(startPort);
|
|
||||||
|
|
||||||
// Запускаем WebServer в отдельном потоке
|
|
||||||
Thread serverThread = new Thread(() -> {
|
|
||||||
try {
|
|
||||||
WebServer.start(port);
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("WebServer error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
serverThread.setDaemon(true);
|
|
||||||
serverThread.start();
|
|
||||||
|
|
||||||
// Даем серверу время запуститься
|
|
||||||
Thread.sleep(1000);
|
|
||||||
|
|
||||||
// Проверяем headless перед запуском JavaFX (только для не-Windows систем)
|
|
||||||
if (!System.getProperty("os.name").toLowerCase().contains("win")) {
|
|
||||||
boolean isHeadless = java.awt.GraphicsEnvironment.isHeadless();
|
|
||||||
String display = System.getenv("DISPLAY");
|
|
||||||
if (isHeadless && (display == null || display.isEmpty())) {
|
|
||||||
System.out.println(ZAnsi.yellow("Дисплей недоступен, переключаюсь на TUI..."));
|
|
||||||
WebServer.stop();
|
|
||||||
runTUI(args);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка обновлений лаунчера
|
|
||||||
checkAndAutoUpdateLauncher();
|
|
||||||
|
|
||||||
// Запускаем JavaFX окно
|
|
||||||
UIWindow.start(port);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void runTUI(String[] args) throws IOException {
|
|
||||||
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
|
||||||
System.setProperty("file.encoding", "UTF-8");
|
|
||||||
System.setProperty("sun.err.encoding", "UTF-8");
|
|
||||||
System.setProperty("sun.stdout.encoding", "UTF-8");
|
|
||||||
|
|
||||||
ZAnsi.install();
|
|
||||||
System.out.print("\033[H\033[2J");
|
|
||||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION) + ZAnsi.cyan(" [CLI mode]"));
|
|
||||||
|
|
||||||
// Проверка всех сервисов при старте
|
|
||||||
ZHttpClient.checkAllServicesOnStartup();
|
|
||||||
|
|
||||||
checkAndAutoUpdateLauncher();
|
|
||||||
|
|
||||||
// === АВТОРИЗАЦИЯ (используем новый API) ===
|
|
||||||
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
|
|
||||||
var sessionResponse = api.checkSession();
|
|
||||||
|
|
||||||
if (!sessionResponse.isSuccess()) {
|
|
||||||
LoginMenu loginMenu = new LoginMenu();
|
|
||||||
boolean loggedIn = loginMenu.show();
|
|
||||||
if (!loggedIn) {
|
|
||||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
|
||||||
ZAnsi.uninstall();
|
|
||||||
System.exit(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var sessionInfo = sessionResponse.getData();
|
|
||||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.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)"));
|
|
||||||
|
|
||||||
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();
|
|
||||||
String launcherDir = jarPath.substring(0, jarPath.lastIndexOf(java.io.File.separator));
|
|
||||||
String javafxPath = launcherDir + java.io.File.separator + "lib-javafx";
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
|
|
||||||
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(
|
|
||||||
javaPath,
|
|
||||||
"--module-path=" + javafxPath,
|
|
||||||
"--add-modules=javafx.controls,javafx.web",
|
|
||||||
"--add-reads=javafx.graphics=ALL-UNNAMED",
|
|
||||||
"-jar", jarPath
|
|
||||||
);
|
|
||||||
pb.inheritIO();
|
|
||||||
pb.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 {
|
|
||||||
if (Config.isZernMCBuild()) {
|
|
||||||
zernMCFlow();
|
|
||||||
} else {
|
|
||||||
globalFlow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== ZERNMC FLOW ======================
|
|
||||||
private static void zernMCFlow() throws Exception {
|
|
||||||
ConsoleUtils.clearScreen();
|
|
||||||
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
|
|
||||||
|
|
||||||
// 1. Проверка подключения к серверу
|
|
||||||
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
|
||||||
try {
|
|
||||||
String response = ZHttpClient.get("/health");
|
|
||||||
System.out.println(ZAnsi.brightGreen("✓ Сервер доступен"));
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу"));
|
|
||||||
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage()));
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Авторизация
|
|
||||||
boolean sessionRestored = AuthManager.loadSavedSession();
|
|
||||||
if (!sessionRestored) {
|
|
||||||
LoginMenu loginMenu = new LoginMenu();
|
|
||||||
boolean loggedIn = loginMenu.show();
|
|
||||||
if (!loggedIn) {
|
|
||||||
System.exit(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow)
|
|
||||||
LaunchMenu launchMenu = new LaunchMenu();
|
|
||||||
launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== GLOBAL FLOW ======================
|
|
||||||
private static void globalFlow() throws Exception {
|
|
||||||
while (true) {
|
|
||||||
ConsoleUtils.clearScreen();
|
|
||||||
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
|
|
||||||
|
|
||||||
List<String> options = List.of(
|
|
||||||
"Запустить игру",
|
|
||||||
"Проверка обновлений",
|
|
||||||
"Настройки",
|
|
||||||
"Проверка подключения к серверам",
|
|
||||||
"Выход"
|
|
||||||
);
|
|
||||||
|
|
||||||
ArrowMenu menu = new ArrowMenu("Главное меню", options);
|
|
||||||
int choice = menu.show();
|
|
||||||
|
|
||||||
if (choice == -1 || choice == 4) {
|
|
||||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (choice) {
|
|
||||||
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
|
|
||||||
case 1 -> new UpdateMenu().show();
|
|
||||||
case 2 -> new SettingsMenu().show();
|
|
||||||
case 3 -> new ServerCheckMenu().show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.api.install;
|
|
||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
|
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class InstallService {
|
|
||||||
|
|
||||||
private PackDownloader.ProgressCallback progressCallback;
|
|
||||||
|
|
||||||
public void setProgressCallback(PackDownloader.ProgressCallback callback) {
|
|
||||||
this.progressCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResponse<InstallResult> installZernMCPack(String packName, String instanceName) {
|
|
||||||
try {
|
|
||||||
boolean created = InstanceManager.createInstanceFolder(instanceName);
|
|
||||||
if (!created) {
|
|
||||||
return ApiResponse.error("Сборка с таким именем уже существует: " + instanceName);
|
|
||||||
}
|
|
||||||
|
|
||||||
Instance instance = InstanceManager.getInstance(instanceName);
|
|
||||||
if (instance == null) {
|
|
||||||
return ApiResponse.error("Не удалось создать директорию сборки");
|
|
||||||
}
|
|
||||||
|
|
||||||
PackDownloader downloader = new PackDownloader(instance);
|
|
||||||
if (progressCallback != null) {
|
|
||||||
downloader.setProgressCallback(progressCallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем список доступных сборок
|
|
||||||
List<ServerPack> availablePacks = downloader.getAvailablePacks();
|
|
||||||
|
|
||||||
// Находим нужную сборку
|
|
||||||
ServerPack selectedPack = availablePacks.stream()
|
|
||||||
.filter(p -> p.getName().equals(packName))
|
|
||||||
.findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
if (selectedPack == null) {
|
|
||||||
return ApiResponse.error("Сборка не найдена: " + packName);
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean success = downloader.installOrUpdatePack(packName, selectedPack);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
return ApiResponse.success(new InstallResult(
|
|
||||||
instanceName,
|
|
||||||
selectedPack.getMinecraftVersion(),
|
|
||||||
selectedPack.getLoaderType(),
|
|
||||||
selectedPack.getVersion()
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
return ApiResponse.error("Не удалось установить сборку");
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ApiResponse.error("Ошибка установки: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResponse<UpdateCheckResult> checkForUpdates(String instanceName) {
|
|
||||||
try {
|
|
||||||
Instance instance = InstanceManager.getInstance(instanceName);
|
|
||||||
if (instance == null || !instance.isServerPack()) {
|
|
||||||
return ApiResponse.success(new UpdateCheckResult(false, false, 0, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
PackDownloader downloader = new PackDownloader(instance);
|
|
||||||
int serverVersion = downloader.checkForUpdates(instance.getServerPackName());
|
|
||||||
boolean hasUpdate = serverVersion > 0;
|
|
||||||
|
|
||||||
return ApiResponse.success(new UpdateCheckResult(
|
|
||||||
hasUpdate,
|
|
||||||
true,
|
|
||||||
instance.getServerVersion(),
|
|
||||||
serverVersion
|
|
||||||
));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ApiResponse.error("Ошибка проверки обновлений: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResponse<HashCheckResult> verifyHashes(String instanceName) {
|
|
||||||
try {
|
|
||||||
Instance instance = InstanceManager.getInstance(instanceName);
|
|
||||||
if (instance == null) {
|
|
||||||
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!instance.isServerPack() || instance.getServerPackName() == null) {
|
|
||||||
return ApiResponse.success(new HashCheckResult(false, List.of()));
|
|
||||||
}
|
|
||||||
|
|
||||||
PackDownloader downloader = new PackDownloader(instance);
|
|
||||||
Map<String, String> localFiles = downloader.scanLocalFiles();
|
|
||||||
|
|
||||||
// Отправляем хеши на сервер через diff
|
|
||||||
var diff = downloader.getDiff(instance.getServerPackName(), localFiles);
|
|
||||||
|
|
||||||
List<String> mismatched = new ArrayList<>();
|
|
||||||
for (var f : diff.getToDownload()) {
|
|
||||||
mismatched.add(f.getPath());
|
|
||||||
}
|
|
||||||
mismatched.addAll(diff.getToUpdate());
|
|
||||||
mismatched.addAll(diff.getToDelete());
|
|
||||||
|
|
||||||
boolean hasMismatches = !mismatched.isEmpty();
|
|
||||||
|
|
||||||
return ApiResponse.success(new HashCheckResult(hasMismatches, mismatched));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ApiResponse.error("Ошибка проверки хешей: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResponse<PlayTimeInfo> getPlayTime(String instanceName) {
|
|
||||||
try {
|
|
||||||
Instance instance = InstanceManager.getInstance(instanceName);
|
|
||||||
if (instance == null) {
|
|
||||||
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instance.isServerPack()) {
|
|
||||||
// TODO: Для ZernMC получаем время с сервера
|
|
||||||
// String response = ZHttpClient.get("/users/me/playtime?pack=" + instance.getServerPackName());
|
|
||||||
// Пока возвращаем 0 - в будущем интегрировать с сервером
|
|
||||||
return ApiResponse.success(new PlayTimeInfo(0, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Для локальных сборок возвращаем 0
|
|
||||||
return ApiResponse.success(new PlayTimeInfo(0, false));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ApiResponse.error("Ошибка получения времени: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int extractPlayTime(String json) {
|
|
||||||
try {
|
|
||||||
// Простой парсинг JSON
|
|
||||||
String minutes = json.replaceAll(".*\"minutes\"\\s*:\\s*(\\d+).*", "$1");
|
|
||||||
return Integer.parseInt(minutes);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class InstallResult {
|
|
||||||
private String name;
|
|
||||||
private String mcVersion;
|
|
||||||
private String loaderType;
|
|
||||||
private int serverVersion;
|
|
||||||
|
|
||||||
public InstallResult(String name, String mcVersion, String loaderType, int serverVersion) {
|
|
||||||
this.name = name;
|
|
||||||
this.mcVersion = mcVersion;
|
|
||||||
this.loaderType = loaderType;
|
|
||||||
this.serverVersion = serverVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getName() { return name; }
|
|
||||||
public String getMcVersion() { return mcVersion; }
|
|
||||||
public String getLoaderType() { return loaderType; }
|
|
||||||
public int getServerVersion() { return serverVersion; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class UpdateCheckResult {
|
|
||||||
private boolean hasUpdate;
|
|
||||||
private boolean isServerPack;
|
|
||||||
private int currentVersion;
|
|
||||||
private int latestVersion;
|
|
||||||
|
|
||||||
public UpdateCheckResult(boolean hasUpdate, boolean isServerPack, int currentVersion, int latestVersion) {
|
|
||||||
this.hasUpdate = hasUpdate;
|
|
||||||
this.isServerPack = isServerPack;
|
|
||||||
this.currentVersion = currentVersion;
|
|
||||||
this.latestVersion = latestVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isHasUpdate() { return hasUpdate; }
|
|
||||||
public boolean isServerPack() { return isServerPack; }
|
|
||||||
public int getCurrentVersion() { return currentVersion; }
|
|
||||||
public int getLatestVersion() { return latestVersion; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class HashCheckResult {
|
|
||||||
private boolean hasMismatches;
|
|
||||||
private List<String> mismatchedFiles;
|
|
||||||
|
|
||||||
public HashCheckResult(boolean hasMismatches, List<String> mismatchedFiles) {
|
|
||||||
this.hasMismatches = hasMismatches;
|
|
||||||
this.mismatchedFiles = mismatchedFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasMismatches() { return hasMismatches; }
|
|
||||||
public List<String> getMismatchedFiles() { return mismatchedFiles; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class PlayTimeInfo {
|
|
||||||
private int totalMinutes;
|
|
||||||
private boolean fromServer;
|
|
||||||
|
|
||||||
public PlayTimeInfo(int totalMinutes, boolean fromServer) {
|
|
||||||
this.totalMinutes = totalMinutes;
|
|
||||||
this.fromServer = fromServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getTotalMinutes() { return totalMinutes; }
|
|
||||||
public boolean isFromServer() { return fromServer; }
|
|
||||||
|
|
||||||
public String getFormattedTime() {
|
|
||||||
int hours = totalMinutes / 60;
|
|
||||||
int minutes = totalMinutes % 60;
|
|
||||||
if (hours > 0) {
|
|
||||||
return hours + "ч " + minutes + "м";
|
|
||||||
}
|
|
||||||
return minutes + "м";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-257
@@ -1,257 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
|
||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
|
||||||
|
|
||||||
import java.io.*;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.StandardOpenOption;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class NeoForgeInstaller {
|
|
||||||
|
|
||||||
private final Instance instance;
|
|
||||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(java.time.Duration.ofSeconds(30))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
public NeoForgeInstaller(Instance instance) {
|
|
||||||
this.instance = instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
|
|
||||||
System.out.println(ZAnsi.cyan("Установка NeoForge " + neoForgeVersion + " для Minecraft " + mcVersion));
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
|
|
||||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
|
||||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
|
||||||
|
|
||||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
instance.setAssetIndex(assetIndex);
|
|
||||||
|
|
||||||
String mavenGroup = getMavenGroup(mcVersion);
|
|
||||||
String mavenArtifact = getMavenArtifact(mcVersion);
|
|
||||||
|
|
||||||
// Формируем путь к версии
|
|
||||||
String versionName = mcVersion + "-" + neoForgeVersion;
|
|
||||||
Path versionDir = instance.getPath().resolve("versions").resolve(versionName);
|
|
||||||
Files.createDirectories(versionDir);
|
|
||||||
|
|
||||||
// Скачиваем universal.jar (это основной JAR NeoForge)
|
|
||||||
String baseMavenUrl = "https://maven.neoforged.net/releases/"
|
|
||||||
+ mavenGroup.replace('.', '/') + "/"
|
|
||||||
+ mavenArtifact + "/"
|
|
||||||
+ neoForgeVersion + "/";
|
|
||||||
|
|
||||||
String universalJarUrl = baseMavenUrl + mavenArtifact + "-" + neoForgeVersion + "-universal.jar";
|
|
||||||
Path neoForgeJar = versionDir.resolve(versionName + ".jar");
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("Скачивание NeoForge universal.jar..."));
|
|
||||||
downloadFileDirect(universalJarUrl, neoForgeJar);
|
|
||||||
|
|
||||||
// Создаем version.json вручную
|
|
||||||
System.out.println(ZAnsi.cyan("Создание version.json..."));
|
|
||||||
createVersionJson(versionDir.resolve(versionName + ".json"), mcVersion, neoForgeVersion, mavenArtifact);
|
|
||||||
|
|
||||||
// Скачиваем необходимые библиотеки
|
|
||||||
System.out.println(ZAnsi.cyan("Скачивание библиотек NeoForge..."));
|
|
||||||
downloadNeoForgeLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " успешно установлен!"));
|
|
||||||
instance.setMinecraftVersion(mcVersion);
|
|
||||||
instance.setLoaderType("neoforge");
|
|
||||||
instance.setLoaderVersion(neoForgeVersion);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getMavenGroup(String mcVersion) {
|
|
||||||
if (mcVersion.equals("1.20.1")) {
|
|
||||||
return "net.neoforged";
|
|
||||||
}
|
|
||||||
return "net.neoforged";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getMavenArtifact(String mcVersion) {
|
|
||||||
if (mcVersion.equals("1.20.1")) {
|
|
||||||
return "forge";
|
|
||||||
}
|
|
||||||
return "neoforge";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void createLauncherProfile() throws IOException {
|
|
||||||
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
|
|
||||||
if (Files.exists(profilePath)) return;
|
|
||||||
|
|
||||||
String minimalProfile = """
|
|
||||||
{
|
|
||||||
"profiles": {},
|
|
||||||
"selectedProfile": "Default"
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
|
||||||
System.out.println(ZAnsi.yellow("Создан launcher_profiles.json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(url))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
|
||||||
throw new IOException("HTTP " + response.statusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
|
|
||||||
|
|
||||||
try (InputStream in = response.body();
|
|
||||||
FileOutputStream out = new FileOutputStream(target.toFile())) {
|
|
||||||
|
|
||||||
byte[] buffer = new byte[8192];
|
|
||||||
int bytesRead;
|
|
||||||
long totalRead = 0;
|
|
||||||
int lastPercent = -1;
|
|
||||||
|
|
||||||
while ((bytesRead = in.read(buffer)) != -1) {
|
|
||||||
out.write(buffer, 0, bytesRead);
|
|
||||||
totalRead += bytesRead;
|
|
||||||
|
|
||||||
if (contentLength > 0) {
|
|
||||||
int percent = (int) ((totalRead * 100) / contentLength);
|
|
||||||
if (percent != lastPercent) {
|
|
||||||
String downloaded = ProgressBar.formatBytes(totalRead);
|
|
||||||
String total = ProgressBar.formatBytes(contentLength);
|
|
||||||
ProgressBar.show("NeoForge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
|
|
||||||
lastPercent = percent;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
char[] spinner = {'|', '/', '-', '\\'};
|
|
||||||
int idx = (int) (totalRead / 1024) % 4;
|
|
||||||
System.out.print("\rСкачивание NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void downloadFileDirect(String url, Path target) throws Exception {
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(url))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
|
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
|
||||||
throw new IOException("HTTP " + response.statusCode() + " for " + url);
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.green(" " + target.getFileName() + " завершено ✓"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void createVersionJson(Path jsonFile, String mcVersion, String neoForgeVersion, String mavenArtifact) throws IOException {
|
|
||||||
// Создаем минимальный version.json для NeoForge
|
|
||||||
String versionName = mcVersion + "-" + neoForgeVersion;
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"id": "%s",
|
|
||||||
"type": "release",
|
|
||||||
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
|
|
||||||
"inheritsFrom": "%s",
|
|
||||||
"arguments": {
|
|
||||||
"--tweakClass": "cpw.mods.fml.relauncher.CoreModManager"
|
|
||||||
},
|
|
||||||
"libraries": [
|
|
||||||
{"name": "net.neoforged:neoforge:%s"},
|
|
||||||
{"name": "cpw.mods:bootstraplauncher:1.1.2"},
|
|
||||||
{"name": "net.minecraftforge:unsafe:0.2.0"},
|
|
||||||
{"name": "net.minecraftforge:srgutils:0.4.4"},
|
|
||||||
{"name": "net.minecraftforge:modlauncher:10.2.1"},
|
|
||||||
{"name": "net.minecraftforge:coremods:5.0.1"},
|
|
||||||
{"name": "net.minecraftforge:accesstransformers:8.8"},
|
|
||||||
{"name": "net.minecraftforge:eventbus:6.0.5"},
|
|
||||||
{"name": "net.minecraftforge:forgemin:0.1.1"},
|
|
||||||
{"name": "net.minecraftforge:scanner:1.2.2"},
|
|
||||||
{"name": "com.google.code.gson:gson:2.10.1"},
|
|
||||||
{"name": "com.google.guava:guava:32.1.3-jre"},
|
|
||||||
{"name": "org.apache.commons:commons-lang3:3.13.0"},
|
|
||||||
{"name": "org.jline:jline-reader:3.12.1"},
|
|
||||||
{"name": "org.jline:jline-terminal:3.12.1"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""".formatted(versionName, mcVersion, neoForgeVersion);
|
|
||||||
|
|
||||||
Files.writeString(jsonFile, json);
|
|
||||||
System.out.println(ZAnsi.green(" version.json создан ✓"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void downloadNeoForgeLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
|
|
||||||
System.out.println(ZAnsi.cyan("Скачивание библиотек NeoForge..."));
|
|
||||||
|
|
||||||
String baseMavenUrl = "https://maven.neoforged.net/releases/"
|
|
||||||
+ mavenGroup.replace('.', '/') + "/";
|
|
||||||
|
|
||||||
Path librariesDir = instance.getPath().resolve("libraries");
|
|
||||||
|
|
||||||
// Список основных библиотек NeoForge
|
|
||||||
String[][] libs = {
|
|
||||||
{mavenGroup, mavenArtifact, neoForgeVersion},
|
|
||||||
{"cpw.mods", "bootstraplauncher", "1.1.2"},
|
|
||||||
{"net.minecraftforge", "unsafe", "0.2.0"},
|
|
||||||
{"net.minecraftforge", "srgutils", "0.4.4"},
|
|
||||||
{"net.minecraftforge", "modlauncher", "10.2.1"},
|
|
||||||
{"net.minecraftforge", "coremods", "5.0.1"},
|
|
||||||
{"net.minecraftforge", "accesstransformers", "8.8"},
|
|
||||||
{"net.minecraftforge", "eventbus", "6.0.5"},
|
|
||||||
{"net.minecraftforge", "forgemin", "0.1.1"},
|
|
||||||
{"net.minecraftforge", "scanner", "1.2.2"}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (String[] lib : libs) {
|
|
||||||
String group = lib[0].replace('.', '/');
|
|
||||||
String artifact = lib[1];
|
|
||||||
String version = lib[2];
|
|
||||||
|
|
||||||
String jarName = artifact + "-" + version + ".jar";
|
|
||||||
String mavenPath = group + "/" + artifact + "/" + version + "/" + jarName;
|
|
||||||
Path target = librariesDir.resolve(mavenPath);
|
|
||||||
|
|
||||||
if (Files.exists(target)) {
|
|
||||||
System.out.println(ZAnsi.green(" " + jarName + " уже есть ✓"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Files.createDirectories(target.getParent());
|
|
||||||
|
|
||||||
String url = baseMavenUrl + mavenPath;
|
|
||||||
try {
|
|
||||||
downloadFileDirect(url, target);
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Пробуем Maven Central как fallback
|
|
||||||
try {
|
|
||||||
String centralUrl = "https://repo1.maven.org/maven2/" + mavenPath;
|
|
||||||
downloadFileDirect(centralUrl, target);
|
|
||||||
} catch (Exception e2) {
|
|
||||||
System.out.println(ZAnsi.yellow(" Предупреждение: не удалось скачать " + jarName));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.green("Библиотеки NeoForge обработаны ✓"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.web;
|
|
||||||
|
|
||||||
import java.awt.GraphicsEnvironment;
|
|
||||||
import javafx.application.Application;
|
|
||||||
import javafx.concurrent.Worker;
|
|
||||||
import javafx.geometry.Rectangle2D;
|
|
||||||
import javafx.scene.Scene;
|
|
||||||
import javafx.scene.web.WebEngine;
|
|
||||||
import javafx.scene.web.WebView;
|
|
||||||
import javafx.stage.Screen;
|
|
||||||
import javafx.stage.Stage;
|
|
||||||
import javafx.stage.StageStyle;
|
|
||||||
|
|
||||||
public class UIWindow extends Application {
|
|
||||||
|
|
||||||
private static String url;
|
|
||||||
private static int port;
|
|
||||||
|
|
||||||
public static void start(int port) {
|
|
||||||
UIWindow.port = port;
|
|
||||||
UIWindow.url = "http://localhost:" + port;
|
|
||||||
Application.launch(UIWindow.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void start(Stage stage) {
|
|
||||||
stage.setTitle("ZernMC Launcher");
|
|
||||||
stage.initStyle(StageStyle.UNDECORATED);
|
|
||||||
|
|
||||||
WebView webView = new WebView();
|
|
||||||
WebEngine webEngine = webView.getEngine();
|
|
||||||
|
|
||||||
webEngine.load(url);
|
|
||||||
|
|
||||||
webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
|
|
||||||
if (newState == Worker.State.FAILED) {
|
|
||||||
System.err.println("Failed to load: " + url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Scene scene = new Scene(webView);
|
|
||||||
stage.setScene(scene);
|
|
||||||
|
|
||||||
Rectangle2D screenBounds = Screen.getPrimary().getVisualBounds();
|
|
||||||
double screenWidth = screenBounds.getWidth();
|
|
||||||
double screenHeight = screenBounds.getHeight();
|
|
||||||
|
|
||||||
double windowWidth = Math.min(1200, screenWidth * 0.8);
|
|
||||||
double windowHeight = Math.min(800, screenHeight * 0.85);
|
|
||||||
|
|
||||||
stage.setWidth(windowWidth);
|
|
||||||
stage.setHeight(windowHeight);
|
|
||||||
stage.setX((screenWidth - windowWidth) / 2);
|
|
||||||
stage.setY((screenHeight - windowHeight) / 2);
|
|
||||||
|
|
||||||
stage.show();
|
|
||||||
|
|
||||||
stage.setOnCloseRequest(event -> {
|
|
||||||
WebServer.stop();
|
|
||||||
System.exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.web;
|
|
||||||
|
|
||||||
import io.javalin.Javalin;
|
|
||||||
import io.javalin.http.staticfiles.Location;
|
|
||||||
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
|
||||||
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
|
||||||
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
|
||||||
import me.sashegdev.zernmc.launcher.api.install.InstallService;
|
|
||||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
|
||||||
|
|
||||||
import java.awt.Desktop;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.net.ServerSocket;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.StandardCopyOption;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class WebServer {
|
|
||||||
|
|
||||||
private static final LauncherAPI api = new LauncherAPI();
|
|
||||||
private static Javalin app;
|
|
||||||
private static int currentPort;
|
|
||||||
private static volatile boolean running = false;
|
|
||||||
|
|
||||||
public static int findFreePort(int startPort) throws IOException {
|
|
||||||
for (int port = startPort; port < startPort + 100; port++) {
|
|
||||||
if (isPortAvailable(port)) {
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IOException("Не удалось найти свободный порт в диапазоне " + startPort + "-" + (startPort + 99));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isPortAvailable(int port) {
|
|
||||||
try (ServerSocket socket = new ServerSocket(port)) {
|
|
||||||
return true;
|
|
||||||
} catch (IOException e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void start(int port) throws Exception {
|
|
||||||
currentPort = port;
|
|
||||||
running = true;
|
|
||||||
|
|
||||||
// Отключаем логирование Javalin в консоль
|
|
||||||
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "error");
|
|
||||||
|
|
||||||
app = Javalin.create(config -> {
|
|
||||||
config.staticFiles.add("/webapp", Location.CLASSPATH);
|
|
||||||
config.staticFiles.add("/assets", Location.CLASSPATH);
|
|
||||||
}).start(port);
|
|
||||||
|
|
||||||
// API эндпоинты
|
|
||||||
setupApiRoutes();
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("✓ Web UI готов на http://localhost:" + port));
|
|
||||||
|
|
||||||
// Блокируем главный поток (сервер работает)
|
|
||||||
while (running) {
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void setupApiRoutes() {
|
|
||||||
// Auth
|
|
||||||
app.get("/api/auth/status", ctx -> {
|
|
||||||
if (AuthManager.loadSavedSession()) {
|
|
||||||
ctx.json(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"loggedIn", true,
|
|
||||||
"username", AuthManager.getUsername()
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
ctx.json(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"loggedIn", false
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/auth/login", ctx -> {
|
|
||||||
Map<String, String> body = ctx.bodyAsClass(Map.class);
|
|
||||||
String username = body.get("username");
|
|
||||||
String password = body.get("password");
|
|
||||||
|
|
||||||
if (username == null || password == null) {
|
|
||||||
ctx.status(400).json(Map.of("success", false, "error", "Missing username or password"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = api.login(username, password);
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
ctx.json(Map.of("success", true, "username", username));
|
|
||||||
} else {
|
|
||||||
ctx.status(401).json(Map.of("success", false, "error", result.getError()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/auth/logout", ctx -> {
|
|
||||||
AuthManager.logout();
|
|
||||||
ctx.json(Map.of("success", true));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Instances - локальные
|
|
||||||
app.get("/api/instances", ctx -> {
|
|
||||||
var result = api.getAllInstances();
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
ctx.json(Map.of("success", true, "data", result.getData()));
|
|
||||||
} else {
|
|
||||||
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Instance детали
|
|
||||||
app.get("/api/instances/{name}", ctx -> {
|
|
||||||
String name = ctx.pathParam("name");
|
|
||||||
var result = api.instances().getInstance(name);
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
ctx.json(Map.of("success", true, "data", result.getData()));
|
|
||||||
} else {
|
|
||||||
ctx.status(404).json(Map.of("success", false, "error", result.getError()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Launch
|
|
||||||
app.post("/api/instances/{name}/launch", ctx -> {
|
|
||||||
String name = ctx.pathParam("name");
|
|
||||||
var result = api.launch(name);
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
ctx.json(Map.of("success", true, "message", "Launch started"));
|
|
||||||
} else {
|
|
||||||
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
app.post("/api/instances/{name}/delete", ctx -> {
|
|
||||||
String name = ctx.pathParam("name");
|
|
||||||
var result = api.instances().deleteInstance(name);
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
ctx.json(Map.of("success", true, "message", "Instance deleted"));
|
|
||||||
} else {
|
|
||||||
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ZernMC серверные сборки
|
|
||||||
app.get("/api/instances/zernmc", ctx -> {
|
|
||||||
// TODO: получить реальные сборки с сервера
|
|
||||||
List<Map<String, Object>> packs = List.of(
|
|
||||||
Map.of("name", "ZernMC SkyBlock", "version", 1, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 150),
|
|
||||||
Map.of("name", "ZernMC RPG", "version", 3, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 200)
|
|
||||||
);
|
|
||||||
ctx.json(Map.of("success", true, "data", packs));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Установка ZernMC сборки
|
|
||||||
app.post("/api/instances/zernmc/install", ctx -> {
|
|
||||||
Map<String, String> body = ctx.bodyAsClass(Map.class);
|
|
||||||
String packName = body.get("packName");
|
|
||||||
String instanceName = body.get("instanceName");
|
|
||||||
|
|
||||||
if (packName == null || instanceName == null) {
|
|
||||||
ctx.status(400).json(Map.of("success", false, "error", "Missing packName or instanceName"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = api.install().installZernMCPack(packName, instanceName);
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
ctx.json(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"data", Map.of(
|
|
||||||
"name", result.getData().getName(),
|
|
||||||
"mcVersion", result.getData().getMcVersion(),
|
|
||||||
"loaderType", result.getData().getLoaderType(),
|
|
||||||
"serverVersion", result.getData().getServerVersion()
|
|
||||||
)
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// SSE прогресс установки
|
|
||||||
app.get("/api/instances/{name}/install/stream", ctx -> {
|
|
||||||
ctx.header("Content-Type", "text/event-stream");
|
|
||||||
ctx.header("Cache-Control", "no-cache");
|
|
||||||
ctx.header("Connection", "keep-alive");
|
|
||||||
|
|
||||||
String instanceName = ctx.pathParam("name");
|
|
||||||
var instanceInfo = api.instances().getInstance(instanceName);
|
|
||||||
|
|
||||||
if (!instanceInfo.isSuccess() || instanceInfo.getData() == null) {
|
|
||||||
ctx.result("data: {\"phase\":\"error\",\"message\":\"Instance not found\"}\n\n");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var os = ctx.outputStream();
|
|
||||||
InstallService service = new InstallService();
|
|
||||||
service.setProgressCallback(info -> {
|
|
||||||
try {
|
|
||||||
String json = String.format(
|
|
||||||
"{\"phase\":\"%s\",\"totalFiles\":%d,\"downloadedFiles\":%d,\"currentFile\":\"%s\",\"fileSize\":%d,\"downloadedBytes\":%d,\"filePercent\":%d,\"totalPercent\":%d,\"eta\":\"%s\"}",
|
|
||||||
info.getPhase(), info.getTotalFiles(), info.getDownloadedFiles(),
|
|
||||||
info.getCurrentFile() != null ? info.getCurrentFile().replace("\"", "\\\"") : "",
|
|
||||||
info.getFileSize(), info.getDownloadedBytes(),
|
|
||||||
info.getFilePercent(), info.getTotalPercent(),
|
|
||||||
info.getEta() != null ? info.getEta() : ""
|
|
||||||
);
|
|
||||||
os.write(("data: " + json + "\n\n").getBytes());
|
|
||||||
os.flush();
|
|
||||||
} catch (Exception e) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = service.installZernMCPack(instanceInfo.getData().getServerPackName(), instanceName);
|
|
||||||
try {
|
|
||||||
if (!result.isSuccess()) {
|
|
||||||
os.write(("data: {\"phase\":\"error\",\"message\":\"" + result.getError().replace("\"", "\\\"") + "\"}\n\n").getBytes());
|
|
||||||
} else {
|
|
||||||
os.write("data: {\"phase\":\"complete\"}\n\n".getBytes());
|
|
||||||
}
|
|
||||||
os.flush();
|
|
||||||
} catch (Exception e) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Проверка обновлений
|
|
||||||
app.get("/api/instances/{name}/updates", ctx -> {
|
|
||||||
String name = ctx.pathParam("name");
|
|
||||||
var result = api.install().checkForUpdates(name);
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
ctx.json(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"data", Map.of(
|
|
||||||
"hasUpdate", result.getData().isHasUpdate(),
|
|
||||||
"isServerPack", result.getData().isServerPack(),
|
|
||||||
"currentVersion", result.getData().getCurrentVersion(),
|
|
||||||
"latestVersion", result.getData().getLatestVersion()
|
|
||||||
)
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Проверка хешей
|
|
||||||
app.get("/api/instances/{name}/verify", ctx -> {
|
|
||||||
String name = ctx.pathParam("name");
|
|
||||||
var result = api.install().verifyHashes(name);
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
ctx.json(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"data", Map.of(
|
|
||||||
"hasMismatches", result.getData().hasMismatches(),
|
|
||||||
"mismatchedFiles", result.getData().getMismatchedFiles()
|
|
||||||
)
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Получение времени игры
|
|
||||||
app.get("/api/instances/{name}/playtime", ctx -> {
|
|
||||||
String name = ctx.pathParam("name");
|
|
||||||
var result = api.install().getPlayTime(name);
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
ctx.json(Map.of(
|
|
||||||
"success", true,
|
|
||||||
"data", Map.of(
|
|
||||||
"totalMinutes", result.getData().getTotalMinutes(),
|
|
||||||
"fromServer", result.getData().isFromServer(),
|
|
||||||
"formatted", result.getData().getFormattedTime()
|
|
||||||
)
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Minecraft версии
|
|
||||||
app.get("/api/versions", ctx -> {
|
|
||||||
List<String> versions = List.of(
|
|
||||||
"1.21.4", "1.21.3", "1.21.2", "1.21.1", "1.21",
|
|
||||||
"1.20.4", "1.20.3", "1.20.2", "1.20.1", "1.20",
|
|
||||||
"1.19.4", "1.19.3", "1.19.2", "1.19.1", "1.19",
|
|
||||||
"1.18.2", "1.18.1", "1.18",
|
|
||||||
"1.17.1", "1.17"
|
|
||||||
);
|
|
||||||
ctx.json(Map.of("success", true, "data",
|
|
||||||
versions.stream().map(v -> Map.of("id", v)).toList()
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Версии лоадеров для конкретной версии Minecraft
|
|
||||||
app.get("/api/versions/{version}/loaders/{loader}", ctx -> {
|
|
||||||
String version = ctx.pathParam("version");
|
|
||||||
String loader = ctx.pathParam("loader");
|
|
||||||
|
|
||||||
List<Map<String, String>> loaderVersions = switch (loader.toLowerCase()) {
|
|
||||||
case "fabric" -> List.of(
|
|
||||||
Map.of("version", "0.16.9"),
|
|
||||||
Map.of("version", "0.16.8"),
|
|
||||||
Map.of("version", "0.16.7"),
|
|
||||||
Map.of("version", "0.16.6"),
|
|
||||||
Map.of("version", "0.16.5"),
|
|
||||||
Map.of("version", "0.15.11"),
|
|
||||||
Map.of("version", "0.15.10"),
|
|
||||||
Map.of("version", "0.15.9")
|
|
||||||
);
|
|
||||||
case "forge" -> List.of(
|
|
||||||
Map.of("version", "1.21-51.0.0"),
|
|
||||||
Map.of("version", "1.20.4-49.0.0"),
|
|
||||||
Map.of("version", "1.20.1-47.1.0"),
|
|
||||||
Map.of("version", "1.19.2-43.2.0"),
|
|
||||||
Map.of("version", "1.18.2-40.2.0")
|
|
||||||
);
|
|
||||||
case "neoforge" -> List.of(
|
|
||||||
Map.of("version", "21.0.0-beta"),
|
|
||||||
Map.of("version", "1.21-21.0.0"),
|
|
||||||
Map.of("version", "1.20.4-21.0.0"),
|
|
||||||
Map.of("version", "1.20.1-21.0.0")
|
|
||||||
);
|
|
||||||
default -> List.of();
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.json(Map.of("success", true, "data", loaderVersions));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Установка ванильной сборки
|
|
||||||
app.post("/api/instances/vanilla/install", ctx -> {
|
|
||||||
Map<String, String> body = ctx.bodyAsClass(Map.class);
|
|
||||||
String mcVersion = body.get("mcVersion");
|
|
||||||
String loader = body.get("loader");
|
|
||||||
String loaderVersion = body.get("loaderVersion");
|
|
||||||
String instanceName = body.get("instanceName");
|
|
||||||
|
|
||||||
if (mcVersion == null || instanceName == null) {
|
|
||||||
ctx.status(400).json(Map.of("success", false, "error", "Missing required parameters"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: реализовать установку ванильной сборки
|
|
||||||
String desc = loader != null ? mcVersion + " + " + loader + " " + loaderVersion : mcVersion + " Vanilla";
|
|
||||||
ctx.json(Map.of("success", true, "message", "Vanilla installation started: " + desc));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.get("/api/health", ctx -> {
|
|
||||||
ctx.json(Map.of("success", true, "status", "ok"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void openBrowser(String url) {
|
|
||||||
try {
|
|
||||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
|
||||||
Desktop.getDesktop().browse(new URI(url));
|
|
||||||
System.out.println(ZAnsi.cyan("Браузер открыт: " + url));
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически. Откройте вручную: " + url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void stop() {
|
|
||||||
running = false;
|
|
||||||
if (app != null) {
|
|
||||||
app.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== LAUNCHER AUTO-UPDATE ====================
|
|
||||||
public static void checkLauncherUpdate() {
|
|
||||||
try {
|
|
||||||
String json = ZHttpClient.getLauncherVersionInfo();
|
|
||||||
String serverVersion = extractVersion(json);
|
|
||||||
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
|
|
||||||
|
|
||||||
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
|
|
||||||
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
|
|
||||||
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
|
|
||||||
performLauncherUpdate(serverVersion);
|
|
||||||
restartLauncher();
|
|
||||||
} else {
|
|
||||||
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
|
|
||||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void performLauncherUpdate(String newVersion) throws Exception {
|
|
||||||
String downloadUrl = ZHttpClient.getBaseUrl() + "/launcher/download?type=jar";
|
|
||||||
Path currentJar = getCurrentJarPath();
|
|
||||||
Path tempJar = currentJar.getParent().resolve("zernmc-launcher-new.jar");
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("Скачивание версии " + newVersion + "..."));
|
|
||||||
|
|
||||||
HttpClient client = HttpClient.newBuilder().build();
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(java.net.URI.create(downloadUrl))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(tempJar));
|
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
|
||||||
throw new IOException("Сервер вернул код: " + response.statusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
long size = Files.size(tempJar);
|
|
||||||
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
|
|
||||||
|
|
||||||
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void restartLauncher() {
|
|
||||||
try {
|
|
||||||
String javaPath = System.getProperty("java.home") + "/bin/java";
|
|
||||||
String jarPath = getCurrentJarPath().toAbsolutePath().toString();
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
|
|
||||||
|
|
||||||
new ProcessBuilder(javaPath, "-jar", jarPath)
|
|
||||||
.inheritIO()
|
|
||||||
.start();
|
|
||||||
|
|
||||||
System.exit(0);
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println(ZAnsi.brightRed("Не удалось перезапустить лаунчер."));
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String extractVersion(String json) {
|
|
||||||
try {
|
|
||||||
return json.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Path getCurrentJarPath() {
|
|
||||||
try {
|
|
||||||
return Path.of(me.sashegdev.zernmc.launcher.Main.class.getProtectionDomain()
|
|
||||||
.getCodeSource()
|
|
||||||
.getLocation()
|
|
||||||
.toURI());
|
|
||||||
} catch (Exception e) {
|
|
||||||
return Path.of("zernmc-launcher.jar");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
JAVA_HOME="$SCRIPT_DIR/jre21"
|
|
||||||
JAVA="$JAVA_HOME/bin/java"
|
|
||||||
|
|
||||||
JAVAFX_PATH="$SCRIPT_DIR/lib-javafx"
|
|
||||||
|
|
||||||
exec "$JAVA" \
|
|
||||||
--module-path="$JAVAFX_PATH" \
|
|
||||||
--add-modules=javafx.controls,javafx.web \
|
|
||||||
--add-reads=javafx.graphics=ALL-UNNAMED \
|
|
||||||
-jar "$SCRIPT_DIR/ZernMCLauncher.jar" "$@"
|
|
||||||
@@ -1,825 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg-primary: #0a0a0f;
|
|
||||||
--bg-secondary: #12121a;
|
|
||||||
--bg-card: #1a1a24;
|
|
||||||
--bg-card-hover: #222230;
|
|
||||||
--bg-sidebar: #0d0d12;
|
|
||||||
--accent-primary: #e94560;
|
|
||||||
--accent-secondary: #ff6b6b;
|
|
||||||
--accent-glow: rgba(233, 69, 96, 0.3);
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #a0a0b0;
|
|
||||||
--text-muted: #606070;
|
|
||||||
--border-color: #2a2a3a;
|
|
||||||
--success: #4ade80;
|
|
||||||
--error: #f87171;
|
|
||||||
--warning: #fbbf24;
|
|
||||||
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
||||||
--shadow-glow: 0 0 30px var(--accent-glow);
|
|
||||||
--radius-sm: 8px;
|
|
||||||
--radius-md: 12px;
|
|
||||||
--radius-lg: 16px;
|
|
||||||
--transition-fast: 150ms ease;
|
|
||||||
--transition-normal: 300ms ease;
|
|
||||||
--transition-slow: 500ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#grid-canvas {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 0;
|
|
||||||
opacity: 0.12;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.screen {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
animation: fadeIn var(--transition-slow) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== LOGIN SCREEN ==================== */
|
|
||||||
.login-container {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 48px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
animation: slideUp var(--transition-slow) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-section {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-placeholder {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.05); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-title {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: linear-gradient(135deg, var(--text-primary), var(--accent-primary));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-version {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 16px;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group input::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px 24px;
|
|
||||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: white;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.7;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-loader {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top-color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: var(--error);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(248, 113, 113, 0.1);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
animation: shake 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-5px); }
|
|
||||||
75% { transform: translateX(5px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== MAIN LAYOUT ==================== */
|
|
||||||
.main-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 280px 1fr 200px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1600px;
|
|
||||||
height: calc(100vh - 40px);
|
|
||||||
gap: 0;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
overflow: hidden;
|
|
||||||
animation: fadeIn var(--transition-slow) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar */
|
|
||||||
.sidebar {
|
|
||||||
background: var(--bg-sidebar);
|
|
||||||
border-right: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-small svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-version {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-instance-section {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-instance {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 16px;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-instance:hover {
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instance-card-mini {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instance-name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instance-version {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--accent-primary);
|
|
||||||
background: rgba(233, 69, 96, 0.15);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: inline-block;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-download {
|
|
||||||
width: 100%;
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px dashed var(--border-color);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-download:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.username-display {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-logout {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-logout:hover {
|
|
||||||
background: rgba(248, 113, 113, 0.1);
|
|
||||||
border-color: var(--error);
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Content - Logs */
|
|
||||||
.main-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 20px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-section {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-header h2 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-clear-logs {
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-clear-logs:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-container {
|
|
||||||
flex: 1;
|
|
||||||
padding: 16px 20px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry {
|
|
||||||
padding: 4px 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
animation: fadeIn var(--transition-fast) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.info {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.success {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.warning {
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.error {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right Panel - Play Button */
|
|
||||||
.right-panel {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 30px;
|
|
||||||
border-left: 1px solid var(--border-color);
|
|
||||||
background: var(--bg-sidebar);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-play {
|
|
||||||
width: 100%;
|
|
||||||
padding: 20px 30px;
|
|
||||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: white;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
box-shadow: 0 4px 20px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-play:hover {
|
|
||||||
transform: translateY(-4px) scale(1.02);
|
|
||||||
box-shadow: 0 8px 40px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-play:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-play:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-update {
|
|
||||||
width: 100%;
|
|
||||||
padding: 20px 30px;
|
|
||||||
background: linear-gradient(135deg, var(--warning), #f59e0b);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: #1a1a24;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
box-shadow: 0 4px 20px rgba(251, 191, 36, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-update:hover {
|
|
||||||
transform: translateY(-4px) scale(1.02);
|
|
||||||
box-shadow: 0 8px 40px rgba(251, 191, 36, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-update:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-update:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== MODAL ==================== */
|
|
||||||
.modal {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(10, 10, 15, 0.9);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
animation: fadeIn var(--transition-fast) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
width: 90%;
|
|
||||||
max-width: 500px;
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
animation: slideUp var(--transition-normal) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h2 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-tabs {
|
|
||||||
display: flex;
|
|
||||||
padding: 16px 24px;
|
|
||||||
gap: 8px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn.active {
|
|
||||||
background: var(--accent-primary);
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn:hover:not(.active) {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
padding: 24px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-input, .text-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-input:focus, .text-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-input option {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-install {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px;
|
|
||||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: white;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-install:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-progress {
|
|
||||||
padding: 24px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 0%;
|
|
||||||
transition: width var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-text {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-label {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-file {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill.animated {
|
|
||||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary), var(--accent-primary));
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: progressShimmer 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes progressShimmer {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== LOADING ==================== */
|
|
||||||
.loading-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(10, 10, 15, 0.9);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
animation: fadeIn var(--transition-fast) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 3px solid var(--border-color);
|
|
||||||
border-top-color: var(--accent-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== ANIMATIONS ==================== */
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cardFadeIn {
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== RESPONSIVE ==================== */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.main-layout {
|
|
||||||
grid-template-columns: 240px 1fr 160px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.main-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: auto 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
padding-bottom: 0;
|
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-content {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-footer {
|
|
||||||
margin-top: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-panel {
|
|
||||||
padding: 16px;
|
|
||||||
border-left: none;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== SCROLLBAR ==================== */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-muted);
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>ZernMC Launcher</title>
|
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<canvas id="grid-canvas"></canvas>
|
|
||||||
|
|
||||||
<div id="app">
|
|
||||||
<!-- Login Screen -->
|
|
||||||
<div id="login-screen" class="screen hidden">
|
|
||||||
<div class="login-container">
|
|
||||||
<div class="logo-section">
|
|
||||||
<div class="logo-placeholder">
|
|
||||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
|
|
||||||
<rect width="80" height="80" rx="20" fill="#e94560"/>
|
|
||||||
<path d="M25 40 L40 25 L55 40 L40 55 Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 class="app-title">ZernMC Launcher</h1>
|
|
||||||
<p class="app-version">v<span id="version">1.0.8</span></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="login-form" class="login-form">
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" id="username" name="username" placeholder="Имя пользователя" required autocomplete="username">
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">
|
|
||||||
<span class="btn-text">Войти</span>
|
|
||||||
<div class="btn-loader hidden"></div>
|
|
||||||
</button>
|
|
||||||
<p id="login-error" class="error-message hidden"></p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Screen -->
|
|
||||||
<div id="main-screen" class="screen hidden">
|
|
||||||
<div class="main-layout">
|
|
||||||
<!-- Left Sidebar -->
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<div class="logo-small">
|
|
||||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
|
||||||
<rect width="40" height="40" rx="10" fill="#e94560"/>
|
|
||||||
<path d="M12 20 L20 12 L28 20 L20 28 Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="header-info">
|
|
||||||
<h1 class="header-title">ZernMC</h1>
|
|
||||||
<span class="header-version">v<span id="header-version">1.0.8</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-content">
|
|
||||||
<!-- Current Instance -->
|
|
||||||
<div class="current-instance-section">
|
|
||||||
<h3 class="section-label">Текущая сборка</h3>
|
|
||||||
<div id="current-instance" class="current-instance">
|
|
||||||
<div class="instance-card-mini">
|
|
||||||
<span class="instance-name">Загрузка...</span>
|
|
||||||
<span class="instance-version">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Download Button -->
|
|
||||||
<button id="download-btn" class="btn-download">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
||||||
<polyline points="7 10 12 15 17 10"/>
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
Скачать сборку
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<span class="username-display" id="username-display"></span>
|
|
||||||
<button class="btn-logout" id="logout-btn" title="Выйти">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
|
||||||
<polyline points="16 17 21 12 16 7"/>
|
|
||||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<main class="main-content">
|
|
||||||
<div class="logs-section">
|
|
||||||
<div class="logs-header">
|
|
||||||
<h2>Логи</h2>
|
|
||||||
<button class="btn-clear-logs" id="clear-logs">Очистить</button>
|
|
||||||
</div>
|
|
||||||
<div id="logs-container" class="logs-container">
|
|
||||||
<div class="log-entry info">Ожидание запуска...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Right Panel - Play Button -->
|
|
||||||
<div class="right-panel">
|
|
||||||
<button id="play-btn" class="btn-play">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
||||||
</svg>
|
|
||||||
ИГРАТЬ
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Download Modal -->
|
|
||||||
<div id="download-modal" class="modal hidden">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Скачать сборку</h2>
|
|
||||||
<button class="modal-close" id="close-download-modal">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-tabs">
|
|
||||||
<button class="tab-btn active" data-tab="zernmc">ZernMC сборки</button>
|
|
||||||
<button class="tab-btn" data-tab="vanilla">Чистый Minecraft</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ZernMC Tab -->
|
|
||||||
<div id="tab-zernmc" class="tab-content active">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Выберите сборку</label>
|
|
||||||
<select id="zernmc-pack-select" class="select-input">
|
|
||||||
<option value="">Загрузка...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Название сборки (системное)</label>
|
|
||||||
<input type="text" id="zernmc-instance-name" class="text-input" placeholder="my-zernmc-pack">
|
|
||||||
</div>
|
|
||||||
<button id="install-zernmc-btn" class="btn-install">
|
|
||||||
Скачать и установить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vanilla Tab -->
|
|
||||||
<div id="tab-vanilla" class="tab-content">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Версия Minecraft</label>
|
|
||||||
<select id="mc-version-select" class="select-input">
|
|
||||||
<option value="">Выберите версию</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Лоадер</label>
|
|
||||||
<select id="loader-select" class="select-input">
|
|
||||||
<option value="vanilla">Vanilla (без лоадера)</option>
|
|
||||||
<option value="fabric">Fabric</option>
|
|
||||||
<option value="forge">Forge</option>
|
|
||||||
<option value="neoforge">NeoForge</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div id="loader-version-group" class="form-group hidden">
|
|
||||||
<label>Версия лоадера</label>
|
|
||||||
<select id="loader-version-select" class="select-input">
|
|
||||||
<option value="">Загрузка...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Название сборки</label>
|
|
||||||
<input type="text" id="vanilla-instance-name" class="text-input" placeholder="my-minecraft">
|
|
||||||
</div>
|
|
||||||
<button id="install-vanilla-btn" class="btn-install">
|
|
||||||
Скачать и установить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="download-progress" class="download-progress hidden">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-fill" id="progress-fill"></div>
|
|
||||||
</div>
|
|
||||||
<p class="progress-text" id="progress-text">Загрузка...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
|
||||||
<div id="loading-overlay" class="loading-overlay hidden">
|
|
||||||
<div class="loader"></div>
|
|
||||||
<p>Загрузка...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,638 +0,0 @@
|
|||||||
const API_BASE = '/api';
|
|
||||||
|
|
||||||
class App {
|
|
||||||
constructor() {
|
|
||||||
this.state = 'INIT';
|
|
||||||
this.username = null;
|
|
||||||
this.currentInstance = null;
|
|
||||||
this.instances = [];
|
|
||||||
this.zernmcPacks = [];
|
|
||||||
this.mcVersions = [];
|
|
||||||
this.hasUpdate = false;
|
|
||||||
this.hasMismatches = false;
|
|
||||||
this.isServerPack = false;
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
this.bindEvents();
|
|
||||||
this.initGridAnimation();
|
|
||||||
await this.checkAuth();
|
|
||||||
}
|
|
||||||
|
|
||||||
bindEvents() {
|
|
||||||
// Login form
|
|
||||||
document.getElementById('login-form').addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
this.handleLogin();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Logout button
|
|
||||||
document.getElementById('logout-btn').addEventListener('click', () => {
|
|
||||||
this.handleLogout();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Download button
|
|
||||||
document.getElementById('download-btn').addEventListener('click', () => {
|
|
||||||
this.showDownloadModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
document.getElementById('close-download-modal').addEventListener('click', () => {
|
|
||||||
this.hideDownloadModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modal tabs
|
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
this.switchTab(e.target.dataset.tab);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Play button
|
|
||||||
document.getElementById('play-btn').addEventListener('click', () => {
|
|
||||||
this.launchInstance();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear logs
|
|
||||||
document.getElementById('clear-logs').addEventListener('click', () => {
|
|
||||||
this.clearLogs();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Loader selection
|
|
||||||
document.getElementById('loader-select').addEventListener('change', (e) => {
|
|
||||||
this.onLoaderChange(e.target.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Install buttons
|
|
||||||
document.getElementById('install-zernmc-btn').addEventListener('click', () => {
|
|
||||||
this.installZernMCPack();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('install-vanilla-btn').addEventListener('click', () => {
|
|
||||||
this.installVanilla();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== GRID ANIMATION ====================
|
|
||||||
initGridAnimation() {
|
|
||||||
const canvas = document.getElementById('grid-canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
let mouseX = 0, mouseY = 0;
|
|
||||||
let offsetX = 0, offsetY = 0;
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
canvas.width = window.innerWidth;
|
|
||||||
canvas.height = window.innerHeight;
|
|
||||||
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', (e) => {
|
|
||||||
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
|
|
||||||
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
|
|
||||||
});
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
offsetX += (mouseX * 0.5 - offsetX) * 0.05;
|
|
||||||
offsetY += (mouseY * 0.5 - offsetY) * 0.05;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
resize();
|
|
||||||
animate();
|
|
||||||
}
|
|
||||||
|
|
||||||
drawGrid(ctx, width, height, offsetX, offsetY) {
|
|
||||||
const gridSize = 50;
|
|
||||||
const dotSize = 1;
|
|
||||||
|
|
||||||
ctx.fillStyle = '#e94560';
|
|
||||||
|
|
||||||
for (let x = 0; x <= width; x += gridSize) {
|
|
||||||
for (let y = 0; y <= height; y += gridSize) {
|
|
||||||
const px = x + offsetX * 10;
|
|
||||||
const py = y + offsetY * 10;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== API ====================
|
|
||||||
async request(endpoint, options = {}) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API Error:', error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== AUTH ====================
|
|
||||||
async checkAuth() {
|
|
||||||
this.showLoading(true);
|
|
||||||
const result = await this.request('/auth/status');
|
|
||||||
|
|
||||||
if (result.loggedIn) {
|
|
||||||
this.username = result.username;
|
|
||||||
this.showMainScreen();
|
|
||||||
await this.loadCurrentInstance();
|
|
||||||
} else {
|
|
||||||
this.showLoginScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleLogin() {
|
|
||||||
const username = document.getElementById('username').value;
|
|
||||||
const password = document.getElementById('password').value;
|
|
||||||
const errorEl = document.getElementById('login-error');
|
|
||||||
const btn = document.querySelector('#login-form button[type="submit"]');
|
|
||||||
const btnText = btn.querySelector('.btn-text');
|
|
||||||
const btnLoader = btn.querySelector('.btn-loader');
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
this.showError('Введите имя пользователя и пароль');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
btnText.classList.add('hidden');
|
|
||||||
btnLoader.classList.remove('hidden');
|
|
||||||
errorEl.classList.add('hidden');
|
|
||||||
|
|
||||||
const result = await this.request('/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username, password })
|
|
||||||
});
|
|
||||||
|
|
||||||
btn.disabled = false;
|
|
||||||
btnText.classList.remove('hidden');
|
|
||||||
btnLoader.classList.add('hidden');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.username = result.username;
|
|
||||||
this.showMainScreen();
|
|
||||||
await this.loadCurrentInstance();
|
|
||||||
} else {
|
|
||||||
this.showError(result.error || 'Ошибка входа');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleLogout() {
|
|
||||||
await this.request('/auth/logout', { method: 'POST' });
|
|
||||||
this.username = null;
|
|
||||||
this.currentInstance = null;
|
|
||||||
this.showLoginScreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
const errorEl = document.getElementById('login-error');
|
|
||||||
errorEl.textContent = message;
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== INSTANCES ====================
|
|
||||||
async loadCurrentInstance() {
|
|
||||||
const result = await this.request('/instances');
|
|
||||||
|
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
|
||||||
this.currentInstance = result.data[0];
|
|
||||||
this.renderCurrentInstance(this.currentInstance);
|
|
||||||
|
|
||||||
this.isServerPack = this.currentInstance.isServerPack || false;
|
|
||||||
|
|
||||||
if (this.isServerPack) {
|
|
||||||
this.addLog('Проверка целостности файлов...', 'info');
|
|
||||||
|
|
||||||
const verifyResult = await this.request(`/instances/${this.currentInstance.name}/verify`);
|
|
||||||
if (verifyResult.success && verifyResult.data) {
|
|
||||||
this.hasMismatches = verifyResult.data.hasMismatches;
|
|
||||||
if (this.hasMismatches) {
|
|
||||||
this.addLog('Обнаружены изменённые файлы!', 'warning');
|
|
||||||
} else {
|
|
||||||
this.addLog('Файлы целы', 'success');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateResult = await this.request(`/instances/${this.currentInstance.name}/updates`);
|
|
||||||
if (updateResult.success && updateResult.data) {
|
|
||||||
this.hasUpdate = updateResult.data.hasUpdate;
|
|
||||||
if (this.hasUpdate) {
|
|
||||||
this.addLog('Доступно обновление: v' + updateResult.data.currentVersion + ' → v' + updateResult.data.latestVersion, 'warning');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatePlayButton();
|
|
||||||
this.addLog('Сборка загружена: ' + this.currentInstance.name, 'success');
|
|
||||||
} else {
|
|
||||||
this.renderNoInstance();
|
|
||||||
this.enablePlayButton(false);
|
|
||||||
this.addLog('Установите сборку для игры', 'warning');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePlayButton() {
|
|
||||||
const btn = document.getElementById('play-btn');
|
|
||||||
if (!this.currentInstance) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.className = 'btn-play';
|
|
||||||
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ИГРАТЬ';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.hasUpdate || this.hasMismatches) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.className = 'btn-update';
|
|
||||||
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>ОБНОВИТЬ';
|
|
||||||
} else {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.className = 'btn-play';
|
|
||||||
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ИГРАТЬ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCurrentInstance(instance) {
|
|
||||||
const container = document.getElementById('current-instance');
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="instance-card-mini">
|
|
||||||
<span class="instance-name">${this.escapeHtml(instance.name)}</span>
|
|
||||||
<span class="instance-version">${this.escapeHtml(instance.version || 'Vanilla')}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderNoInstance() {
|
|
||||||
const container = document.getElementById('current-instance');
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="instance-card-mini">
|
|
||||||
<span class="instance-name" style="color: var(--text-muted)">Нет сборки</span>
|
|
||||||
<span class="instance-version" style="background: var(--bg-secondary)">Нажмите скачать</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
enablePlayButton(enabled) {
|
|
||||||
const btn = document.getElementById('play-btn');
|
|
||||||
btn.disabled = !enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
async launchInstance() {
|
|
||||||
if (!this.currentInstance) return;
|
|
||||||
|
|
||||||
if (this.hasUpdate || this.hasMismatches) {
|
|
||||||
await this.updateInstance();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addLog('Проверка целостности файлов...', 'info');
|
|
||||||
this.enablePlayButton(false);
|
|
||||||
|
|
||||||
const result = await this.request(`/instances/${this.currentInstance.name}/launch`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.addLog('Сборка запущена!', 'success');
|
|
||||||
} else {
|
|
||||||
this.addLog('Ошибка: ' + result.error, 'error');
|
|
||||||
this.enablePlayButton(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateInstance() {
|
|
||||||
if (!this.currentInstance || !this.isServerPack) return;
|
|
||||||
|
|
||||||
const packName = this.currentInstance.serverPackName;
|
|
||||||
if (!packName) {
|
|
||||||
this.addLog('Ошибка: неизвестная сборка', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addLog('Обновление сборки...', 'info');
|
|
||||||
this.enablePlayButton(false);
|
|
||||||
|
|
||||||
const progressContainer = this.showAnimatedProgress('Обновление сборки...');
|
|
||||||
|
|
||||||
let eventSource = null;
|
|
||||||
let progressData = { totalFiles: 0, downloadedFiles: 0 };
|
|
||||||
|
|
||||||
try {
|
|
||||||
eventSource = new EventSource(`/api/instances/${this.currentInstance.name}/install/stream`);
|
|
||||||
eventSource.onmessage = (e) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
if (data.phase === 'starting') {
|
|
||||||
progressData.totalFiles = data.totalFiles || 0;
|
|
||||||
this.updateAnimatedProgress(progressContainer, `Загрузка: 0/${progressData.totalFiles} файлов`, 5);
|
|
||||||
} else if (data.phase === 'downloading') {
|
|
||||||
progressData.downloadedFiles = data.downloadedFiles || 0;
|
|
||||||
const total = data.totalFiles || progressData.totalFiles || 1;
|
|
||||||
const percent = Math.round((progressData.downloadedFiles / total) * 100);
|
|
||||||
const fileName = data.currentFile ? data.currentFile.split('/').pop() : '';
|
|
||||||
const filePercent = data.filePercent || 0;
|
|
||||||
this.updateAnimatedProgress(progressContainer,
|
|
||||||
`Файл ${progressData.downloadedFiles}/${total} (${percent}%)`,
|
|
||||||
percent,
|
|
||||||
fileName,
|
|
||||||
filePercent
|
|
||||||
);
|
|
||||||
} else if (data.phase === 'complete') {
|
|
||||||
this.updateAnimatedProgress(progressContainer, 'Готово!', 100);
|
|
||||||
} else if (data.phase === 'error') {
|
|
||||||
this.addLog('Ошибка: ' + (data.message || 'неизвестная ошибка'), 'error');
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.log('SSE not available, using fallback progress');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.request('/instances/zernmc/install', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
packName: packName,
|
|
||||||
instanceName: this.currentInstance.name
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hideProgress();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.addLog('Сборка обновлена!', 'success');
|
|
||||||
|
|
||||||
this.addLog('Проверка после обновления...', 'info');
|
|
||||||
const verifyResult = await this.request(`/instances/${this.currentInstance.name}/verify`);
|
|
||||||
if (verifyResult.success && verifyResult.data) {
|
|
||||||
this.hasMismatches = verifyResult.data.hasMismatches;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateResult = await this.request(`/instances/${this.currentInstance.name}/updates`);
|
|
||||||
if (updateResult.success && updateResult.data) {
|
|
||||||
this.hasUpdate = updateResult.data.hasUpdate;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatePlayButton();
|
|
||||||
|
|
||||||
if (!this.hasUpdate && !this.hasMismatches) {
|
|
||||||
this.addLog('Готово к игре!', 'success');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.addLog('Ошибка обновления: ' + result.error, 'error');
|
|
||||||
this.updatePlayButton();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showAnimatedProgress(text) {
|
|
||||||
const progress = document.getElementById('download-progress');
|
|
||||||
const progressText = document.getElementById('progress-text');
|
|
||||||
const progressFill = document.getElementById('progress-fill');
|
|
||||||
|
|
||||||
progress.classList.remove('hidden');
|
|
||||||
progressText.innerHTML = `<div class="progress-label">${text}</div>
|
|
||||||
<div class="progress-file"></div>`;
|
|
||||||
progressFill.style.width = '5%';
|
|
||||||
progressFill.classList.add('animated');
|
|
||||||
|
|
||||||
return { container: progress, text: progressText, fill: progressFill };
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAnimatedProgress(progressContainer, text, percent, fileName = '', filePercent = 0) {
|
|
||||||
const { text: progressText, fill: progressFill } = progressContainer;
|
|
||||||
if (fileName) {
|
|
||||||
progressText.innerHTML = `<div class="progress-label">${text}</div>
|
|
||||||
<div class="progress-file">${fileName} (${filePercent}%)</div>`;
|
|
||||||
} else {
|
|
||||||
progressText.innerHTML = `<div class="progress-label">${text}</div>`;
|
|
||||||
}
|
|
||||||
progressFill.style.width = percent + '%';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== DOWNLOAD MODAL ====================
|
|
||||||
async showDownloadModal() {
|
|
||||||
document.getElementById('download-modal').classList.remove('hidden');
|
|
||||||
await this.loadZernMCPacks();
|
|
||||||
await this.loadMCVersions();
|
|
||||||
}
|
|
||||||
|
|
||||||
hideDownloadModal() {
|
|
||||||
document.getElementById('download-modal').classList.add('hidden');
|
|
||||||
this.hideProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
switchTab(tab) {
|
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
||||||
btn.classList.toggle('active', btn.dataset.tab === tab);
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.tab-content').forEach(content => {
|
|
||||||
content.classList.toggle('active', content.id === 'tab-' + tab);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadZernMCPacks() {
|
|
||||||
const select = document.getElementById('zernmc-pack-select');
|
|
||||||
select.innerHTML = '<option value="">Загрузка...</option>';
|
|
||||||
|
|
||||||
const result = await this.request('/instances/zernmc');
|
|
||||||
|
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
|
||||||
this.zernmcPacks = result.data;
|
|
||||||
select.innerHTML = result.data.map(pack =>
|
|
||||||
`<option value="${this.escapeHtml(pack.name)}">${this.escapeHtml(pack.name)} (v${pack.version})</option>`
|
|
||||||
).join('');
|
|
||||||
} else {
|
|
||||||
select.innerHTML = '<option value="">Нет доступных сборок</option>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadMCVersions() {
|
|
||||||
const select = document.getElementById('mc-version-select');
|
|
||||||
select.innerHTML = '<option value="">Загрузка...</option>';
|
|
||||||
|
|
||||||
const result = await this.request('/versions');
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
this.mcVersions = result.data;
|
|
||||||
select.innerHTML = '<option value="">Выберите версию</option>' +
|
|
||||||
result.data.map(v => `<option value="${v.id}">${v.id}</option>`).join('');
|
|
||||||
} else {
|
|
||||||
select.innerHTML = '<option value="">Не удалось загрузить</option>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onLoaderChange(loader) {
|
|
||||||
const loaderVersionGroup = document.getElementById('loader-version-group');
|
|
||||||
const loaderVersionSelect = document.getElementById('loader-version-select');
|
|
||||||
|
|
||||||
if (loader === 'vanilla') {
|
|
||||||
loaderVersionGroup.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
loaderVersionGroup.classList.remove('hidden');
|
|
||||||
loaderVersionSelect.innerHTML = '<option value="">Загрузка...</option>';
|
|
||||||
|
|
||||||
const result = await this.request(`/versions/${document.getElementById('mc-version-select').value}/loaders/${loader}`);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
loaderVersionSelect.innerHTML = result.data.map(v =>
|
|
||||||
`<option value="${v.version}">${v.version}</option>`
|
|
||||||
).join('');
|
|
||||||
} else {
|
|
||||||
loaderVersionSelect.innerHTML = '<option value="">Нет версий</option>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async installZernMCPack() {
|
|
||||||
const packName = document.getElementById('zernmc-pack-select').value;
|
|
||||||
const instanceName = document.getElementById('zernmc-instance-name').value;
|
|
||||||
|
|
||||||
if (!packName) {
|
|
||||||
alert('Выберите сборку');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!instanceName) {
|
|
||||||
alert('Введите название сборки');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showProgress('Установка ZernMC сборки...');
|
|
||||||
this.addLog('Начало установки: ' + packName, 'info');
|
|
||||||
|
|
||||||
const result = await this.request('/instances/zernmc/install', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ packName, instanceName })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.hideDownloadModal();
|
|
||||||
await this.loadCurrentInstance();
|
|
||||||
this.addLog('Сборка установлена!', 'success');
|
|
||||||
} else {
|
|
||||||
this.addLog('Ошибка установки: ' + result.error, 'error');
|
|
||||||
this.hideProgress();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async installVanilla() {
|
|
||||||
const mcVersion = document.getElementById('mc-version-select').value;
|
|
||||||
const loader = document.getElementById('loader-select').value;
|
|
||||||
const loaderVersion = document.getElementById('loader-version-select').value;
|
|
||||||
const instanceName = document.getElementById('vanilla-instance-name').value;
|
|
||||||
|
|
||||||
if (!mcVersion) {
|
|
||||||
alert('Выберите версию Minecraft');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!instanceName) {
|
|
||||||
alert('Введите название сборки');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loader !== 'vanilla' && !loaderVersion) {
|
|
||||||
alert('Выберите версию лоадера');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showProgress('Установка сборки...');
|
|
||||||
this.addLog(`Начало установки: Minecraft ${mcVersion} ${loader !== 'vanilla' ? loader + ' ' + loaderVersion : ''}`, 'info');
|
|
||||||
|
|
||||||
const result = await this.request('/instances/vanilla/install', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
mcVersion,
|
|
||||||
loader: loader === 'vanilla' ? null : loader,
|
|
||||||
loaderVersion: loader === 'vanilla' ? null : loaderVersion,
|
|
||||||
instanceName
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.hideDownloadModal();
|
|
||||||
await this.loadCurrentInstance();
|
|
||||||
this.addLog('Сборка установлена!', 'success');
|
|
||||||
} else {
|
|
||||||
this.addLog('Ошибка установки: ' + result.error, 'error');
|
|
||||||
this.hideProgress();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showProgress(text) {
|
|
||||||
const progress = document.getElementById('download-progress');
|
|
||||||
const progressText = document.getElementById('progress-text');
|
|
||||||
const progressFill = document.getElementById('progress-fill');
|
|
||||||
|
|
||||||
progress.classList.remove('hidden');
|
|
||||||
progressText.textContent = text;
|
|
||||||
progressFill.style.width = '50%';
|
|
||||||
}
|
|
||||||
|
|
||||||
hideProgress() {
|
|
||||||
document.getElementById('download-progress').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== LOGS ====================
|
|
||||||
addLog(message, type = 'info') {
|
|
||||||
const container = document.getElementById('logs-container');
|
|
||||||
const entry = document.createElement('div');
|
|
||||||
entry.className = `log-entry ${type}`;
|
|
||||||
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
|
||||||
container.appendChild(entry);
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearLogs() {
|
|
||||||
const container = document.getElementById('logs-container');
|
|
||||||
container.innerHTML = '<div class="log-entry info">Логи очищены</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== UI HELPERS ====================
|
|
||||||
showLoginScreen() {
|
|
||||||
document.getElementById('login-screen').classList.remove('hidden');
|
|
||||||
document.getElementById('main-screen').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
showMainScreen() {
|
|
||||||
document.getElementById('login-screen').classList.add('hidden');
|
|
||||||
document.getElementById('main-screen').classList.remove('hidden');
|
|
||||||
document.getElementById('username-display').textContent = this.username || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
showLoading(show) {
|
|
||||||
const overlay = document.getElementById('loading-overlay');
|
|
||||||
if (show) {
|
|
||||||
overlay.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
overlay.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = new App();
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.api;
|
|
||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class InstanceServiceTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void instanceService_instantiates() {
|
|
||||||
InstanceService service = new InstanceService();
|
|
||||||
assertNotNull(service, "InstanceService должен создаваться");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getAllInstances_returnsResponse() {
|
|
||||||
InstanceService service = new InstanceService();
|
|
||||||
ApiResponse<?> response = service.getAllInstances();
|
|
||||||
|
|
||||||
assertNotNull(response, "Ответ не должен быть null");
|
|
||||||
assertTrue(response.isSuccess() || !response.isSuccess(), "Должен быть валидный ответ");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getAllInstances_returnsList() {
|
|
||||||
InstanceService service = new InstanceService();
|
|
||||||
ApiResponse<?> response = service.getAllInstances();
|
|
||||||
|
|
||||||
assertNotNull(response.getData(), "Data не должен быть null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isInstanceExists_returnsBoolean() {
|
|
||||||
InstanceService service = new InstanceService();
|
|
||||||
ApiResponse<Boolean> response = service.isInstanceExists("nonexistent");
|
|
||||||
|
|
||||||
assertNotNull(response, "Ответ не должен быть null");
|
|
||||||
assertTrue(response.isSuccess(), "Проверка должна быть успешной");
|
|
||||||
assertNotNull(response.getData(), "Data должен быть boolean");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isInstanceExists_nonexistentReturnsFalse() {
|
|
||||||
InstanceService service = new InstanceService();
|
|
||||||
ApiResponse<Boolean> response = service.isInstanceExists("definitely_nonexistent_12345");
|
|
||||||
|
|
||||||
assertTrue(response.isSuccess());
|
|
||||||
assertFalse(response.getData(), "Несуществующая сборка должна вернуть false");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteInstance_invalidName_returnsError() {
|
|
||||||
InstanceService service = new InstanceService();
|
|
||||||
ApiResponse<Boolean> response = service.deleteInstance("nonexistent");
|
|
||||||
|
|
||||||
assertNotNull(response, "Ответ не должен быть null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getInstance_nonexistent_returnsError() {
|
|
||||||
InstanceService service = new InstanceService();
|
|
||||||
ApiResponse<?> response = service.getInstance("definitely_nonexistent_12345");
|
|
||||||
|
|
||||||
assertNotNull(response, "Ответ не должен быть null");
|
|
||||||
assertFalse(response.isSuccess(), "Несуществующая сборка должна вернуть ошибку");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.web;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
import java.awt.GraphicsEnvironment;
|
|
||||||
|
|
||||||
class HeadlessDetectionTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void headlessDetection_works() {
|
|
||||||
boolean isHeadless = GraphicsEnvironment.isHeadless();
|
|
||||||
assertNotNull(isHeadless, "isHeadless() должен возвращать boolean");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void headlessDetection_consistentResult() {
|
|
||||||
boolean isHeadless1 = GraphicsEnvironment.isHeadless();
|
|
||||||
boolean isHeadless2 = GraphicsEnvironment.isHeadless();
|
|
||||||
assertEquals(isHeadless1, isHeadless2, "Результат должен быть консистентным");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void javaFxCheck_works() {
|
|
||||||
try {
|
|
||||||
boolean isHeadless = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()
|
|
||||||
.getDefaultScreenDevice() != null;
|
|
||||||
assertFalse(isHeadless, "На Linux без дисплея должно быть headless");
|
|
||||||
} catch (Exception e) {
|
|
||||||
assertTrue(true, "Ожидаемая ошибка на headless");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.web;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.ServerSocket;
|
|
||||||
|
|
||||||
class WebServerTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findFreePort_returnsValidPort() throws IOException {
|
|
||||||
int port = WebServer.findFreePort(8080);
|
|
||||||
assertTrue(port >= 8080, "Порт должен быть >= 8080");
|
|
||||||
assertTrue(port < 8180, "Порт должен быть < 8180");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findFreePort_findsDifferentPorts() throws IOException {
|
|
||||||
int port1 = WebServer.findFreePort(9000);
|
|
||||||
int port2 = WebServer.findFreePort(9100);
|
|
||||||
|
|
||||||
assertNotEquals(port1, port2, "Должены быть разные порты");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findFreePort_respectsStartPort() throws IOException {
|
|
||||||
int port = WebServer.findFreePort(9500);
|
|
||||||
assertTrue(port >= 9500, "Порт должен быть >= указанного startPort");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void portRangeTest() throws IOException {
|
|
||||||
int port = WebServer.findFreePort(8080);
|
|
||||||
assertTrue(port >= 8080 && port < 8180, "Порт в допустимом диапазоне 8080-8179");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -19,7 +19,7 @@ def parse_args():
|
|||||||
# Additional options
|
# Additional options
|
||||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
|
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
|
||||||
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
|
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
|
||||||
parser.add_argument("--workers", type=int, default=4, help="Number of workers for production mode")
|
parser.add_argument("--workers", type=int, default=1, help="Number of workers for production mode (default: 1, more causes file download slowdown)")
|
||||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|||||||
+553
-23
@@ -1,8 +1,11 @@
|
|||||||
import re
|
import re
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import json
|
import json
|
||||||
@@ -12,6 +15,10 @@ from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
|||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||||
|
|
||||||
|
# Disable httpx debug logging
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
|
|
||||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||||
from models import PackMeta
|
from models import PackMeta
|
||||||
from middleware import LoggingMiddleware
|
from middleware import LoggingMiddleware
|
||||||
@@ -28,6 +35,18 @@ logger = structlog.get_logger(__name__)
|
|||||||
manifest_cache = TTLCache(maxsize=100, ttl=300)
|
manifest_cache = TTLCache(maxsize=100, ttl=300)
|
||||||
|
|
||||||
BUILDS_DIR = Path("builds")
|
BUILDS_DIR = Path("builds")
|
||||||
|
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||||
|
|
||||||
|
# IP Filtering Configuration
|
||||||
|
import os
|
||||||
|
import middleware as mw
|
||||||
|
|
||||||
|
# Only configure manually blocked IPs at import time
|
||||||
|
# Public blocklists are loaded in lifespan (once, not per-worker)
|
||||||
|
MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""}
|
||||||
|
|
||||||
|
# Cache file for blocklist (load once)
|
||||||
|
BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -37,6 +56,60 @@ async def lifespan(app: FastAPI):
|
|||||||
# Initialize logging
|
# Initialize logging
|
||||||
init_logging()
|
init_logging()
|
||||||
|
|
||||||
|
# Load public blocklists (single worker loads, others wait for cache)
|
||||||
|
USE_PUBLIC_BLOCKLIST = os.environ.get("PUBLIC_BLOCKLIST", "true").lower() == "true"
|
||||||
|
all_blocked = set(MANUAL_BLOCKED_IPS)
|
||||||
|
|
||||||
|
if USE_PUBLIC_BLOCKLIST:
|
||||||
|
cached_ips = set()
|
||||||
|
|
||||||
|
# Try to load from cache first
|
||||||
|
if BLOCKLIST_CACHE_FILE.exists():
|
||||||
|
try:
|
||||||
|
cached_ips = set(BLOCKLIST_CACHE_FILE.read_text().strip().splitlines())
|
||||||
|
if cached_ips:
|
||||||
|
logger.info(f"Loaded {len(cached_ips)} IPs from blocklist cache")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load blocklist cache: {e}")
|
||||||
|
|
||||||
|
# If no cache, download (only one worker will do this)
|
||||||
|
if not cached_ips:
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
lock_file = DATA_DIR / "blocklist.lock"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to acquire lock (non-blocking)
|
||||||
|
import fcntl
|
||||||
|
lock_fd = open(lock_file, 'w')
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
# We got the lock - download
|
||||||
|
cached_ips = mw.load_public_blocklists()
|
||||||
|
if cached_ips:
|
||||||
|
BLOCKLIST_CACHE_FILE.write_text("\n".join(cached_ips))
|
||||||
|
logger.info(f"Downloaded and saved {len(cached_ips)} IPs to blocklist cache")
|
||||||
|
except BlockingIOError:
|
||||||
|
# Another process is downloading - wait for cache
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
lock_fd.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Lock error: {e}")
|
||||||
|
|
||||||
|
# Re-read cache after download
|
||||||
|
if BLOCKLIST_CACHE_FILE.exists() and not cached_ips:
|
||||||
|
try:
|
||||||
|
cached_ips = set(BLOCKLIST_CACHE_FILE.read_text().strip().splitlines())
|
||||||
|
if cached_ips:
|
||||||
|
logger.info(f"Loaded {len(cached_ips)} IPs from blocklist cache (after wait)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
all_blocked.update(cached_ips)
|
||||||
|
|
||||||
|
mw.set_ip_config(blocked=all_blocked)
|
||||||
|
logger.info(f"IP blocklist loaded: {len(all_blocked)} IPs")
|
||||||
|
|
||||||
# Determine environment
|
# Determine environment
|
||||||
if args.test:
|
if args.test:
|
||||||
env = "test"
|
env = "test"
|
||||||
@@ -75,6 +148,26 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
logger.info("All packs ready. Server is running.")
|
logger.info("All packs ready. Server is running.")
|
||||||
|
|
||||||
|
# Scan launcher versions and generate meta
|
||||||
|
logger.info("Scanning launcher versions...")
|
||||||
|
|
||||||
|
# Generate meta.json in builds/ directory
|
||||||
|
logger.info("Generating launcher meta...")
|
||||||
|
generate_launcher_builds_meta()
|
||||||
|
|
||||||
|
# Extract new format ZIPs to versions directory
|
||||||
|
logger.info("Extracting new format versions...")
|
||||||
|
extract_new_format_versions()
|
||||||
|
|
||||||
|
launcher_versions = get_launcher_versions()
|
||||||
|
if launcher_versions:
|
||||||
|
latest = launcher_versions[0]
|
||||||
|
logger.info(f"Launcher meta ready: v{latest['meta']['version']} ({len(latest['meta']['files'])} files)")
|
||||||
|
else:
|
||||||
|
logger.warning("No launcher versions found in new format")
|
||||||
|
|
||||||
|
logger.info("Launcher meta system ready.")
|
||||||
|
|
||||||
# Initialize proxy client
|
# Initialize proxy client
|
||||||
global proxy_client
|
global proxy_client
|
||||||
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||||
@@ -683,27 +776,280 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
|||||||
size=full_path.stat().st_size,
|
size=full_path.stat().st_size,
|
||||||
client_ip=client_ip)
|
client_ip=client_ip)
|
||||||
|
|
||||||
return FileResponse(full_path)
|
return FileResponse(full_path, direct_passthrough=True)
|
||||||
|
|
||||||
|
|
||||||
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
|
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
|
||||||
|
|
||||||
def get_current_launcher_version() -> str:
|
def get_current_launcher_version() -> str:
|
||||||
"""Get current launcher version from build.version file"""
|
"""Get current launcher version from meta system (new format) or build.version (legacy)"""
|
||||||
|
versions = get_launcher_versions()
|
||||||
|
if versions:
|
||||||
|
return versions[0]["meta"]["version"]
|
||||||
|
|
||||||
|
# Fallback to build.version for legacy
|
||||||
version_file = BUILDS_DIR / "build.version"
|
version_file = BUILDS_DIR / "build.version"
|
||||||
if version_file.exists():
|
if version_file.exists():
|
||||||
return version_file.read_text().strip()
|
return version_file.read_text().strip()
|
||||||
return "1.0.0"
|
return "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(version_str: str) -> dict:
|
||||||
|
"""Parse version string to determine if it's new or legacy format"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
match = re.match(r'^(\d+)\.(\d+)\.(\d+)(.*)$', version_str)
|
||||||
|
if not match:
|
||||||
|
return {"major": 0, "minor": 0, "patch": 0, "suffix": version_str, "is_legacy": True}
|
||||||
|
|
||||||
|
major, minor, patch, suffix = match.groups()
|
||||||
|
suffix = suffix.strip("-")
|
||||||
|
|
||||||
|
is_legacy = bool(suffix)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"major": int(major),
|
||||||
|
"minor": int(minor),
|
||||||
|
"patch": int(patch),
|
||||||
|
"suffix": suffix,
|
||||||
|
"is_legacy": is_legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_new_format(filename: str) -> bool:
|
||||||
|
"""Check if filename represents new format build"""
|
||||||
|
return filename.startswith("ZernMC-win-")
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== ЛАУНЧЕР МЕТА СИСТЕМА ======================
|
||||||
|
|
||||||
|
def calculate_file_hash(file_path: Path) -> str:
|
||||||
|
"""Calculate SHA256 hash of a file"""
|
||||||
|
import hashlib
|
||||||
|
hash_sha = hashlib.sha256()
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
while chunk := f.read(8192):
|
||||||
|
hash_sha.update(chunk)
|
||||||
|
return hash_sha.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launcher_builds_meta():
|
||||||
|
"""Generate meta.json in builds/ directory for incremental updates"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
version = get_current_launcher_version()
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
|
||||||
|
meta_path = BUILDS_DIR / "meta.json"
|
||||||
|
|
||||||
|
# Check if meta exists and is fresh
|
||||||
|
if meta_path.exists():
|
||||||
|
try:
|
||||||
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
|
existing = json.load(f)
|
||||||
|
if existing.get("version") == version:
|
||||||
|
logger.debug("Launcher meta.json already exists and is current")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Generate new meta
|
||||||
|
files = []
|
||||||
|
try:
|
||||||
|
for file_path in BUILDS_DIR.rglob("*"):
|
||||||
|
if file_path.is_file() and file_path.name not in ["meta.json"]:
|
||||||
|
rel_path = str(file_path.relative_to(BUILDS_DIR))
|
||||||
|
stat = file_path.stat()
|
||||||
|
|
||||||
|
# Calculate hash
|
||||||
|
sha256_hash = hashlib.sha256()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(8192), b""):
|
||||||
|
sha256_hash.update(chunk)
|
||||||
|
|
||||||
|
files.append({
|
||||||
|
"path": rel_path,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"hash": f"sha256:{sha256_hash.hexdigest()}"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to generate launcher meta: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"version": version,
|
||||||
|
"type": "builds",
|
||||||
|
"release_date": datetime.utcnow().isoformat(),
|
||||||
|
"files": files
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(meta, f, indent=2)
|
||||||
|
logger.info(f"Generated launcher meta.json with {len(files)} files")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to save meta.json: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def scan_launcher_version(version: str) -> Optional[dict]:
|
||||||
|
"""Scan a launcher version directory and return meta"""
|
||||||
|
# First check if meta exists in builds/ directly (for new format)
|
||||||
|
meta_path = BUILDS_DIR / "meta.json"
|
||||||
|
if meta_path.exists():
|
||||||
|
try:
|
||||||
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
if meta.get("version") == version:
|
||||||
|
return meta
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: check versions directory
|
||||||
|
version_path = VERSIONS_DIR / version
|
||||||
|
|
||||||
|
if not version_path.exists() or not version_path.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
meta_path = version_path / "meta.json"
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
if meta_path.exists():
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Generate meta
|
||||||
|
files = []
|
||||||
|
for file_path in version_path.rglob("*"):
|
||||||
|
if file_path.is_file() and file_path.name != "meta.json":
|
||||||
|
rel_path = str(file_path.relative_to(version_path))
|
||||||
|
stat = file_path.stat()
|
||||||
|
file_hash = calculate_file_hash(file_path)
|
||||||
|
files.append({
|
||||||
|
"path": rel_path,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"hash": f"sha256:{file_hash}"
|
||||||
|
})
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"version": version,
|
||||||
|
"type": "new",
|
||||||
|
"release_date": datetime.utcnow().isoformat(),
|
||||||
|
"files": files
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save meta
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(meta, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to save launcher meta for {version}: {e}")
|
||||||
|
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version_key(v: str) -> tuple:
|
||||||
|
"""Parse version string for proper numeric sorting"""
|
||||||
|
try:
|
||||||
|
parts = v.split(".")
|
||||||
|
return tuple(int(p) for p in parts)
|
||||||
|
except:
|
||||||
|
return (0, 0, 0)
|
||||||
|
|
||||||
|
def get_launcher_versions() -> list:
|
||||||
|
"""Get list of available launcher versions with meta"""
|
||||||
|
if not VERSIONS_DIR.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
versions = []
|
||||||
|
for version_dir in VERSIONS_DIR.iterdir():
|
||||||
|
if version_dir.is_dir():
|
||||||
|
meta = scan_launcher_version(version_dir.name)
|
||||||
|
if meta:
|
||||||
|
versions.append({
|
||||||
|
"version": version_dir.name,
|
||||||
|
"meta": meta
|
||||||
|
})
|
||||||
|
|
||||||
|
versions.sort(key=lambda x: parse_version_key(x["version"]), reverse=True)
|
||||||
|
return versions
|
||||||
|
|
||||||
|
|
||||||
|
def get_launcher_version_meta(version: str) -> Optional[dict]:
|
||||||
|
"""Get meta for specific launcher version"""
|
||||||
|
return scan_launcher_version(version)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_new_format_versions():
|
||||||
|
"""Extract new format ZIPs to versions directory"""
|
||||||
|
VERSIONS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Find all ZernMC-win-*.zip files
|
||||||
|
new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip"))
|
||||||
|
|
||||||
|
for zip_file in new_format_zips:
|
||||||
|
version = zip_file.stem.replace("ZernMC-win-", "")
|
||||||
|
extract_dir = VERSIONS_DIR / version
|
||||||
|
|
||||||
|
# Skip if already extracted and meta exists
|
||||||
|
if extract_dir.exists() and (extract_dir / "meta.json").exists():
|
||||||
|
logger.debug(f"Version {version} already extracted")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import zipfile
|
||||||
|
with zipfile.ZipFile(zip_file, 'r') as zf:
|
||||||
|
# Extract all files
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
|
||||||
|
logger.info(f"Extracted {zip_file.name} successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to extract {zip_file.name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ======================
|
||||||
|
|
||||||
|
|
||||||
def get_available_zips() -> list:
|
def get_available_zips() -> list:
|
||||||
"""Get list of available zip archives"""
|
"""Get list of available zip archives (new format only)"""
|
||||||
if not BUILDS_DIR.exists():
|
if not BUILDS_DIR.exists():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
zips = []
|
zips = []
|
||||||
for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"):
|
for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"):
|
||||||
|
if is_new_format(zip_file.name):
|
||||||
|
continue
|
||||||
|
|
||||||
version = zip_file.stem.replace("ZernMCLauncher-", "")
|
version = zip_file.stem.replace("ZernMCLauncher-", "")
|
||||||
|
parsed = parse_version(version)
|
||||||
|
stat = zip_file.stat()
|
||||||
|
zips.append({
|
||||||
|
"version": version,
|
||||||
|
"filename": zip_file.name,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
|
"is_legacy": parsed["is_legacy"]
|
||||||
|
})
|
||||||
|
|
||||||
|
zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True)
|
||||||
|
return zips
|
||||||
|
|
||||||
|
|
||||||
|
def get_new_format_zips() -> list:
|
||||||
|
"""Get list of available zip archives (new format: ZernMC-win-*.zip)"""
|
||||||
|
if not BUILDS_DIR.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
zips = []
|
||||||
|
for zip_file in BUILDS_DIR.glob("ZernMC-win-*.zip"):
|
||||||
|
version = zip_file.stem.replace("ZernMC-win-", "")
|
||||||
stat = zip_file.stat()
|
stat = zip_file.stat()
|
||||||
zips.append({
|
zips.append({
|
||||||
"version": version,
|
"version": version,
|
||||||
@@ -712,7 +1058,37 @@ def get_available_zips() -> list:
|
|||||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||||
})
|
})
|
||||||
|
|
||||||
zips.sort(key=lambda x: x["version"], reverse=True)
|
zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True)
|
||||||
|
return zips
|
||||||
|
|
||||||
|
|
||||||
|
def get_legacy_zips() -> list:
|
||||||
|
"""Get list of available legacy zip archives (< 1.0.8 or with suffix)"""
|
||||||
|
if not BUILDS_DIR.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
zips = []
|
||||||
|
for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"):
|
||||||
|
version = zip_file.stem.replace("ZernMCLauncher-", "")
|
||||||
|
parsed = parse_version(version)
|
||||||
|
|
||||||
|
is_legacy = (
|
||||||
|
parsed["is_legacy"] or
|
||||||
|
(parsed["major"] < 1) or
|
||||||
|
(parsed["major"] == 1 and parsed["minor"] == 0 and parsed["patch"] < 8)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_legacy:
|
||||||
|
stat = zip_file.stat()
|
||||||
|
zips.append({
|
||||||
|
"version": version,
|
||||||
|
"filename": zip_file.name,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
|
"is_legacy": True
|
||||||
|
})
|
||||||
|
|
||||||
|
zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True)
|
||||||
return zips
|
return zips
|
||||||
|
|
||||||
|
|
||||||
@@ -720,7 +1096,8 @@ def get_available_zips() -> list:
|
|||||||
async def get_launcher_version():
|
async def get_launcher_version():
|
||||||
"""Return launcher version information"""
|
"""Return launcher version information"""
|
||||||
version = get_current_launcher_version()
|
version = get_current_launcher_version()
|
||||||
zips = get_available_zips()
|
new_zips = get_new_format_zips()
|
||||||
|
legacy_zips = get_legacy_zips()
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"version": version,
|
"version": version,
|
||||||
@@ -737,11 +1114,15 @@ async def get_launcher_version():
|
|||||||
response["download_exe"] = "/launcher/download/exe"
|
response["download_exe"] = "/launcher/download/exe"
|
||||||
response["exe_size"] = exe_path.stat().st_size
|
response["exe_size"] = exe_path.stat().st_size
|
||||||
|
|
||||||
if zips:
|
if new_zips:
|
||||||
response["download_zip"] = f"/launcher/download/zip/{zips[0]['filename']}"
|
response["download_zip"] = f"/launcher/download/zip/{new_zips[0]['filename']}"
|
||||||
response["zip_version"] = zips[0]["version"]
|
response["zip_version"] = new_zips[0]["version"]
|
||||||
response["zip_size"] = zips[0]["size"]
|
response["zip_size"] = new_zips[0]["size"]
|
||||||
response["all_zips"] = zips
|
response["all_zips"] = new_zips
|
||||||
|
|
||||||
|
if legacy_zips:
|
||||||
|
response["legacy_zips"] = legacy_zips
|
||||||
|
response["legacy_download_url"] = "/launcher/download/legacy"
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -749,6 +1130,9 @@ async def get_launcher_version():
|
|||||||
@app.get("/launcher/download/jar")
|
@app.get("/launcher/download/jar")
|
||||||
async def download_launcher_jar():
|
async def download_launcher_jar():
|
||||||
"""Download launcher JAR file"""
|
"""Download launcher JAR file"""
|
||||||
|
# Prefer new shaded JAR, fallback to old
|
||||||
|
file_path = BUILDS_DIR / "zernmclauncher.jar"
|
||||||
|
if not file_path.exists():
|
||||||
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
@@ -756,7 +1140,7 @@ async def download_launcher_jar():
|
|||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=file_path,
|
path=file_path,
|
||||||
filename="ZernMCLauncher.jar",
|
filename="zernmclauncher.jar",
|
||||||
media_type="application/java-archive"
|
media_type="application/java-archive"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -779,7 +1163,8 @@ async def download_launcher_exe():
|
|||||||
@app.get("/launcher/download/zip/{filename}")
|
@app.get("/launcher/download/zip/{filename}")
|
||||||
async def download_launcher_zip(filename: str):
|
async def download_launcher_zip(filename: str):
|
||||||
"""Download specific launcher ZIP archive"""
|
"""Download specific launcher ZIP archive"""
|
||||||
if ".." in filename or not filename.startswith("ZernMCLauncher-") or not filename.endswith(".zip"):
|
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
|
||||||
|
if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"):
|
||||||
raise HTTPException(400, "Invalid filename")
|
raise HTTPException(400, "Invalid filename")
|
||||||
|
|
||||||
file_path = BUILDS_DIR / filename
|
file_path = BUILDS_DIR / filename
|
||||||
@@ -796,25 +1181,158 @@ async def download_launcher_zip(filename: str):
|
|||||||
|
|
||||||
@app.get("/launcher/download/latest")
|
@app.get("/launcher/download/latest")
|
||||||
async def download_latest_launcher():
|
async def download_latest_launcher():
|
||||||
"""Download the latest launcher (prefer ZIP if available, fallback to JAR)"""
|
"""Download the latest launcher (new format: ZernMC-win-*.zip)"""
|
||||||
zips = get_available_zips()
|
zips = get_new_format_zips()
|
||||||
|
|
||||||
if zips:
|
if zips:
|
||||||
latest_zip = zips[0]["filename"]
|
latest_zip = zips[0]["filename"]
|
||||||
return await download_launcher_zip(latest_zip)
|
return await download_launcher_zip(latest_zip)
|
||||||
|
|
||||||
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
raise HTTPException(404, "No new format launcher files available")
|
||||||
if jar_path.exists():
|
|
||||||
return await download_launcher_jar()
|
|
||||||
|
|
||||||
raise HTTPException(404, "No launcher files available")
|
|
||||||
|
@app.get("/launcher/download/legacy")
|
||||||
|
async def download_legacy_launcher():
|
||||||
|
"""Download the latest legacy launcher (< 1.0.8 or with suffix)"""
|
||||||
|
zips = get_legacy_zips()
|
||||||
|
|
||||||
|
if zips:
|
||||||
|
latest_zip = zips[0]["filename"]
|
||||||
|
return await download_launcher_zip(latest_zip)
|
||||||
|
|
||||||
|
raise HTTPException(404, "No legacy launcher files available")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/launcher/download/zip/{filename}")
|
||||||
|
async def download_launcher_zip(filename: str):
|
||||||
|
"""Download specific launcher ZIP archive"""
|
||||||
|
if ".." in filename:
|
||||||
|
raise HTTPException(400, "Invalid filename")
|
||||||
|
|
||||||
|
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
|
||||||
|
if not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"):
|
||||||
|
raise HTTPException(400, "Invalid filename")
|
||||||
|
|
||||||
|
file_path = BUILDS_DIR / filename
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(404, "ZIP file not found")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
filename=filename,
|
||||||
|
media_type="application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ======================
|
||||||
|
|
||||||
|
@app.get("/launcher/meta")
|
||||||
|
async def get_launcher_meta_list():
|
||||||
|
"""Get list of all launcher versions with meta (new format)"""
|
||||||
|
versions = get_launcher_versions()
|
||||||
|
return {
|
||||||
|
"versions": [
|
||||||
|
{"version": v["version"], "meta": v["meta"]}
|
||||||
|
for v in versions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/launcher/meta/{version}")
|
||||||
|
async def get_launcher_version_meta_handler(version: str):
|
||||||
|
"""Get meta for specific launcher version"""
|
||||||
|
meta = get_launcher_version_meta(version)
|
||||||
|
if not meta:
|
||||||
|
raise HTTPException(404, f"Version {version} not found or not in new format")
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/launcher/diff")
|
||||||
|
async def get_launcher_diff(request: Request):
|
||||||
|
"""Get diff for launcher update - compare local files with server version"""
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
# Get latest version
|
||||||
|
versions = get_launcher_versions()
|
||||||
|
if not versions:
|
||||||
|
raise HTTPException(404, "No versions available")
|
||||||
|
|
||||||
|
latest = versions[0]
|
||||||
|
meta = latest["meta"]
|
||||||
|
|
||||||
|
# Build hash map from client
|
||||||
|
client_hashes = body # {filename: hash, ...}
|
||||||
|
|
||||||
|
to_download = []
|
||||||
|
to_delete = []
|
||||||
|
|
||||||
|
# Find new/updated files
|
||||||
|
server_files = {f["path"]: f for f in meta["files"]}
|
||||||
|
|
||||||
|
for path, file_info in server_files.items():
|
||||||
|
if path not in client_hashes:
|
||||||
|
to_download.append(file_info)
|
||||||
|
elif client_hashes[path] != file_info["hash"]:
|
||||||
|
to_download.append(file_info)
|
||||||
|
|
||||||
|
# Find deleted files (files on server but not in client)
|
||||||
|
for path in client_hashes:
|
||||||
|
if path not in server_files:
|
||||||
|
to_delete.append(path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": meta["version"],
|
||||||
|
"to_download": to_download,
|
||||||
|
"to_delete": to_delete
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/launcher/file/{version}/{file_path:path}")
|
||||||
|
async def get_launcher_file(version: str, file_path: str, request: Request):
|
||||||
|
"""Download a specific file from a launcher version"""
|
||||||
|
# Ищем в builds/ директории (где лежит zernmc.exe, lib, assets и т.д.)
|
||||||
|
full_path = BUILDS_DIR / file_path
|
||||||
|
|
||||||
|
# Security: prevent path traversal
|
||||||
|
if ".." in file_path:
|
||||||
|
raise HTTPException(403, "Invalid file path")
|
||||||
|
|
||||||
|
if not full_path.exists() or not full_path.is_file():
|
||||||
|
# Fallback: ищем в versions директории
|
||||||
|
alt_path = VERSIONS_DIR / version / file_path
|
||||||
|
if alt_path.exists() and alt_path.is_file():
|
||||||
|
full_path = alt_path
|
||||||
|
else:
|
||||||
|
raise HTTPException(404, "File not found: " + file_path)
|
||||||
|
|
||||||
|
return FileResponse(full_path, direct_passthrough=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/launcher/download/zip/{version}")
|
||||||
|
async def download_launcher_zip_version(version: str):
|
||||||
|
"""Download full ZIP for specific version (for new installs)"""
|
||||||
|
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
|
||||||
|
|
||||||
|
if not zip_path.exists():
|
||||||
|
raise HTTPException(404, f"ZIP for version {version} not found")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=zip_path,
|
||||||
|
filename=f"ZernMC-win-{version}.zip",
|
||||||
|
media_type="application/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== END ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ======================
|
||||||
|
|
||||||
|
|
||||||
@app.get("/launcher/info")
|
@app.get("/launcher/info")
|
||||||
async def get_launcher_full_info():
|
async def get_launcher_full_info():
|
||||||
"""Full launcher information with all available files"""
|
"""Full launcher information with all available files"""
|
||||||
version = get_current_launcher_version()
|
version = get_current_launcher_version()
|
||||||
zips = get_available_zips()
|
new_zips = get_new_format_zips()
|
||||||
|
legacy_zips = get_legacy_zips()
|
||||||
|
|
||||||
info = {
|
info = {
|
||||||
"current_version": version,
|
"current_version": version,
|
||||||
@@ -822,9 +1340,21 @@ async def get_launcher_full_info():
|
|||||||
"files": {
|
"files": {
|
||||||
"jar": None,
|
"jar": None,
|
||||||
"exe": None,
|
"exe": None,
|
||||||
"zips": zips
|
"zips": new_zips + legacy_zips
|
||||||
},
|
},
|
||||||
"recommended": "zip" if zips else ("exe" if (BUILDS_DIR / "ZernMCLauncher.exe").exists() else "jar")
|
"recommended": "zip" if new_zips else ("exe" if (BUILDS_DIR / "ZernMCLauncher.exe").exists() else "jar"),
|
||||||
|
"new_format": {
|
||||||
|
"available": len(new_zips) > 0,
|
||||||
|
"latest": new_zips[0] if new_zips else None,
|
||||||
|
"download_url": "/launcher/download/latest"
|
||||||
|
},
|
||||||
|
"legacy": {
|
||||||
|
"available": len(legacy_zips) > 0,
|
||||||
|
"count": len(legacy_zips),
|
||||||
|
"latest": legacy_zips[0] if legacy_zips else None,
|
||||||
|
"download_url": "/launcher/download/legacy",
|
||||||
|
"warning": "Legacy builds are technically compatible but not recommended. Consider upgrading to new format."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
||||||
@@ -841,8 +1371,8 @@ async def get_launcher_full_info():
|
|||||||
"download_url": "/launcher/download/exe"
|
"download_url": "/launcher/download/exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
if zips:
|
if new_zips:
|
||||||
info["files"]["latest_zip"] = zips[0]
|
info["files"]["latest_zip"] = new_zips[0]
|
||||||
info["files"]["download_latest"] = "/launcher/download/latest"
|
info["files"]["download_latest"] = "/launcher/download/latest"
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|||||||
+175
-16
@@ -5,43 +5,202 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import traceback
|
import traceback
|
||||||
|
import httpx
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
# Public blocklist URLs
|
||||||
async def dispatch(self, request: Request, call_next):
|
BLOCKLIST_URLS = [
|
||||||
# Generate request ID
|
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset",
|
||||||
request_id = str(uuid.uuid4())[:8]
|
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/iblocklist_isp.netset",
|
||||||
|
]
|
||||||
|
|
||||||
# Get client IP
|
|
||||||
|
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"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
if forwarded:
|
if forwarded:
|
||||||
client_ip = forwarded.split(",")[0].strip()
|
client_ip = forwarded.split(",")[0].strip()
|
||||||
|
return client_ip
|
||||||
|
|
||||||
# Log incoming request
|
|
||||||
logger.info(f"→ {request.method} {request.url.path} (IP: {client_ip}, ID: {request_id})")
|
|
||||||
|
|
||||||
# Start timer
|
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):
|
||||||
|
request_id = str(uuid.uuid4())[:8]
|
||||||
|
global _stats, _stats_last_log
|
||||||
|
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
|
||||||
|
# Check if IP is blocked (silent)
|
||||||
|
if is_ip_blocked(client_ip):
|
||||||
|
_stats["blocked"] += 1
|
||||||
|
return Response(status_code=404, content="")
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
if not check_rate_limit(client_ip):
|
||||||
|
_stats["rate_limited"] += 1
|
||||||
|
# Periodic stats logging instead of every warning
|
||||||
|
if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
|
||||||
|
logger.warning(f"Stats: {_stats}")
|
||||||
|
_stats_last_log = time.time()
|
||||||
|
return Response(status_code=429, content="Too many requests")
|
||||||
|
|
||||||
|
# Check suspicious path (silent 404 for bots)
|
||||||
|
path = request.url.path
|
||||||
|
if is_suspicious_path(path):
|
||||||
|
# Return 404 without logging - confuse the bots
|
||||||
|
return Response(status_code=404, content="")
|
||||||
|
|
||||||
|
# Skip logging for large file downloads (don't spam logs)
|
||||||
|
is_file_download = path.startswith("/pack/") and "/file/" in path
|
||||||
|
|
||||||
|
# Track total requests for stats
|
||||||
|
_stats["total"] += 1
|
||||||
|
|
||||||
|
# Log legitimate requests (except file downloads)
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
|
if not is_file_download:
|
||||||
|
logger.info(f"→ {request.method} {path} (IP: {client_ip}, ID: {request_id})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
||||||
# Calculate duration
|
|
||||||
duration = (time.time() - start_time) * 1000
|
duration = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
# Log response
|
if not is_file_download:
|
||||||
logger.info(f"← {request.method} {request.url.path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
|
logger.info(f"← {request.method} {path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
|
||||||
|
|
||||||
|
# Periodic stats logging (only log if there were blocked/rate-limited)
|
||||||
|
now = time.time()
|
||||||
|
if now - _stats_last_log > STATS_LOG_INTERVAL:
|
||||||
|
if _stats["blocked"] > 0 or _stats["rate_limited"] > 0:
|
||||||
|
logger.warning(f"Blocked requests: IP_blocked={_stats['blocked']}, rate_limited={_stats['rate_limited']}")
|
||||||
|
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
|
||||||
|
_stats_last_log = now
|
||||||
|
|
||||||
# Add request ID to response headers
|
|
||||||
response.headers["X-Request-ID"] = request_id
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration = (time.time() - start_time) * 1000
|
duration = (time.time() - start_time) * 1000
|
||||||
# Log full traceback
|
|
||||||
error_traceback = traceback.format_exc()
|
error_traceback = traceback.format_exc()
|
||||||
logger.error(f"✗ {request.method} {request.url.path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
|
logger.error(f"✗ {request.method} {path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
|
||||||
raise
|
raise
|
||||||
Reference in New Issue
Block a user