Compare commits
12 Commits
59480217aa
..
ui
| Author | SHA1 | Date | |
|---|---|---|---|
| b493b3278b | |||
| ec7ef01760 | |||
| 166dbf8935 | |||
| 7014c4a455 | |||
| d956bce921 | |||
| a765d064c4 | |||
| 1d5241075b | |||
| 2c670b1103 | |||
| 389280f7f1 | |||
| ee1e4fa8d2 | |||
| e17b1d073a | |||
| a8f3ca5049 |
+9
-1
@@ -11,4 +11,12 @@ jre
|
||||
.vscode
|
||||
dependency-reduced-pom.xml
|
||||
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
|
||||
telegram-bot/
|
||||
telegram-bot/
|
||||
builds/
|
||||
server/news/
|
||||
data/
|
||||
packs/
|
||||
.__pycache__
|
||||
.pytest_cache
|
||||
.venv
|
||||
resources
|
||||
@@ -3,7 +3,9 @@ package me.sashegdev.zernmc.launcher;
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
@@ -11,6 +13,11 @@ import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.swing.*;
|
||||
import javax.swing.plaf.basic.BasicProgressBarUI;
|
||||
import java.awt.*;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
@@ -21,10 +28,16 @@ import java.util.jar.Manifest;
|
||||
public class Bootstrap {
|
||||
private static final String JAR_NAME = "zernmclauncher.jar";
|
||||
private static final String BASE_URL = "http://87.120.187.36:1582";
|
||||
private static List<String> MIRRORS = new ArrayList<>();
|
||||
private static volatile boolean jfxChildExiting = false;
|
||||
|
||||
private static Path baseDir;
|
||||
private static Path binDir;
|
||||
private static Path logDir;
|
||||
private static Path javafxPath;
|
||||
private static boolean isCliMode;
|
||||
private static boolean isJfxMode;
|
||||
private static BootstrapUI ui;
|
||||
|
||||
private static Path getLauncherJar() {
|
||||
return binDir.resolve(JAR_NAME);
|
||||
@@ -36,34 +49,148 @@ public class Bootstrap {
|
||||
Files.createDirectories(binDir);
|
||||
logDir = baseDir.resolve("logs");
|
||||
Files.createDirectories(logDir);
|
||||
javafxPath = baseDir.resolve("lib").resolve("javafx");
|
||||
|
||||
log("=== ZernMC Launcher ===");
|
||||
|
||||
// Определяем режим запуска
|
||||
List<String> argList = Arrays.asList(args);
|
||||
boolean cliMode = argList.contains("--cli");
|
||||
boolean jfxMode = !cliMode; // по умолчанию JFX
|
||||
isCliMode = argList.contains("--cli");
|
||||
isJfxMode = !isCliMode;
|
||||
|
||||
log("Mode: " + (isCliMode ? "CLI" : "JFX"));
|
||||
|
||||
if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
|
||||
ui = new BootstrapUI();
|
||||
SwingUtilities.invokeLater(() -> ui.show());
|
||||
}
|
||||
|
||||
// Проверка и обновление лаунчера
|
||||
String currentVersion = readCurrentVersion();
|
||||
String serverVersion = getServerVersion();
|
||||
|
||||
log("Локальная версия: " + currentVersion);
|
||||
log("Версия на сервере: " + serverVersion);
|
||||
log("Local version: " + currentVersion);
|
||||
log("Server version: " + serverVersion);
|
||||
setVersionInfo(currentVersion, serverVersion);
|
||||
|
||||
loadMirrors();
|
||||
log("Primary server: " + BASE_URL);
|
||||
log("Mirrors available: " + (MIRRORS.size() + 1));
|
||||
|
||||
if (isNewer(serverVersion, currentVersion)) {
|
||||
log("Доступно обновление!");
|
||||
log("Update available!");
|
||||
downloadUpdate(serverVersion);
|
||||
} else {
|
||||
log("Версия актуальна");
|
||||
log("Version is up to date");
|
||||
}
|
||||
|
||||
// Запуск в выбранном режиме
|
||||
if (jfxMode) {
|
||||
launchJFX();
|
||||
} else {
|
||||
launchCLI();
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
log("Shutdown signal received...");
|
||||
}));
|
||||
|
||||
if (ui != null) {
|
||||
setTitle("Launching...");
|
||||
setProgress(100, 100);
|
||||
log("Starting launcher...");
|
||||
try { Thread.sleep(400); } catch (InterruptedException ignored) {}
|
||||
ui.close();
|
||||
}
|
||||
|
||||
launchMain(args);
|
||||
}
|
||||
|
||||
private static void launchMain(String[] args) throws Exception {
|
||||
log("Loading launcher: " + getLauncherJar());
|
||||
|
||||
if (isCliMode) {
|
||||
launchInProcess(args);
|
||||
} else {
|
||||
launchInNewProcess(args);
|
||||
}
|
||||
}
|
||||
|
||||
private static void launchInProcess(String[] args) throws Exception {
|
||||
ClassLoader parent = Bootstrap.class.getClassLoader();
|
||||
URL[] urls = { getLauncherJar().toUri().toURL() };
|
||||
URLClassLoader cl = new URLClassLoader(urls, parent);
|
||||
|
||||
Thread.currentThread().setContextClassLoader(cl);
|
||||
|
||||
try {
|
||||
Class<?> mainClass = cl.loadClass("me.sashegdev.zernmc.launcher.Main");
|
||||
java.lang.reflect.Method mainMethod = mainClass.getMethod("main", String[].class);
|
||||
mainMethod.invoke(null, (Object) args);
|
||||
} finally {
|
||||
cl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void launchInNewProcess(String[] args) throws Exception {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
|
||||
Path javaBin = findJava(false);
|
||||
// On Windows, use javaw.exe to hide console in JFX mode
|
||||
if (os.contains("windows")) {
|
||||
Path javawPath = javaBin.resolveSibling("javaw.exe");
|
||||
if (Files.exists(javawPath)) {
|
||||
javaBin = javawPath;
|
||||
}
|
||||
}
|
||||
Path javafxPath = baseDir.resolve("lib").resolve("javafx");
|
||||
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(javaBin.toAbsolutePath().toString());
|
||||
cmd.add("-Dfile.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stdout.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stderr.encoding=UTF-8");
|
||||
cmd.add("-Dlauncher.server=" + BASE_URL);
|
||||
|
||||
if (Files.exists(javafxPath)) {
|
||||
cmd.add("--module-path");
|
||||
cmd.add(javafxPath.toAbsolutePath().toString());
|
||||
cmd.add("--add-modules");
|
||||
cmd.add("javafx.controls,javafx.web");
|
||||
}
|
||||
|
||||
cmd.add("-jar");
|
||||
cmd.add(getLauncherJar().toAbsolutePath().toString());
|
||||
cmd.add("--jfx");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.directory(baseDir.toFile());
|
||||
pb.inheritIO();
|
||||
|
||||
log("Starting process: " + String.join(" ", cmd));
|
||||
|
||||
Process p = pb.start();
|
||||
int code = p.waitFor();
|
||||
|
||||
log("JFX process exited with code: " + code);
|
||||
System.exit(code);
|
||||
}
|
||||
|
||||
private static Path findJava(boolean preferConsole) {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
String javaExe = "java.exe";
|
||||
|
||||
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
|
||||
if (!Files.exists(javaBin)) {
|
||||
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
|
||||
}
|
||||
if (!Files.exists(javaBin)) {
|
||||
try {
|
||||
Process p = new ProcessBuilder("where", javaExe).redirectErrorStream(true).start();
|
||||
if (p.waitFor() == 0) {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
||||
String path = br.readLine();
|
||||
if (path != null) javaBin = Paths.get(path.trim());
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
if (!Files.exists(javaBin)) {
|
||||
throw new RuntimeException("Java not found");
|
||||
}
|
||||
return javaBin;
|
||||
}
|
||||
|
||||
private static void log(String msg) {
|
||||
@@ -73,6 +200,19 @@ public class Bootstrap {
|
||||
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (Exception ignored) {}
|
||||
if (ui != null) ui.setStatus(msg);
|
||||
}
|
||||
|
||||
private static void setProgress(int current, int total) {
|
||||
if (ui != null) ui.setProgress(current, total);
|
||||
}
|
||||
|
||||
private static void setVersionInfo(String localVer, String serverVer) {
|
||||
if (ui != null) ui.setVersionInfo(localVer, serverVer);
|
||||
}
|
||||
|
||||
private static void setTitle(String text) {
|
||||
if (ui != null) ui.setTitleText(text);
|
||||
}
|
||||
|
||||
private static String readCurrentVersion() {
|
||||
@@ -85,7 +225,7 @@ public class Bootstrap {
|
||||
if (v != null && !v.isBlank()) return v;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Ошибка чтения манифеста: " + e.getMessage());
|
||||
log("Error reading manifest: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return "0.0.0";
|
||||
@@ -118,7 +258,7 @@ public class Bootstrap {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Ошибка получения версии: " + e.getMessage());
|
||||
log("Error fetching version: " + e.getMessage());
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
@@ -139,23 +279,23 @@ public class Bootstrap {
|
||||
}
|
||||
|
||||
private static void downloadUpdate(String newVersion) throws Exception {
|
||||
log("Проверка обновлений...");
|
||||
log("Checking for updates...");
|
||||
|
||||
// Получаем мета с сервера
|
||||
Map<String, FileMeta> serverFiles = fetchServerMeta(newVersion);
|
||||
if (serverFiles.isEmpty()) {
|
||||
log("Не удалось получить мета с сервера");
|
||||
log("Failed to get server meta");
|
||||
return;
|
||||
}
|
||||
|
||||
// Сканируем локальные файлы
|
||||
Map<String, String> localFiles = scanLocalFiles();
|
||||
log("Локальных файлов: " + localFiles.size());
|
||||
log("Файлов на сервере: " + serverFiles.size());
|
||||
log("Local files: " + localFiles.size());
|
||||
log("Server files: " + serverFiles.size());
|
||||
|
||||
// Сравниваем и скачиваем
|
||||
int downloaded = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
|
||||
String selfName = getSelfFileName();
|
||||
|
||||
for (Map.Entry<String, FileMeta> entry : serverFiles.entrySet()) {
|
||||
String filePath = entry.getKey();
|
||||
@@ -169,18 +309,44 @@ public class Bootstrap {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (localHash != null) {
|
||||
log("Обновление: " + filePath);
|
||||
} else {
|
||||
log("Скачивание: " + filePath);
|
||||
// Skip self-update (can't overwrite running executable)
|
||||
if (selfName != null && (filePath.equalsIgnoreCase(selfName) || filePath.endsWith("/" + selfName))) {
|
||||
log("Skipping self-update: " + filePath + " (file in use)");
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadFile(newVersion, filePath, serverMeta.size);
|
||||
downloaded++;
|
||||
if (localHash != null) {
|
||||
log("Updating: " + filePath);
|
||||
} else {
|
||||
log("Downloading: " + filePath);
|
||||
}
|
||||
|
||||
try {
|
||||
downloadFile(newVersion, filePath, serverMeta.size);
|
||||
downloaded++;
|
||||
} catch (Exception e) {
|
||||
log("Warning: Could not update " + filePath + " - " + e.getMessage());
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
log("Обновлено файлов: " + downloaded + ", пропущено: " + skipped);
|
||||
log("Обновлено до v" + newVersion);
|
||||
log("Updated files: " + downloaded + ", skipped: " + skipped + ", failed: " + failed);
|
||||
log("Updated to v" + newVersion);
|
||||
}
|
||||
|
||||
private static String getSelfFileName() {
|
||||
try {
|
||||
String classPath = Bootstrap.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
|
||||
if (classPath != null) {
|
||||
String fn = Paths.get(classPath).getFileName().toString();
|
||||
// If running from a JAR, the exe has the same stem
|
||||
if (fn.endsWith(".jar")) {
|
||||
return fn.replace(".jar", ".exe");
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, FileMeta> fetchServerMeta(String version) {
|
||||
@@ -210,7 +376,7 @@ public class Bootstrap {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Ошибка получения мета: " + e.getMessage());
|
||||
log("Error fetching meta: " + e.getMessage());
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -248,38 +414,83 @@ public class Bootstrap {
|
||||
}
|
||||
|
||||
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception {
|
||||
URL url = new URL(BASE_URL + "/launcher/file/" + version + "/" + filePath);
|
||||
List<String> servers = new ArrayList<>();
|
||||
if (isServerReachable(BASE_URL)) servers.add(BASE_URL);
|
||||
servers.addAll(MIRRORS);
|
||||
java.util.Collections.shuffle(servers);
|
||||
|
||||
Exception lastError = null;
|
||||
for (String server : servers) {
|
||||
try {
|
||||
downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
|
||||
downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
|
||||
}
|
||||
|
||||
private static void downloadFileFromServer(String urlStr, long expectedSize, String fileName) throws Exception {
|
||||
URL url = new URL(urlStr);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(60000);
|
||||
|
||||
if (conn.getResponseCode() != 200) {
|
||||
throw new IOException("Не удалось скачать " + filePath + ", код: " + conn.getResponseCode());
|
||||
throw new IOException("HTTP " + conn.getResponseCode());
|
||||
}
|
||||
|
||||
Path outPath = baseDir.resolve(filePath);
|
||||
if (expectedSize <= 0) {
|
||||
expectedSize = conn.getContentLengthLong();
|
||||
}
|
||||
|
||||
Path outPath = baseDir.resolve(fileName);
|
||||
Files.createDirectories(outPath.getParent());
|
||||
|
||||
long downloaded = 0;
|
||||
long lastUpdate = 0;
|
||||
long startTime = System.currentTimeMillis();
|
||||
setTitle("Downloading " + fileName);
|
||||
|
||||
try (InputStream in = conn.getInputStream();
|
||||
OutputStream out = new FileOutputStream(outPath.toFile())) {
|
||||
byte[] buf = new byte[8192];
|
||||
byte[] buf = new byte[65536];
|
||||
int len;
|
||||
while ((len = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, len);
|
||||
downloaded += len;
|
||||
|
||||
if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) {
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
|
||||
double downloadedMB = downloaded / 1024.0 / 1024.0;
|
||||
double totalMB = expectedSize / 1024.0 / 1024.0;
|
||||
|
||||
String progressStr = String.format("%.1f/%.1f MB (%.1f MB/s)", downloadedMB, totalMB, speed);
|
||||
log(progressStr);
|
||||
setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
|
||||
lastUpdate = downloaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем хеш
|
||||
String actualHash = calculateFileHash(outPath);
|
||||
String expectedHash = expectedSize > 0 ? "" : "";
|
||||
if (downloaded != expectedSize) {
|
||||
log("Предупреждение: размер " + filePath + " не совпадает");
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
|
||||
log(String.format("Downloaded %.1f MB (%.1f MB/s) - Done!", downloaded / 1024.0 / 1024.0, speed));
|
||||
setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
|
||||
}
|
||||
|
||||
private static String getProgressBar(long current, long total) {
|
||||
if (total <= 0) return "====";
|
||||
int filled = (int) ((current * 20) / total);
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
for (int i = 0; i < 20; i++) {
|
||||
sb.append(i < filled ? "=" : " ");
|
||||
}
|
||||
|
||||
// Выводим прогресс
|
||||
System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB");
|
||||
sb.append("]");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static class FileMeta {
|
||||
@@ -291,120 +502,214 @@ public class Bootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
private static void loadMirrors() {
|
||||
try {
|
||||
URL url = new URL(BASE_URL + "/launcher/mirrors");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
|
||||
if (conn.getResponseCode() == 200) {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
|
||||
com.google.gson.JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject();
|
||||
com.google.gson.JsonArray mirrorsArray = json.getAsJsonArray("mirrors");
|
||||
|
||||
for (com.google.gson.JsonElement elem : mirrorsArray) {
|
||||
com.google.gson.JsonObject mirror = elem.getAsJsonObject();
|
||||
String mirrorUrl = mirror.get("url").getAsString();
|
||||
if (!MIRRORS.contains(mirrorUrl)) {
|
||||
MIRRORS.add(mirrorUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Mirrors unavailable: " + e.getMessage());
|
||||
}
|
||||
|
||||
if (!Files.exists(javaBin)) {
|
||||
throw new RuntimeException("Java не найдена. Убедитесь, что jre21 присутствует в папке с лаунчером или Java установлена в системе");
|
||||
}
|
||||
|
||||
return javaBin;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isServerReachable(String serverUrl) {
|
||||
try {
|
||||
URL url = new URL(serverUrl + "/launcher/version");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(2000);
|
||||
conn.setReadTimeout(2000);
|
||||
return conn.getResponseCode() == 200;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== SWING UI ======================
|
||||
|
||||
private static class BootstrapUI {
|
||||
private final JFrame frame;
|
||||
private final JLabel statusLabel;
|
||||
private final JProgressBar progressBar;
|
||||
private final JLabel titleLabel;
|
||||
private final JLabel versionLabel;
|
||||
private final JLabel speedLabel;
|
||||
private final Color bgColor = new Color(0x0c, 0x0c, 0x12);
|
||||
private final Color surfaceColor = new Color(0x16, 0x16, 0x1f);
|
||||
private final Color accentColor = new Color(0xe9, 0x45, 0x60);
|
||||
private final Color textColor = new Color(0xee, 0xee, 0xf0);
|
||||
private final Color mutedColor = new Color(0x88, 0x88, 0x9a);
|
||||
|
||||
BootstrapUI() {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
frame = new JFrame("ZernMC Launcher");
|
||||
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||
frame.setSize(480, 280);
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.setResizable(false);
|
||||
frame.setBackground(bgColor);
|
||||
frame.setUndecorated(true);
|
||||
|
||||
JPanel root = new JPanel(new BorderLayout());
|
||||
root.setBackground(bgColor);
|
||||
root.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(new Color(0x2a, 0x2a, 0x3a), 1),
|
||||
BorderFactory.createEmptyBorder(20, 24, 20, 24)
|
||||
));
|
||||
|
||||
// Title bar
|
||||
JPanel titleBar = new JPanel(new BorderLayout());
|
||||
titleBar.setOpaque(false);
|
||||
|
||||
JLabel brandLabel = new JLabel("ZernMC Launcher");
|
||||
brandLabel.setFont(new Font("Segoe UI", Font.BOLD, 18));
|
||||
brandLabel.setForeground(textColor);
|
||||
|
||||
JPanel titleControls = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
|
||||
titleControls.setOpaque(false);
|
||||
JButton closeBtn = createTitleButton("\u2715");
|
||||
closeBtn.addActionListener(e -> System.exit(0));
|
||||
titleControls.add(closeBtn);
|
||||
|
||||
titleBar.add(brandLabel, BorderLayout.WEST);
|
||||
titleBar.add(titleControls, BorderLayout.EAST);
|
||||
root.add(titleBar, BorderLayout.NORTH);
|
||||
|
||||
// Center content
|
||||
JPanel center = new JPanel();
|
||||
center.setOpaque(false);
|
||||
center.setLayout(new BoxLayout(center, BoxLayout.Y_AXIS));
|
||||
center.add(Box.createVerticalStrut(16));
|
||||
|
||||
titleLabel = new JLabel("Initializing...");
|
||||
titleLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
|
||||
titleLabel.setForeground(mutedColor);
|
||||
titleLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
center.add(titleLabel);
|
||||
center.add(Box.createVerticalStrut(8));
|
||||
|
||||
versionLabel = new JLabel(" ");
|
||||
versionLabel.setFont(new Font("Segoe UI", Font.PLAIN, 12));
|
||||
versionLabel.setForeground(mutedColor);
|
||||
versionLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
center.add(versionLabel);
|
||||
center.add(Box.createVerticalStrut(16));
|
||||
|
||||
statusLabel = new JLabel("Starting...");
|
||||
statusLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
|
||||
statusLabel.setForeground(textColor);
|
||||
statusLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
center.add(statusLabel);
|
||||
center.add(Box.createVerticalStrut(12));
|
||||
|
||||
progressBar = new JProgressBar(0, 100);
|
||||
progressBar.setPreferredSize(new Dimension(400, 6));
|
||||
progressBar.setMaximumSize(new Dimension(400, 6));
|
||||
progressBar.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
progressBar.setBackground(new Color(0x2a, 0x2a, 0x3a));
|
||||
progressBar.setForeground(accentColor);
|
||||
progressBar.setBorderPainted(false);
|
||||
progressBar.setValue(0);
|
||||
progressBar.setUI(new BasicProgressBarUI() {
|
||||
protected Color getSelectionBackground() { return accentColor; }
|
||||
protected Color getSelectionForeground() { return accentColor; }
|
||||
});
|
||||
center.add(progressBar);
|
||||
center.add(Box.createVerticalStrut(6));
|
||||
|
||||
speedLabel = new JLabel(" ");
|
||||
speedLabel.setFont(new Font("Segoe UI", Font.PLAIN, 11));
|
||||
speedLabel.setForeground(mutedColor);
|
||||
speedLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||
center.add(speedLabel);
|
||||
|
||||
root.add(center, BorderLayout.CENTER);
|
||||
|
||||
// Draggable frame
|
||||
MouseAdapter dragAdapter = new MouseAdapter() {
|
||||
private int x, y;
|
||||
public void mousePressed(MouseEvent e) { x = e.getX(); y = e.getY(); }
|
||||
public void mouseDragged(MouseEvent e) {
|
||||
frame.setLocation(e.getXOnScreen() - x, e.getYOnScreen() - y);
|
||||
}
|
||||
};
|
||||
root.addMouseListener(dragAdapter);
|
||||
root.addMouseMotionListener(dragAdapter);
|
||||
|
||||
frame.setContentPane(root);
|
||||
}
|
||||
|
||||
private JButton createTitleButton(String text) {
|
||||
JButton btn = new JButton(text);
|
||||
btn.setFont(new Font("Segoe UI", Font.PLAIN, 14));
|
||||
btn.setForeground(mutedColor);
|
||||
btn.setBackground(bgColor);
|
||||
btn.setBorderPainted(false);
|
||||
btn.setFocusPainted(false);
|
||||
btn.setContentAreaFilled(false);
|
||||
btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
btn.addMouseListener(new MouseAdapter() {
|
||||
public void mouseEntered(MouseEvent e) { btn.setForeground(accentColor); }
|
||||
public void mouseExited(MouseEvent e) { btn.setForeground(mutedColor); }
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
void show() {
|
||||
frame.setVisible(true);
|
||||
frame.toFront();
|
||||
}
|
||||
|
||||
void close() {
|
||||
frame.dispose();
|
||||
}
|
||||
|
||||
void setStatus(final String text) {
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText(text));
|
||||
}
|
||||
|
||||
void setProgress(final int current, final int total) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
if (total > 0) {
|
||||
int pct = (int) ((long) current * 100 / total);
|
||||
progressBar.setValue(Math.min(pct, 100));
|
||||
speedLabel.setText(String.format("%.1f / %.1f MB",
|
||||
current / 1024.0 / 1024.0, total / 1024.0 / 1024.0));
|
||||
} else {
|
||||
progressBar.setIndeterminate(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setVersionInfo(final String local, final String server) {
|
||||
SwingUtilities.invokeLater(() ->
|
||||
versionLabel.setText("v" + local + " \u2192 v" + server));
|
||||
}
|
||||
|
||||
void setTitleText(final String text) {
|
||||
SwingUtilities.invokeLater(() -> titleLabel.setText(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-11
@@ -132,16 +132,17 @@
|
||||
<artifactId>launch4j-maven-plugin</artifactId>
|
||||
<version>2.5.0</version>
|
||||
<executions>
|
||||
<!-- GUI версия (основная) - без консоли -->
|
||||
<execution>
|
||||
<id>l4j</id>
|
||||
<id>l4j-gui</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>launch4j</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outfile>../../server/builds/zernmc-${project.version}.exe</outfile>
|
||||
<outfile>../../server/builds/zernmc.exe</outfile>
|
||||
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
|
||||
<headerType>console</headerType>
|
||||
<headerType>gui</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<jre>
|
||||
@@ -157,7 +158,38 @@
|
||||
<productName>ZernMC</productName>
|
||||
<companyName>ZernMC</companyName>
|
||||
<internalName>zernmc</internalName>
|
||||
<originalFilename>zernmc-${project.version}.exe</originalFilename>
|
||||
<originalFilename>zernmc.exe</originalFilename>
|
||||
</versionInfo>
|
||||
</configuration>
|
||||
</execution>
|
||||
|
||||
<!-- CLI версия - с консолью -->
|
||||
<execution>
|
||||
<id>l4j-cli</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>launch4j</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outfile>../../server/builds/zernmc-cli.exe</outfile>
|
||||
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
|
||||
<headerType>console</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||
<jre>
|
||||
<path>lib/jre21</path>
|
||||
<minVersion>21</minVersion>
|
||||
</jre>
|
||||
<versionInfo>
|
||||
<fileVersion>${project.version}.0</fileVersion>
|
||||
<txtFileVersion>${project.version}</txtFileVersion>
|
||||
<fileDescription>ZernMC Launcher CLI</fileDescription>
|
||||
<productVersion>${project.version}.0</productVersion>
|
||||
<txtProductVersion>${project.version}</txtProductVersion>
|
||||
<productName>ZernMC CLI</productName>
|
||||
<companyName>ZernMC</companyName>
|
||||
<internalName>zernmc-cli</internalName>
|
||||
<originalFilename>zernmc-cli.exe</originalFilename>
|
||||
</versionInfo>
|
||||
</configuration>
|
||||
</execution>
|
||||
@@ -219,10 +251,6 @@
|
||||
</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"
|
||||
@@ -236,11 +264,26 @@
|
||||
</fileset>
|
||||
</copy>
|
||||
|
||||
<!-- Создаём zip -->
|
||||
<!-- Создаём README -->
|
||||
<echo file="../../server/builds/README.txt">
|
||||
ZernMC Launcher
|
||||
|
||||
Files:
|
||||
- zernmc.exe - Main launcher with GUI (no console window)
|
||||
- zernmc-cli.exe - CLI version for servers/advanced users (with console)
|
||||
|
||||
How to use GUI:
|
||||
Just run zernmc.exe
|
||||
|
||||
How to use CLI:
|
||||
Run from command line: zernmc-cli.exe --cli
|
||||
</echo>
|
||||
|
||||
<!-- Создаём один архив со всем -->
|
||||
<zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
|
||||
basedir="../../server/builds"
|
||||
includes="zernmc.exe,bin/**,assets/**,lib/**"
|
||||
excludes="build.version,*-${project.version}.*,zernmclauncher.jar,zernmc-bootstrap.jar"/>
|
||||
includes="zernmc.exe,zernmc-cli.exe,bin/**,assets/**,lib/**,README.txt"
|
||||
excludes="build.version,*.jar"/>
|
||||
</target>
|
||||
</configuration>
|
||||
</execution>
|
||||
|
||||
@@ -3,9 +3,11 @@ package me.sashegdev.zernmc.launcher;
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@@ -24,26 +26,23 @@ public class Bootstrap {
|
||||
|
||||
log("=== ZernMC Launcher ===");
|
||||
|
||||
// Определяем режим запуска
|
||||
List<String> argList = Arrays.asList(args);
|
||||
boolean cliMode = argList.contains("--cli");
|
||||
boolean jfxMode = !cliMode; // по умолчанию JFX
|
||||
boolean jfxMode = !cliMode;
|
||||
|
||||
// Проверка и обновление лаунчера
|
||||
String currentVersion = readCurrentVersion();
|
||||
String serverVersion = getServerVersion();
|
||||
|
||||
log("Локальная версия: " + currentVersion);
|
||||
log("Версия на сервере: " + serverVersion);
|
||||
log("Local version: " + currentVersion);
|
||||
log("Server version: " + serverVersion);
|
||||
|
||||
if (isNewer(serverVersion, currentVersion)) {
|
||||
log("Доступно обновление!");
|
||||
log("Update available!");
|
||||
downloadUpdate(serverVersion);
|
||||
} else {
|
||||
log("Версия актуальна");
|
||||
log("Version is up to date");
|
||||
}
|
||||
|
||||
// Запуск в выбранном режиме
|
||||
if (jfxMode) {
|
||||
launchJFX();
|
||||
} else {
|
||||
@@ -70,7 +69,7 @@ public class Bootstrap {
|
||||
|
||||
private static String getServerVersion() {
|
||||
try {
|
||||
URL url = new URL(BASE_URL.replace("download?type=jar", "version"));
|
||||
URL url = new URL(BASE_URL + "/launcher/version");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
if (conn.getResponseCode() == 200) {
|
||||
@@ -117,10 +116,10 @@ public class Bootstrap {
|
||||
while ((len = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, len);
|
||||
total += len;
|
||||
System.out.print("\rСкачано: " + (total/1024/1024) + " MB");
|
||||
System.out.print("\rDownloaded: " + (total/1024/1024) + " MB");
|
||||
}
|
||||
}
|
||||
log("Скачано");
|
||||
log("Downloaded");
|
||||
|
||||
Path backup = jarFile.resolveSibling(JAR_NAME + ".old");
|
||||
|
||||
@@ -129,9 +128,9 @@ public class Bootstrap {
|
||||
if (Files.exists(backup)) Files.delete(backup);
|
||||
|
||||
Files.writeString(baseDir.resolve(VERSION_FILE), newVersion);
|
||||
log("Обновлено до v" + newVersion);
|
||||
log("Updated to v" + newVersion);
|
||||
} else {
|
||||
throw new IOException("Сервер вернул код: " + conn.getResponseCode());
|
||||
throw new IOException("Server returned code: " + conn.getResponseCode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,21 +138,43 @@ public class Bootstrap {
|
||||
Path javaBin = findJava();
|
||||
Path jarPath = baseDir.resolve(JAR_NAME);
|
||||
|
||||
log("Запуск JFX режима...");
|
||||
log("Starting JFX mode...");
|
||||
log("Java: " + javaBin);
|
||||
log("JAR: " + jarPath);
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
javaBin.toAbsolutePath().toString(),
|
||||
"-jar",
|
||||
jarPath.toAbsolutePath().toString(),
|
||||
"--jfx"
|
||||
);
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(javaBin.toAbsolutePath().toString());
|
||||
cmd.add("-Dfile.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stdout.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stderr.encoding=UTF-8");
|
||||
cmd.add("-jar");
|
||||
cmd.add(jarPath.toAbsolutePath().toString());
|
||||
cmd.add("--jfx");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.directory(baseDir.toFile());
|
||||
pb.inheritIO();
|
||||
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
|
||||
}
|
||||
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
|
||||
Thread outputThread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
System.out.println(line);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
outputThread.start();
|
||||
|
||||
int code = p.waitFor();
|
||||
log("Завершено с кодом: " + code);
|
||||
try { outputThread.interrupt(); } catch (Exception ignored) {}
|
||||
log("Exited with code: " + code);
|
||||
System.exit(code);
|
||||
}
|
||||
|
||||
@@ -161,21 +182,43 @@ public class Bootstrap {
|
||||
Path javaBin = findJava();
|
||||
Path jarPath = baseDir.resolve(JAR_NAME);
|
||||
|
||||
log("Запуск CLI режима...");
|
||||
log("Starting CLI mode...");
|
||||
log("Java: " + javaBin);
|
||||
log("JAR: " + jarPath);
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
javaBin.toAbsolutePath().toString(),
|
||||
"-jar",
|
||||
jarPath.toAbsolutePath().toString(),
|
||||
"--cli"
|
||||
);
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(javaBin.toAbsolutePath().toString());
|
||||
cmd.add("-Dfile.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stdout.encoding=UTF-8");
|
||||
cmd.add("-Dsun.stderr.encoding=UTF-8");
|
||||
cmd.add("-jar");
|
||||
cmd.add(jarPath.toAbsolutePath().toString());
|
||||
cmd.add("--cli");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.directory(baseDir.toFile());
|
||||
pb.inheritIO();
|
||||
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
|
||||
}
|
||||
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
|
||||
Thread outputThread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
System.out.println(line);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
outputThread.start();
|
||||
|
||||
int code = p.waitFor();
|
||||
log("Завершено с кодом: " + code);
|
||||
try { outputThread.interrupt(); } catch (Exception ignored) {}
|
||||
log("Exited with code: " + code);
|
||||
System.exit(code);
|
||||
}
|
||||
|
||||
@@ -183,15 +226,12 @@ public class Bootstrap {
|
||||
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();
|
||||
@@ -207,9 +247,9 @@ public class Bootstrap {
|
||||
}
|
||||
|
||||
if (!Files.exists(javaBin)) {
|
||||
throw new RuntimeException("Java не найдена. Убедитесь, что jre21 присутствует в папке с лаунчером или Java установлена в системе");
|
||||
throw new RuntimeException("Java not found. Make sure jre21 is present in the launcher folder or Java is installed on the system");
|
||||
}
|
||||
|
||||
return javaBin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,24 +15,24 @@ public class Main {
|
||||
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.stderr.encoding", "UTF-8");
|
||||
System.setProperty("sun.stdout.encoding", "UTF-8");
|
||||
|
||||
// Для Windows CMD - пытаемся переключить в UTF-8 режим
|
||||
try {
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
System.setProperty("java.stdout.encoding", "UTF-8");
|
||||
System.setProperty("java.stderr.encoding", "UTF-8");
|
||||
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
||||
|
||||
LauncherLogger.init();
|
||||
|
||||
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||
try {
|
||||
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
ZAnsi.install();
|
||||
System.out.print("\033[H\033[2J");
|
||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
|
||||
LauncherLogger.info("Starting ZernMC Launcher " + CURRENT_VERSION);
|
||||
|
||||
// Определяем режим запуска
|
||||
List<String> argList = List.of(args);
|
||||
boolean jfxMode = argList.contains("--jfx");
|
||||
boolean cliMode = argList.contains("--cli");
|
||||
@@ -42,23 +42,22 @@ public class Main {
|
||||
return;
|
||||
}
|
||||
|
||||
// CLI режим (по умолчанию или с --cli)
|
||||
System.out.print("\033[H\033[2J");
|
||||
System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
|
||||
|
||||
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
|
||||
System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage()));
|
||||
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
|
||||
System.err.println(ZAnsi.yellow("JavaFX недоступен. Возможно, отсутствуют нативные библиотеки."));
|
||||
System.err.println(ZAnsi.yellow("Попробуйте использовать CLI режим: --cli"));
|
||||
System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing."));
|
||||
System.err.println(ZAnsi.yellow("Try CLI mode: --cli"));
|
||||
}
|
||||
e.printStackTrace();
|
||||
System.exit(1);
|
||||
@@ -66,38 +65,36 @@ public class Main {
|
||||
}
|
||||
|
||||
private static void startCLI() throws IOException {
|
||||
// Проверка всех сервисов при старте
|
||||
ZHttpClient.checkAllServicesOnStartup();
|
||||
ZHttpClient.checkAllServicesOnStartup(true);
|
||||
|
||||
// === АВТОРИЗАЦИЯ (используем новый API) ===
|
||||
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
|
||||
System.out.println(ZAnsi.cyan("Checking authorization..."));
|
||||
var sessionResponse = api.checkSession();
|
||||
|
||||
if (!sessionResponse.isSuccess()) {
|
||||
LoginMenu loginMenu = new LoginMenu();
|
||||
boolean loggedIn = loginMenu.show();
|
||||
if (!loggedIn) {
|
||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||
System.out.println(ZAnsi.yellow("Goodbye!"));
|
||||
ZAnsi.uninstall();
|
||||
System.exit(0);
|
||||
}
|
||||
} else {
|
||||
var sessionInfo = sessionResponse.getData();
|
||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.getUsername() + "!"));
|
||||
System.out.println(ZAnsi.brightGreen("Welcome back, " + sessionInfo.getUsername() + "!"));
|
||||
}
|
||||
|
||||
// === ГЛАВНЫЙ ЦИКЛ ===
|
||||
System.out.println(ZAnsi.cyan("Starting CLI mode..."));
|
||||
|
||||
try {
|
||||
mainLoop();
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage()));
|
||||
System.err.println(ZAnsi.brightRed("Critical error: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
ZAnsi.uninstall();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
|
||||
private static void mainLoop() throws Exception {
|
||||
if (Config.isZernMCBuild()) {
|
||||
zernMCFlow();
|
||||
@@ -106,24 +103,21 @@ public class Main {
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== 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 серверу..."));
|
||||
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
|
||||
try {
|
||||
String response = ZHttpClient.get("/health");
|
||||
System.out.println(ZAnsi.brightGreen("✓ Сервер доступен"));
|
||||
System.out.println(ZAnsi.brightGreen("✓ Server is available"));
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу"));
|
||||
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.brightRed("✗ Could not connect to ZernMC server"));
|
||||
System.out.println(ZAnsi.white("Error: " + e.getMessage()));
|
||||
ConsoleUtils.pause();
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
// 2. Авторизация
|
||||
boolean sessionRestored = AuthManager.loadSavedSession();
|
||||
if (!sessionRestored) {
|
||||
LoginMenu loginMenu = new LoginMenu();
|
||||
@@ -132,42 +126,40 @@ public class Main {
|
||||
System.exit(0);
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
|
||||
System.out.println(ZAnsi.brightGreen("Welcome back, " + AuthManager.getUsername() + "!"));
|
||||
}
|
||||
|
||||
// 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow)
|
||||
LaunchMenu launchMenu = new LaunchMenu();
|
||||
launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри
|
||||
launchMenu.show();
|
||||
}
|
||||
|
||||
// ====================== GLOBAL FLOW ======================
|
||||
private static void globalFlow() throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
|
||||
|
||||
List<String> options = List.of(
|
||||
"Запустить игру",
|
||||
"Проверка обновлений",
|
||||
"Настройки",
|
||||
"Проверка подключения к серверам",
|
||||
"Выход"
|
||||
"Launch Game",
|
||||
"Check Updates",
|
||||
"Settings",
|
||||
"Server Connection Check",
|
||||
"Exit"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Главное меню", options);
|
||||
ArrowMenu menu = new ArrowMenu("Main Menu", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 4) {
|
||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||
System.out.println(ZAnsi.yellow("Goodbye!"));
|
||||
break;
|
||||
}
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
|
||||
case 0 -> new LaunchMenu().show();
|
||||
case 1 -> new UpdateMenu().show();
|
||||
case 2 -> new SettingsMenu().show();
|
||||
case 3 -> new ServerCheckMenu().show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ package me.sashegdev.zernmc.launcher.api;
|
||||
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
|
||||
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
||||
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Центральный фасад для внутреннего API лаунчера.
|
||||
* Используется как единая точка входа для UI и других компонентов.
|
||||
*/
|
||||
public class LauncherAPI {
|
||||
|
||||
private final AuthService authService;
|
||||
@@ -34,8 +34,6 @@ public class LauncherAPI {
|
||||
return launchService;
|
||||
}
|
||||
|
||||
// ====================== Удобные методы ======================
|
||||
|
||||
public boolean isLoggedIn() {
|
||||
return authService.isLoggedIn();
|
||||
}
|
||||
@@ -56,6 +54,14 @@ public class LauncherAPI {
|
||||
return authService.logout();
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> activatePass(String passCode) {
|
||||
return authService.activatePass(passCode);
|
||||
}
|
||||
|
||||
public ApiResponse<AuthService.LoginResult> register(String username, String password) {
|
||||
return authService.register(username, password);
|
||||
}
|
||||
|
||||
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
|
||||
return instanceService.getAllInstances();
|
||||
}
|
||||
@@ -71,4 +77,126 @@ public class LauncherAPI {
|
||||
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
|
||||
return launchService.launch(instanceName);
|
||||
}
|
||||
|
||||
public ApiResponse<List<String>> getMCVersions() {
|
||||
try {
|
||||
org.json.JSONObject manifest = ZHttpClient.getMojangVersionManifest();
|
||||
org.json.JSONArray versions = manifest.getJSONArray("versions");
|
||||
List<String> mcVersions = new ArrayList<>();
|
||||
for (int i = 0; i < versions.length(); i++) {
|
||||
mcVersions.add(versions.getJSONObject(i).getString("id"));
|
||||
}
|
||||
return ApiResponse.success(mcVersions);
|
||||
} catch (Exception e) {
|
||||
System.out.println("[API] MC versions fetch failed: " + e.getMessage());
|
||||
}
|
||||
return ApiResponse.error("Failed to load Minecraft versions");
|
||||
}
|
||||
|
||||
public ApiResponse<List<String>> getLoaderVersions(String mcVersion, String loader) {
|
||||
try {
|
||||
List<String> versions = new ArrayList<>();
|
||||
|
||||
switch (loader.toLowerCase()) {
|
||||
case "fabric":
|
||||
versions = ZHttpClient.getFabricLoaderVersions();
|
||||
break;
|
||||
case "forge":
|
||||
String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
|
||||
int idx = 0;
|
||||
while ((idx = xml.indexOf("<version>", idx)) != -1) {
|
||||
int start = idx + 9;
|
||||
int end = xml.indexOf("</version>", start);
|
||||
if (end == -1) break;
|
||||
String fullVersion = xml.substring(start, end).trim();
|
||||
if (fullVersion.startsWith(mcVersion + "-")) {
|
||||
versions.add(fullVersion.substring(mcVersion.length() + 1));
|
||||
}
|
||||
idx = end;
|
||||
}
|
||||
versions.sort(LauncherAPI::compareVersions);
|
||||
break;
|
||||
case "neoforge":
|
||||
String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
|
||||
int neoidx = 0;
|
||||
while ((neoidx = neoforgeXml.indexOf("<version>", neoidx)) != -1) {
|
||||
int start = neoidx + 9;
|
||||
int end = neoforgeXml.indexOf("</version>", start);
|
||||
if (end == -1) break;
|
||||
String fullVersion = neoforgeXml.substring(start, end).trim();
|
||||
if (isNeoForgeCompatible(fullVersion, mcVersion)) {
|
||||
versions.add(fullVersion);
|
||||
}
|
||||
neoidx = end;
|
||||
}
|
||||
versions.sort(LauncherAPI::compareVersions);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return ApiResponse.success(versions);
|
||||
} catch (Exception e) {
|
||||
System.out.println("[API] Loader versions fetch failed: " + e.getMessage());
|
||||
return ApiResponse.error("Failed to load loader versions");
|
||||
}
|
||||
}
|
||||
|
||||
private static int compareVersions(String a, String b) {
|
||||
String[] partsA = a.split("\\.");
|
||||
String[] partsB = b.split("\\.");
|
||||
int len = Math.min(partsA.length, partsB.length);
|
||||
for (int i = 0; i < len; i++) {
|
||||
try {
|
||||
int numA = Integer.parseInt(partsA[i]);
|
||||
int numB = Integer.parseInt(partsB[i]);
|
||||
if (numA != numB) return Integer.compare(numB, numA);
|
||||
} catch (NumberFormatException e) {
|
||||
int cmp = partsA[i].compareTo(partsB[i]);
|
||||
if (cmp != 0) return cmp;
|
||||
}
|
||||
}
|
||||
return Integer.compare(partsB.length, partsA.length);
|
||||
}
|
||||
|
||||
private boolean isNeoForgeCompatible(String version, String mcVersion) {
|
||||
if (mcVersion.startsWith("1.21")) {
|
||||
return version.contains("1.21") && !version.contains("1.20");
|
||||
} else if (mcVersion.startsWith("1.20") && !mcVersion.equals("1.20")) {
|
||||
return version.contains("1.20.4") || version.contains("1.20.5") || version.contains("1.20.6");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public ApiResponse<List<Map<String, String>>> getZernMCPacks() {
|
||||
try {
|
||||
String token = authService.getCurrentToken();
|
||||
if (token == null) {
|
||||
LauncherLogger.warn("getZernMCPacks: not logged in");
|
||||
return ApiResponse.error("Not logged in");
|
||||
}
|
||||
|
||||
String response = ZHttpClient.get("/packs");
|
||||
org.json.JSONObject root = new org.json.JSONObject(response);
|
||||
org.json.JSONArray arr = root.optJSONArray("packs");
|
||||
List<Map<String, String>> packs = new ArrayList<>();
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
org.json.JSONObject pack = arr.getJSONObject(i);
|
||||
Map<String, String> packInfo = new java.util.HashMap<>();
|
||||
packInfo.put("name", pack.optString("name", ""));
|
||||
packInfo.put("displayName", pack.optString("displayName", pack.optString("name", "")));
|
||||
packInfo.put("version", pack.optString("version", ""));
|
||||
packInfo.put("mcVersion", pack.optString("minecraft_version", ""));
|
||||
packInfo.put("loader", pack.optString("loader_type", "vanilla"));
|
||||
packInfo.put("description", pack.optString("description", ""));
|
||||
packs.add(packInfo);
|
||||
}
|
||||
}
|
||||
LauncherLogger.info("getZernMCPacks: loaded " + packs.size() + " packs");
|
||||
return ApiResponse.success(packs);
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("getZernMCPacks failed: " + e.getMessage());
|
||||
return ApiResponse.error("Failed to load packs: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-8
@@ -1,5 +1,7 @@
|
||||
package me.sashegdev.zernmc.launcher.api.auth;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
@@ -8,6 +10,29 @@ import java.io.IOException;
|
||||
|
||||
public class AuthService {
|
||||
|
||||
public ApiResponse<LoginResult> register(String username, String password) {
|
||||
try {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("username", username);
|
||||
json.addProperty("password", password);
|
||||
String response = post("/auth/register", json.toString());
|
||||
|
||||
// If registration succeeds, auto-login
|
||||
AuthManager.AuthResult result = AuthManager.login(username, password);
|
||||
if (result.success) {
|
||||
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
|
||||
return ApiResponse.success(loginResult);
|
||||
}
|
||||
return ApiResponse.error(result.error != null ? result.error : "Registration failed");
|
||||
} catch (Exception e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && msg.contains("HTTP 409")) {
|
||||
return ApiResponse.error("Username already taken");
|
||||
}
|
||||
return ApiResponse.error("Registration error: " + msg);
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<LoginResult> login(String username, String password) {
|
||||
try {
|
||||
AuthManager.AuthResult result = AuthManager.login(username, password);
|
||||
@@ -15,9 +40,9 @@ public class AuthService {
|
||||
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
|
||||
return ApiResponse.success(loginResult);
|
||||
}
|
||||
return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
|
||||
return ApiResponse.error(result.error != null ? result.error : "Invalid login or password");
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка авторизации: " + e.getMessage());
|
||||
return ApiResponse.error("Auth error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +51,7 @@ public class AuthService {
|
||||
AuthManager.logout();
|
||||
return ApiResponse.success(true);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка при выходе: " + e.getMessage());
|
||||
return ApiResponse.error("Logout error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,19 +68,21 @@ public class AuthService {
|
||||
);
|
||||
return ApiResponse.success(info);
|
||||
}
|
||||
return ApiResponse.error("Сессия не найдена");
|
||||
return ApiResponse.error("Session not found");
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка проверки сессии: " + e.getMessage());
|
||||
return ApiResponse.error("Session check error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> activatePass(String passCode) {
|
||||
try {
|
||||
String response = post("/auth/pass/activate",
|
||||
"{\"code\":\"" + passCode + "\"}");
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("pass_code", passCode);
|
||||
String response = post("/auth/pass/activate", json.toString());
|
||||
AuthManager.refreshUserInfo();
|
||||
return ApiResponse.success(true);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка активации проходки: " + e.getMessage());
|
||||
return ApiResponse.error("Pass activation error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +132,10 @@ public class AuthService {
|
||||
return AuthManager.getUsername();
|
||||
}
|
||||
|
||||
public String getCurrentToken() {
|
||||
return AuthManager.getAccessToken();
|
||||
}
|
||||
|
||||
public static class LoginResult {
|
||||
private String username;
|
||||
private String token;
|
||||
|
||||
+34
-10
@@ -18,7 +18,7 @@ public class InstanceService {
|
||||
.collect(Collectors.toList());
|
||||
return ApiResponse.success(infoList);
|
||||
} catch (IOException e) {
|
||||
return ApiResponse.error("Ошибка получения списка сборок: " + e.getMessage());
|
||||
return ApiResponse.error("Error getting instances list: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ public class InstanceService {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Сборка не найдена: " + name);
|
||||
return ApiResponse.error("Pack not found: " + name);
|
||||
}
|
||||
return ApiResponse.success(toInstanceInfo(instance));
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка получения сборки: " + e.getMessage());
|
||||
return ApiResponse.error("Error getting pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ public class InstanceService {
|
||||
try {
|
||||
boolean created = InstanceManager.createInstanceFolder(name);
|
||||
if (!created) {
|
||||
return ApiResponse.error("Сборка с таким именем уже существует: " + name);
|
||||
return ApiResponse.error("A pack with this name already exists: " + name);
|
||||
}
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
return ApiResponse.success(toInstanceInfo(instance));
|
||||
} catch (IOException e) {
|
||||
return ApiResponse.error("Ошибка создания сборки: " + e.getMessage());
|
||||
return ApiResponse.error("Error creating pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,11 @@ public class InstanceService {
|
||||
try {
|
||||
boolean deleted = InstanceManager.deleteInstance(name);
|
||||
if (!deleted) {
|
||||
return ApiResponse.error("Не удалось удалить сборку: " + name);
|
||||
return ApiResponse.error("Failed to delete pack: " + name);
|
||||
}
|
||||
return ApiResponse.success(true);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка удаления сборки: " + e.getMessage());
|
||||
return ApiResponse.error("Error deleting pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,16 +64,24 @@ public class InstanceService {
|
||||
Instance instance = InstanceManager.getInstance(name);
|
||||
return ApiResponse.success(instance != null);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка проверки сборки: " + e.getMessage());
|
||||
return ApiResponse.error("Error checking pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private InstanceInfo toInstanceInfo(Instance instance) {
|
||||
String name = instance.getName().toLowerCase();
|
||||
String category = instance.isServerPack() ? "zernmc" : "local";
|
||||
|
||||
return new InstanceInfo(
|
||||
instance.getName(),
|
||||
instance.getPath().toString(),
|
||||
instance.getMinecraftVersion(),
|
||||
instance.getLoaderType()
|
||||
instance.getLoaderType(),
|
||||
category,
|
||||
instance.isServerPack(),
|
||||
instance.getServerVersion(),
|
||||
instance.getLoaderVersion(),
|
||||
instance.getServerPackName()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,17 +90,33 @@ public class InstanceService {
|
||||
private String path;
|
||||
private String version;
|
||||
private String loaderType;
|
||||
private String category;
|
||||
private boolean isServerPack;
|
||||
private int serverVersion;
|
||||
private String loaderVersion;
|
||||
private String serverPackName;
|
||||
|
||||
public InstanceInfo(String name, String path, String version, String loaderType) {
|
||||
public InstanceInfo(String name, String path, String version, String loaderType, String category,
|
||||
boolean isServerPack, int serverVersion, String loaderVersion, String serverPackName) {
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
this.version = version;
|
||||
this.loaderType = loaderType;
|
||||
this.category = category;
|
||||
this.isServerPack = isServerPack;
|
||||
this.serverVersion = serverVersion;
|
||||
this.loaderVersion = loaderVersion;
|
||||
this.serverPackName = serverPackName;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public String getPath() { return path; }
|
||||
public String getVersion() { return version; }
|
||||
public String getLoaderType() { return loaderType; }
|
||||
public String getCategory() { return category; }
|
||||
public boolean isServerPack() { return isServerPack; }
|
||||
public int getServerVersion() { return serverVersion; }
|
||||
public String getLoaderVersion() { return loaderVersion; }
|
||||
public String getServerPackName() { return serverPackName; }
|
||||
}
|
||||
}
|
||||
|
||||
+92
-43
@@ -7,24 +7,42 @@ import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class LaunchService {
|
||||
|
||||
private static final ConcurrentHashMap<Long, Process> runningProcesses = new ConcurrentHashMap<>();
|
||||
|
||||
static {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
System.out.println("[LAUNCH] Shutting down all running processes...");
|
||||
runningProcesses.values().forEach(p -> {
|
||||
try {
|
||||
p.destroy();
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(instanceName);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||
return ApiResponse.error("Pack not found: " + instanceName);
|
||||
}
|
||||
|
||||
LauncherLogger.info("Preparing launch for: " + instanceName);
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
LaunchOptions options = createOptions();
|
||||
|
||||
List<String> command = builder.build(options);
|
||||
|
||||
@@ -35,7 +53,8 @@ public class LaunchService {
|
||||
);
|
||||
return ApiResponse.success(info);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка подготовки запуска: " + e.getMessage());
|
||||
LauncherLogger.error("Error preparing launch for " + instanceName, e);
|
||||
return ApiResponse.error("Error preparing launch: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,78 +62,84 @@ public class LaunchService {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(instanceName);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||
return ApiResponse.error("Pack not found: " + instanceName);
|
||||
}
|
||||
|
||||
JFXLauncher.initGameLog(instance.getPath());
|
||||
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
|
||||
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
|
||||
// Set auth info
|
||||
LaunchOptions options = createOptions();
|
||||
options.setUsername(AuthManager.getUsername());
|
||||
options.setAccessToken(AuthManager.getAccessToken());
|
||||
options.setUuid(AuthManager.getUuid());
|
||||
|
||||
List<String> command = builder.build(options);
|
||||
System.out.println("[LAUNCH] Generated command for " + instanceName + ":");
|
||||
command.forEach(arg -> System.out.println(" " + arg));
|
||||
LauncherLogger.info("Generated command for " + instanceName + ":");
|
||||
command.forEach(arg -> LauncherLogger.debug(" " + arg));
|
||||
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||
processBuilder.directory(instance.getPath().toFile());
|
||||
processBuilder.redirectErrorStream(true);
|
||||
|
||||
Path logsDir = instance.getPath().resolve("logs");
|
||||
java.nio.file.Files.createDirectories(logsDir);
|
||||
Path gameLog = logsDir.resolve("game.log");
|
||||
|
||||
Process process = processBuilder.start();
|
||||
System.out.println("[LAUNCH] Process started, pid=" + process.pid());
|
||||
long pid = process.pid();
|
||||
|
||||
runningProcesses.put(pid, process);
|
||||
LauncherLogger.info("Process started, pid=" + pid);
|
||||
|
||||
// Capture output (stdout)
|
||||
Thread outThread = new Thread(() -> {
|
||||
java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true);
|
||||
|
||||
Thread logReader = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
System.out.println("[STDOUT] " + line);
|
||||
String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line;
|
||||
JFXLauncher.appendGameLog(line);
|
||||
try {
|
||||
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
logFileOut.flush();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[STDOUT ERROR] " + e.getMessage());
|
||||
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage());
|
||||
JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
|
||||
} finally {
|
||||
try { logFileOut.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
});
|
||||
outThread.setDaemon(true);
|
||||
outThread.start();
|
||||
}, "GameLogReader-" + instanceName);
|
||||
logReader.setDaemon(true);
|
||||
logReader.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());
|
||||
}
|
||||
process.onExit().thenRun(() -> {
|
||||
runningProcesses.remove(pid);
|
||||
JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
|
||||
});
|
||||
errThread.setDaemon(true);
|
||||
errThread.start();
|
||||
|
||||
ProcessInfo info = new ProcessInfo(
|
||||
instanceName,
|
||||
process.pid(),
|
||||
"RUNNING"
|
||||
);
|
||||
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
|
||||
return ApiResponse.success(info);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка запуска: " + e.getMessage());
|
||||
LauncherLogger.error("Launch error for " + instanceName, e);
|
||||
return ApiResponse.error("Launch error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void killAllProcesses() {
|
||||
runningProcesses.values().forEach(p -> {
|
||||
try {
|
||||
p.destroyForcibly();
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
runningProcesses.clear();
|
||||
}
|
||||
|
||||
public ApiResponse<Boolean> isReady(String instanceName) {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(instanceName);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||
return ApiResponse.error("Pack not found: " + instanceName);
|
||||
}
|
||||
|
||||
Path versionJson = instance.getPath().resolve("version.json");
|
||||
@@ -122,7 +147,7 @@ public class LaunchService {
|
||||
|
||||
return ApiResponse.success(hasVersionJson);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка проверки готовности: " + e.getMessage());
|
||||
return ApiResponse.error("Readiness check error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +155,7 @@ public class LaunchService {
|
||||
try {
|
||||
Instance instance = InstanceManager.getInstance(instanceName);
|
||||
if (instance == null) {
|
||||
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||
return ApiResponse.error("Pack not found: " + instanceName);
|
||||
}
|
||||
|
||||
InstanceInfo info = new InstanceInfo(
|
||||
@@ -142,10 +167,34 @@ public class LaunchService {
|
||||
);
|
||||
return ApiResponse.success(info);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error("Ошибка получения информации: " + e.getMessage());
|
||||
return ApiResponse.error("Info retrieval error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static LaunchOptions createOptions() {
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
options.setMaxMemory(Config.getMaxMemory());
|
||||
options.setWidth(Config.getWindowWidth());
|
||||
options.setHeight(Config.getWindowHeight());
|
||||
options.setJavaPath(Config.getJavaPath());
|
||||
List<String> extraArgs = new ArrayList<>();
|
||||
if (Config.isSystemBasedJvm()) {
|
||||
String[] systemFlags = Config.getSystemJvmFlags().split("\\s+");
|
||||
for (String arg : systemFlags) {
|
||||
if (!arg.isEmpty()) extraArgs.add(arg);
|
||||
}
|
||||
}
|
||||
String args = Config.getExtraJvmArgs();
|
||||
if (args != null && !args.isEmpty()) {
|
||||
for (String arg : args.split("\n")) {
|
||||
arg = arg.trim();
|
||||
if (!arg.isEmpty()) extraArgs.add(arg);
|
||||
}
|
||||
}
|
||||
options.setExtraJvmArgs(extraArgs);
|
||||
return options;
|
||||
}
|
||||
|
||||
public static class LaunchInfo {
|
||||
private String instanceName;
|
||||
private List<String> command;
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
@@ -26,37 +27,67 @@ public class AuthManager {
|
||||
private static volatile AuthSession session = null;
|
||||
private static volatile UserInfo userInfo = null;
|
||||
|
||||
// === Роли ===
|
||||
public static final int ROLE_USER = 0;
|
||||
public static final int ROLE_PASS_HOLDER = 1;
|
||||
public static final int ROLE_MODERATOR = 2;
|
||||
public static final int ROLE_ELDER = 3;
|
||||
public static final int ROLE_CREATOR = 4;
|
||||
|
||||
// === Права доступа ===
|
||||
public static final String PERM_VIEW_PACKS = "view_packs";
|
||||
public static final String PERM_DOWNLOAD_PACK = "download_pack";
|
||||
|
||||
public static boolean loadSavedSession() {
|
||||
if (!Files.exists(AUTH_FILE)) return false;
|
||||
if (!Files.exists(AUTH_FILE)) {
|
||||
LauncherLogger.warn("loadSavedSession: auth.json not found at " + AUTH_FILE);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
String json = Files.readString(AUTH_FILE);
|
||||
AuthSession loaded = GSON.fromJson(json, AuthSession.class);
|
||||
if (loaded == null || loaded.accessToken == null) return false;
|
||||
if (loaded == null || loaded.accessToken == null) {
|
||||
LauncherLogger.warn("loadSavedSession: invalid auth.json content, deleting");
|
||||
Files.deleteIfExists(AUTH_FILE);
|
||||
return false;
|
||||
}
|
||||
|
||||
session = loaded;
|
||||
userInfo = fetchUserInfo();
|
||||
LauncherLogger.info("loadSavedSession: loaded session for " + loaded.username
|
||||
+ " expiresAt=" + loaded.expiresAt + " hasRefresh=" + (loaded.refreshToken != null));
|
||||
|
||||
refreshUserInfo();
|
||||
|
||||
if (isAccessTokenExpired()) {
|
||||
return tryRefresh();
|
||||
LauncherLogger.info("loadSavedSession: token expired, attempting refresh");
|
||||
boolean refreshed = tryRefresh();
|
||||
if (!refreshed) {
|
||||
if (session == null) {
|
||||
LauncherLogger.warn("loadSavedSession: token rejected by server (401)");
|
||||
return false;
|
||||
}
|
||||
LauncherLogger.warn("loadSavedSession: refresh failed (network/no refreshToken),"
|
||||
+ " keeping session for retry on next launch");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (session == null) {
|
||||
LauncherLogger.warn("loadSavedSession: session invalidated during token refresh");
|
||||
return false;
|
||||
}
|
||||
LauncherLogger.info("loadSavedSession: session valid for " + session.username);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("loadSavedSession error: " + e.getMessage());
|
||||
invalidateSession();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== АВТОРИЗАЦИЯ ======================
|
||||
public static boolean tryAutoLogin() {
|
||||
if (isLoggedIn()) return true;
|
||||
if (!Files.exists(AUTH_FILE)) return false;
|
||||
return loadSavedSession();
|
||||
}
|
||||
|
||||
public static AuthResult login(String username, String password) {
|
||||
return authRequest("/auth/login", username, password);
|
||||
}
|
||||
@@ -73,49 +104,70 @@ public class AuthManager {
|
||||
if (resp.statusCode() == 200) {
|
||||
session = GSON.fromJson(resp.body(), AuthSession.class);
|
||||
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
|
||||
LauncherLogger.info("authRequest: login successful, expiresAt=" + session.expiresAt
|
||||
+ " hasRefresh=" + (session.refreshToken != null));
|
||||
saveSession();
|
||||
userInfo = fetchUserInfo();
|
||||
return AuthResult.ok();
|
||||
} else if (resp.statusCode() == 422) {
|
||||
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
|
||||
return AuthResult.fail("Validation error: " + extractError(resp.body()));
|
||||
} else {
|
||||
return AuthResult.fail(extractError(resp.body()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
|
||||
return AuthResult.fail("Connection error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void logout() {
|
||||
if (session != null && session.refreshToken != null) {
|
||||
try {
|
||||
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
|
||||
} catch (Exception ignored) {}
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("refresh_token", session.refreshToken);
|
||||
post("/auth/logout", json.toString());
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Logout error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
session = null;
|
||||
userInfo = null;
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception e) {
|
||||
LauncherLogger.warn("Failed to delete auth.json: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isLoggedIn() {
|
||||
return session != null && session.accessToken != null;
|
||||
}
|
||||
|
||||
public static boolean authFileExists() {
|
||||
return Files.exists(AUTH_FILE);
|
||||
}
|
||||
|
||||
public static String getUsername() {
|
||||
return session != null ? session.username : "Player";
|
||||
AuthSession localSession = session;
|
||||
return localSession != null ? localSession.username : "Player";
|
||||
}
|
||||
|
||||
public static String getUuid() {
|
||||
return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000";
|
||||
AuthSession localSession = session;
|
||||
return localSession != null ? localSession.uuid : "00000000-0000-0000-0000-000000000000";
|
||||
}
|
||||
|
||||
public static String getAccessToken() {
|
||||
if (session == null) return "0";
|
||||
AuthSession localSession = session;
|
||||
if (localSession == null) return "0";
|
||||
if (isAccessTokenExpired()) {
|
||||
tryRefresh();
|
||||
boolean refreshed = tryRefresh();
|
||||
if (!refreshed) {
|
||||
localSession = session;
|
||||
if (localSession == null) return "0";
|
||||
return localSession.accessToken != null ? localSession.accessToken : "0";
|
||||
}
|
||||
}
|
||||
return session != null && session.accessToken != null ? session.accessToken : "0";
|
||||
localSession = session;
|
||||
return localSession != null && localSession.accessToken != null ? localSession.accessToken : "0";
|
||||
}
|
||||
|
||||
private static boolean isAccessTokenExpired() {
|
||||
@@ -124,41 +176,70 @@ public class AuthManager {
|
||||
}
|
||||
|
||||
private static boolean tryRefresh() {
|
||||
if (session == null || session.refreshToken == null) return false;
|
||||
if (session == null) {
|
||||
LauncherLogger.warn("tryRefresh: session is null");
|
||||
return false;
|
||||
}
|
||||
if (session.refreshToken == null) {
|
||||
LauncherLogger.warn("tryRefresh: no refreshToken in session");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
|
||||
SimpleHttpResponse resp = post("/auth/refresh", body);
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("refresh_token", session.refreshToken);
|
||||
SimpleHttpResponse resp = post("/auth/refresh", json.toString());
|
||||
|
||||
if (resp.statusCode() == 200) {
|
||||
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
|
||||
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
|
||||
session = newSession;
|
||||
userInfo = fetchUserInfo();
|
||||
if (userInfo != null) {
|
||||
session.role = userInfo.role;
|
||||
}
|
||||
saveSession();
|
||||
LauncherLogger.info("tryRefresh: token refreshed successfully");
|
||||
return true;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (resp.statusCode() == 401) {
|
||||
LauncherLogger.warn("tryRefresh: server rejected refresh token (401)");
|
||||
invalidateSession();
|
||||
} else {
|
||||
LauncherLogger.warn("tryRefresh: server returned " + resp.statusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("tryRefresh: network error: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void invalidateSession() {
|
||||
session = null;
|
||||
userInfo = null;
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||
return false;
|
||||
try {
|
||||
Files.deleteIfExists(AUTH_FILE);
|
||||
LauncherLogger.info("Session invalidated, auth.json deleted");
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.error("Failed to delete auth.json", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveSession() {
|
||||
try {
|
||||
Files.createDirectories(AUTH_FILE.getParent());
|
||||
Files.writeString(AUTH_FILE, GSON.toJson(session));
|
||||
LauncherLogger.info("Session saved to " + AUTH_FILE);
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage()));
|
||||
LauncherLogger.error("Failed to save session", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ПОЛУЧЕНИЕ ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ====================
|
||||
private static UserInfo fetchUserInfo() {
|
||||
if (!isLoggedIn() || session.accessToken == null) return null;
|
||||
|
||||
try {
|
||||
// Используем существующий метод ZHttpClient.get() + вручную добавляем токен
|
||||
java.net.HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me");
|
||||
@@ -185,29 +266,39 @@ public class AuthManager {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("Не удалось получить UserInfo: " + e.getMessage());
|
||||
LauncherLogger.warn("Failed to get UserInfo: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ПРОВЕРКИ ПРАВ ====================
|
||||
public static boolean hasPass() {
|
||||
if (!isLoggedIn()) return false;
|
||||
if (userInfo != null) return userInfo.has_pass;
|
||||
return getRole() >= ROLE_PASS_HOLDER;
|
||||
if (getRole() >= ROLE_PASS_HOLDER) return true;
|
||||
try {
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
if (json.has("has_active")) {
|
||||
return json.get("has_active").getAsBoolean();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LauncherLogger.warn("Failed to check pass: " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean canViewPacks() {
|
||||
if (userInfo != null && userInfo.permissions != null) {
|
||||
return userInfo.permissions.contains(PERM_VIEW_PACKS);
|
||||
}
|
||||
return hasPass(); // fallback для старых аккаунтов
|
||||
return hasPass();
|
||||
}
|
||||
|
||||
public static boolean canDownloadPacks() {
|
||||
if (userInfo != null && userInfo.permissions != null) {
|
||||
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
|
||||
}
|
||||
return hasPass(); // fallback
|
||||
return hasPass();
|
||||
}
|
||||
|
||||
public static int getRole() {
|
||||
@@ -221,7 +312,6 @@ public class AuthManager {
|
||||
return "USER";
|
||||
}
|
||||
|
||||
// ====================== POST ======================
|
||||
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
|
||||
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
||||
HttpURLConnection conn = null;
|
||||
@@ -284,36 +374,49 @@ public class AuthManager {
|
||||
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||
}
|
||||
|
||||
public static void updateRole(int newRole) {
|
||||
if (session != null) {
|
||||
session.role = newRole;
|
||||
saveSession();
|
||||
}
|
||||
refreshUserInfo();
|
||||
}
|
||||
|
||||
public static void refreshUserInfo() {
|
||||
UserInfo fresh = fetchUserInfo();
|
||||
if (fresh != null) {
|
||||
userInfo = fresh;
|
||||
if (session != null) {
|
||||
session.role = fresh.role;
|
||||
}
|
||||
}
|
||||
if (session != null) {
|
||||
saveSession();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasActivePass() {
|
||||
if (!isLoggedIn()) return false;
|
||||
try {
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
return json.has("has_active") && json.get("has_active").getAsBoolean();
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.red("Не удалось проверить проходки: ") + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
return hasPass();
|
||||
}
|
||||
|
||||
public static String getPassStatus() {
|
||||
if (!isLoggedIn()) return "Не авторизован";
|
||||
if (!isLoggedIn()) return "Not logged in";
|
||||
try {
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean();
|
||||
return hasActive ? "Есть активная проходка" : "Проходка отсутствует";
|
||||
return hasActive ? "Active pass" : "No pass";
|
||||
} catch (Exception e) {
|
||||
return "Ошибка проверки";
|
||||
return "Check error";
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
|
||||
public static class AuthSession {
|
||||
@SerializedName("access_token") public String accessToken;
|
||||
@SerializedName("refresh_token") public String refreshToken;
|
||||
@SerializedName("expires_in") public int expiresIn;
|
||||
public transient long expiresAt;
|
||||
public long expiresAt;
|
||||
public String username;
|
||||
public String uuid;
|
||||
public int role;
|
||||
@@ -349,9 +452,22 @@ public class AuthManager {
|
||||
public static AuthResult ok() { return new AuthResult(true, null); }
|
||||
public static AuthResult fail(String msg) { return new AuthResult(false, msg); }
|
||||
}
|
||||
|
||||
// === TEST HELPERS ===
|
||||
static void resetForTest() {
|
||||
session = null;
|
||||
userInfo = null;
|
||||
}
|
||||
|
||||
static void setTestSession(AuthSession s) {
|
||||
session = s;
|
||||
}
|
||||
|
||||
static void setTestUserInfo(UserInfo u) {
|
||||
userInfo = u;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ======================
|
||||
class SimpleHttpResponse {
|
||||
final int statusCode;
|
||||
final String body;
|
||||
@@ -363,4 +479,4 @@ class SimpleHttpResponse {
|
||||
|
||||
int statusCode() { return statusCode; }
|
||||
String body() { return body; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,11 @@ public class LaunchMenu {
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ZERNMC BUILD ======================
|
||||
private void showZernMCOnly() throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
|
||||
System.out.println(ZAnsi.cyan("Доступны только серверные сборки"));
|
||||
System.out.println(ZAnsi.cyan("Server packs only"));
|
||||
|
||||
if (!awaitActivePass()) {
|
||||
return;
|
||||
@@ -48,13 +47,13 @@ public class LaunchMenu {
|
||||
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
|
||||
|
||||
if (availablePacks.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("На данный момент нет доступных сборок на сервере."));
|
||||
System.out.println(ZAnsi.yellow("No packs available on the server."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> options = availablePacks.stream()
|
||||
.map(p -> String.format("%s [%s + %s v%d] — %d файлов",
|
||||
.map(p -> String.format("%s [%s + %s v%d] - %d files",
|
||||
p.getName(),
|
||||
p.getMinecraftVersion(),
|
||||
p.getLoaderType(),
|
||||
@@ -62,9 +61,9 @@ public class LaunchMenu {
|
||||
p.getFilesCount()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
options.add("Назад в главное меню");
|
||||
options.add("Back to main menu");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выберите сборку", options);
|
||||
ArrowMenu menu = new ArrowMenu("Select a pack", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
@@ -76,25 +75,25 @@ public class LaunchMenu {
|
||||
|
||||
private boolean awaitActivePass() throws Exception {
|
||||
if (AuthManager.hasActivePass()) {
|
||||
System.out.println(ZAnsi.brightGreen("✓ Активная проходка подтверждена"));
|
||||
System.out.println(ZAnsi.brightGreen("✓ Active pass confirmed"));
|
||||
return true;
|
||||
}
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
|
||||
System.out.println(ZAnsi.white("Для доступа к сборкам ZernMC требуется активная проходка."));
|
||||
System.out.println(ZAnsi.brightRed("You don't have an active pass!"));
|
||||
System.out.println(ZAnsi.white("Access to ZernMC packs requires an active pass."));
|
||||
System.out.println();
|
||||
|
||||
openActivationWebsite();
|
||||
|
||||
System.out.println(ZAnsi.cyan("Ожидаем активацию проходки... (проверка каждые 10 секунд)"));
|
||||
System.out.println(ZAnsi.white("Нажмите Enter для отмены"));
|
||||
System.out.println(ZAnsi.cyan("Waiting for pass activation... (checking every 10 seconds)"));
|
||||
System.out.println(ZAnsi.white("Press Enter to cancel"));
|
||||
|
||||
for (int i = 0; i < 60; i++) {
|
||||
try {
|
||||
if (System.in.available() > 0) {
|
||||
Input.readLine();
|
||||
System.out.println(ZAnsi.yellow("\nОжидание отменено."));
|
||||
System.out.println(ZAnsi.yellow("\nWaiting cancelled."));
|
||||
return false;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
@@ -102,7 +101,7 @@ public class LaunchMenu {
|
||||
Thread.sleep(10000);
|
||||
|
||||
if (AuthManager.hasActivePass()) {
|
||||
System.out.println(ZAnsi.brightGreen("\n✓ Проходка успешно активирована!"));
|
||||
System.out.println(ZAnsi.brightGreen("\n✓ Pass activated successfully!"));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -110,43 +109,42 @@ public class LaunchMenu {
|
||||
if ((i + 1) % 6 == 0) System.out.println();
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightRed("\n\nВремя ожидания истекло."));
|
||||
System.out.println(ZAnsi.brightRed("\n\nWaiting time expired."));
|
||||
return false;
|
||||
}
|
||||
|
||||
private void openActivationWebsite() {
|
||||
//String url = "https://launcher.ru.zernmc.ru/activate-pass";
|
||||
String url = ZHttpClient.getBaseUrl() + "/activate-pass";
|
||||
|
||||
try {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(new URI(url));
|
||||
System.out.println(ZAnsi.cyan("Браузер открыт: " + url));
|
||||
System.out.println(ZAnsi.cyan("Browser opened: " + url));
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически."));
|
||||
System.out.println(ZAnsi.white("Откройте вручную: " + url));
|
||||
System.out.println(ZAnsi.yellow("Could not open browser automatically."));
|
||||
System.out.println(ZAnsi.white("Open manually: " + url));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка открытия браузера: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.white("Ссылка: " + url));
|
||||
System.out.println(ZAnsi.brightRed("Error opening browser: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.white("Link: " + url));
|
||||
}
|
||||
}
|
||||
|
||||
private void installAndRunServerPack(ServerPack selected) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
|
||||
System.out.println(ZAnsi.header("Installing pack: " + selected.getName()));
|
||||
|
||||
System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion());
|
||||
System.out.println(ZAnsi.white(" Лоадер: ") + selected.getLoaderType() +
|
||||
System.out.println(ZAnsi.white(" Loader: ") + selected.getLoaderType() +
|
||||
(selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : ""));
|
||||
System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion());
|
||||
System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount());
|
||||
System.out.println(ZAnsi.white(" Version: v") + selected.getVersion());
|
||||
System.out.println(ZAnsi.white(" Files: ") + selected.getFilesCount());
|
||||
|
||||
String localName = askPackName();
|
||||
if (localName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(localName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
@@ -158,18 +156,17 @@ public class LaunchMenu {
|
||||
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
|
||||
|
||||
if (!success) {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
|
||||
ConsoleUtils.pause();
|
||||
|
||||
launchExistingInstance(newInstance);
|
||||
}
|
||||
|
||||
// ====================== GLOBAL BUILD ======================
|
||||
private void showGlobal() throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
@@ -179,10 +176,10 @@ public class LaunchMenu {
|
||||
.map(Instance::toString)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
options.add("Установить новую сборку");
|
||||
options.add("Назад в главное меню");
|
||||
options.add("Install new pack");
|
||||
options.add("Back to main menu");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Управление сборками", options);
|
||||
ArrowMenu menu = new ArrowMenu("Manage packs", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) break;
|
||||
@@ -201,13 +198,13 @@ public class LaunchMenu {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Установить сборку с сервера ZernMC",
|
||||
"Установить Vanilla Minecraft",
|
||||
"Создать сборку вручную (Fabric/Forge)",
|
||||
"Назад"
|
||||
"Install pack from ZernMC server",
|
||||
"Install Vanilla Minecraft",
|
||||
"Create custom pack (Fabric/Forge)",
|
||||
"Back"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Установка новой сборки", options);
|
||||
ArrowMenu menu = new ArrowMenu("Install new pack", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 3) return;
|
||||
@@ -223,28 +220,28 @@ public class LaunchMenu {
|
||||
if (!awaitActivePass()) return;
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Получение списка доступных сборок..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching available packs..."));
|
||||
|
||||
PackDownloader tempDownloader = new PackDownloader(null);
|
||||
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
|
||||
|
||||
if (availablePacks.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Нет доступных сборок на сервере."));
|
||||
System.out.println(ZAnsi.yellow("No packs available on the server."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> options = availablePacks.stream()
|
||||
.map(p -> String.format("%s [%s + %s v%d] — %d файлов",
|
||||
.map(p -> String.format("%s [%s + %s v%d] - %d files",
|
||||
p.getName(),
|
||||
p.getMinecraftVersion(),
|
||||
p.getLoaderType(),
|
||||
p.getVersion(),
|
||||
p.getFilesCount()))
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options);
|
||||
ArrowMenu menu = new ArrowMenu("Select a pack to install", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
@@ -252,14 +249,14 @@ public class LaunchMenu {
|
||||
ServerPack selected = availablePacks.get(choice);
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
|
||||
System.out.println(ZAnsi.header("Installing pack: " + selected.getName()));
|
||||
|
||||
System.out.print(ZAnsi.white("\nВведите название локальной сборки (Enter = имя пака): "));
|
||||
System.out.print(ZAnsi.white("\nEnter local pack name (Enter = pack name): "));
|
||||
String localName = Input.readLine().trim();
|
||||
if (localName.isEmpty()) localName = selected.getName();
|
||||
|
||||
if (InstanceManager.getInstance(localName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
@@ -271,37 +268,36 @@ public class LaunchMenu {
|
||||
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
// ====================== manageInstance — полностью восстановлен ======================
|
||||
private void manageInstance(Instance instance) throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName()));
|
||||
System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion()));
|
||||
System.out.println(ZAnsi.white("Лоадер: " + instance.getLoaderType() +
|
||||
System.out.println(ZAnsi.header("Managing pack: " + instance.getName()));
|
||||
System.out.println(ZAnsi.white("Version: " + instance.getMinecraftVersion()));
|
||||
System.out.println(ZAnsi.white("Loader: " + instance.getLoaderType() +
|
||||
(instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : "")));
|
||||
|
||||
if (instance.isServerPack()) {
|
||||
System.out.println(ZAnsi.green("Серверная сборка: v" + instance.getServerVersion()));
|
||||
System.out.println(ZAnsi.green("Server pack: v" + instance.getServerVersion()));
|
||||
}
|
||||
|
||||
List<String> options = new ArrayList<>();
|
||||
options.add("Запустить сборку");
|
||||
options.add("Launch pack");
|
||||
if (instance.isServerPack()) {
|
||||
options.add("Проверить обновления");
|
||||
options.add("Check for updates");
|
||||
}
|
||||
options.add("Изменить версию лоадера");
|
||||
options.add("Удалить сборку");
|
||||
options.add("Назад");
|
||||
options.add("Change loader version");
|
||||
options.add("Delete pack");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Действия", options);
|
||||
ArrowMenu menu = new ArrowMenu("Actions", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return;
|
||||
@@ -329,40 +325,40 @@ public class LaunchMenu {
|
||||
|
||||
private void checkAndUpdateServerPack(Instance instance) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName()));
|
||||
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName()));
|
||||
|
||||
PackDownloader downloader = new PackDownloader(instance);
|
||||
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||
|
||||
if (!hasUpdate) {
|
||||
System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")"));
|
||||
System.out.println(ZAnsi.green("Pack is up to date (v" + instance.getServerVersion() + ")"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightYellow("Доступно обновление!"));
|
||||
if (Input.confirm("Обновить сборку")) {
|
||||
System.out.println(ZAnsi.brightYellow("Update available!"));
|
||||
if (Input.confirm("Update pack")) {
|
||||
boolean success = downloader.updatePack(instance.getServerPackName());
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("Сборка успешно обновлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Pack updated successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось обновить сборку."));
|
||||
System.out.println(ZAnsi.brightRed("Failed to update pack."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Обновление отменено."));
|
||||
System.out.println(ZAnsi.yellow("Update cancelled."));
|
||||
}
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void changeLoaderVersion(Instance instance) throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Изменение версии лоадера для " + instance.getName()));
|
||||
System.out.println(ZAnsi.cyan("Changing loader version for " + instance.getName()));
|
||||
|
||||
String currentLoader = instance.getLoaderType();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
|
||||
if ("vanilla".equalsIgnoreCase(currentLoader)) {
|
||||
System.out.println(ZAnsi.yellow("Это vanilla сборка. Нельзя изменить лоадер."));
|
||||
System.out.println(ZAnsi.yellow("This is a vanilla instance. Cannot change loader."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
@@ -378,7 +374,7 @@ public class LaunchMenu {
|
||||
|
||||
if (newLoaderVersion == null) return;
|
||||
|
||||
System.out.println(ZAnsi.cyan("Переустановка лоадера " + currentLoader + " -> " + newLoaderVersion + "..."));
|
||||
System.out.println(ZAnsi.cyan("Reinstalling loader " + currentLoader + " -> " + newLoaderVersion + "..."));
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
boolean success;
|
||||
@@ -393,12 +389,12 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Loader version changed successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось изменить версию лоадера."));
|
||||
System.out.println(ZAnsi.brightRed("Failed to change loader version."));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.brightRed("Error changing loader: " + e.getMessage()));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -408,12 +404,12 @@ public class LaunchMenu {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> confirmOptions = List.of(
|
||||
"Да, удалить сборку",
|
||||
"Нет, отменить"
|
||||
"Yes, delete pack",
|
||||
"No, cancel"
|
||||
);
|
||||
|
||||
ArrowMenu confirmMenu = new ArrowMenu(
|
||||
"Вы действительно хотите удалить сборку '" + instance.getName() + "'?",
|
||||
"Are you sure you want to delete '" + instance.getName() + "'?",
|
||||
confirmOptions
|
||||
);
|
||||
|
||||
@@ -422,12 +418,12 @@ public class LaunchMenu {
|
||||
if (choice == 0) {
|
||||
boolean deleted = InstanceManager.deleteInstance(instance.getName());
|
||||
if (deleted) {
|
||||
System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена."));
|
||||
System.out.println(ZAnsi.brightGreen("Pack '" + instance.getName() + "' deleted successfully."));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось удалить сборку."));
|
||||
System.out.println(ZAnsi.brightRed("Failed to delete pack."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("Удаление отменено."));
|
||||
System.out.println(ZAnsi.yellow("Deletion cancelled."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -436,16 +432,20 @@ public class LaunchMenu {
|
||||
private void launchExistingInstance(Instance instance) {
|
||||
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
|
||||
System.out.println(ZAnsi.brightRed("Launching a server pack requires an active pass!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
||||
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
|
||||
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
LaunchOptions options = new LaunchOptions();
|
||||
options.setMaxMemory(Config.getMaxMemory());
|
||||
options.setWidth(Config.getWindowWidth());
|
||||
options.setHeight(Config.getWindowHeight());
|
||||
options.setJavaPath(Config.getJavaPath());
|
||||
|
||||
options.setUsername(AuthManager.getUsername());
|
||||
options.setUuid(AuthManager.getUuid());
|
||||
@@ -454,20 +454,18 @@ public class LaunchMenu {
|
||||
try {
|
||||
lib.launch(options);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.brightRed("Error launching: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
// ====================== Остальные вспомогательные методы ======================
|
||||
|
||||
private String askPackName() {
|
||||
System.out.print(ZAnsi.white("\nВведите название новой сборки: "));
|
||||
System.out.print(ZAnsi.white("\nEnter new pack name: "));
|
||||
String name = Input.readLine().trim();
|
||||
if (name.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Отменено."));
|
||||
System.out.println(ZAnsi.yellow("Cancelled."));
|
||||
return null;
|
||||
}
|
||||
return name;
|
||||
@@ -475,7 +473,7 @@ public class LaunchMenu {
|
||||
|
||||
private void createVanillaInstance() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||
@@ -483,9 +481,9 @@ public class LaunchMenu {
|
||||
List<String> versionOptions = allVersions.stream()
|
||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||
.collect(Collectors.toList());
|
||||
versionOptions.add("Назад");
|
||||
versionOptions.add("Back");
|
||||
|
||||
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
||||
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
|
||||
int versionChoice = versionMenu.show();
|
||||
|
||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||
@@ -497,7 +495,7 @@ public class LaunchMenu {
|
||||
if (packName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(packName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
@@ -509,9 +507,9 @@ public class LaunchMenu {
|
||||
boolean success = lib.installMinecraft(mcVersion);
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!"));
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla pack '" + packName + "' created successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку."));
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to create pack."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -519,7 +517,7 @@ public class LaunchMenu {
|
||||
|
||||
private void createCustomInstance() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
|
||||
|
||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||
@@ -527,9 +525,9 @@ public class LaunchMenu {
|
||||
List<String> versionOptions = allVersions.stream()
|
||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||
.collect(Collectors.toList());
|
||||
versionOptions.add("Назад");
|
||||
versionOptions.add("Back");
|
||||
|
||||
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
||||
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions);
|
||||
int versionChoice = versionMenu.show();
|
||||
|
||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||
@@ -538,7 +536,7 @@ public class LaunchMenu {
|
||||
String mcVersion = selectedMc.getId();
|
||||
|
||||
List<String> loaderOptions = buildLoaderOptions(mcVersion);
|
||||
ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions);
|
||||
ArrowMenu loaderMenu = new ArrowMenu("Select mod loader for " + mcVersion, loaderOptions);
|
||||
int loaderChoice = loaderMenu.show();
|
||||
|
||||
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
|
||||
@@ -574,7 +572,7 @@ public class LaunchMenu {
|
||||
if (packName == null) return;
|
||||
|
||||
if (InstanceManager.getInstance(packName) != null) {
|
||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||
System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
@@ -594,9 +592,9 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + packName + "' installed successfully!"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to install pack."));
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -609,7 +607,7 @@ public class LaunchMenu {
|
||||
if (isNeoForgeSupported(mcVersion)) options.add("NeoForge");
|
||||
if (isForgeSupported(mcVersion)) options.add("Forge");
|
||||
options.add("Vanilla");
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -631,16 +629,16 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
private String askFabricLoaderVersion() throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching Fabric Loader versions..."));
|
||||
List<String> versions = ZHttpClient.getFabricLoaderVersions();
|
||||
|
||||
List<String> options = versions.stream()
|
||||
.limit(30)
|
||||
.map(v -> "Fabric Loader " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выбор версии Fabric Loader", options);
|
||||
ArrowMenu menu = new ArrowMenu("Select Fabric Loader version", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
@@ -648,7 +646,7 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
private String askForgeVersion(String mcVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий Forge для " + mcVersion + "..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching Forge versions for " + mcVersion + "..."));
|
||||
|
||||
List<String> allForgeVersions = getAllForgeVersions();
|
||||
|
||||
@@ -658,7 +656,7 @@ public class LaunchMenu {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (compatibleVersions.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion));
|
||||
System.out.println(ZAnsi.yellow("No compatible Forge versions found for " + mcVersion));
|
||||
ConsoleUtils.pause();
|
||||
return null;
|
||||
}
|
||||
@@ -667,9 +665,9 @@ public class LaunchMenu {
|
||||
.limit(30)
|
||||
.map(v -> "Forge " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выбор версии Forge для " + mcVersion, options);
|
||||
ArrowMenu menu = new ArrowMenu("Select Forge version for " + mcVersion, options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
@@ -698,7 +696,7 @@ public class LaunchMenu {
|
||||
}
|
||||
|
||||
private String askNeoForgeVersion(String mcVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Получение списка версий NeoForge для " + mcVersion + "..."));
|
||||
System.out.println(ZAnsi.cyan("Fetching NeoForge versions for " + mcVersion + "..."));
|
||||
|
||||
List<String> allNeoForgeVersions = getAllNeoForgeVersions();
|
||||
|
||||
@@ -707,7 +705,7 @@ public class LaunchMenu {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (compatibleVersions.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Не найдено совместимых версий NeoForge для " + mcVersion));
|
||||
System.out.println(ZAnsi.yellow("No compatible NeoForge versions found for " + mcVersion));
|
||||
ConsoleUtils.pause();
|
||||
return null;
|
||||
}
|
||||
@@ -716,9 +714,9 @@ public class LaunchMenu {
|
||||
.limit(30)
|
||||
.map(v -> "NeoForge " + v)
|
||||
.collect(Collectors.toList());
|
||||
options.add("Назад");
|
||||
options.add("Back");
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выбор версии NeoForge для " + mcVersion, options);
|
||||
ArrowMenu menu = new ArrowMenu("Select NeoForge version for " + mcVersion, options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == options.size() - 1) return null;
|
||||
@@ -760,11 +758,10 @@ public class LaunchMenu {
|
||||
index = end;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Skip if one maven doesn't have the artifact
|
||||
}
|
||||
}
|
||||
|
||||
versions.sort((a, b) -> b.compareTo(a));
|
||||
return versions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,30 +10,20 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Экран входа/регистрации.
|
||||
* Показывается при старте лаунчера, если нет сохранённой сессии.
|
||||
*
|
||||
* show() возвращает true — пользователь вошёл/зарегистрировался
|
||||
* false — пользователь выбрал выход из лаунчера
|
||||
*/
|
||||
public class LoginMenu {
|
||||
|
||||
/**
|
||||
* Главный экран выбора действия.
|
||||
*/
|
||||
public boolean show() throws IOException {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Войти в аккаунт",
|
||||
"Создать аккаунт",
|
||||
"Выйти из лаунчера"
|
||||
"Sign In",
|
||||
"Create Account",
|
||||
"Exit Launcher"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Добро пожаловать в ZernMC!", options);
|
||||
ArrowMenu menu = new ArrowMenu("Welcome to ZernMC!", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 2) return false;
|
||||
@@ -45,62 +35,56 @@ public class LoginMenu {
|
||||
};
|
||||
|
||||
if (success) return true;
|
||||
// Если не успех — покажем меню снова (ошибка уже напечатана внутри методов)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывается когда пользователь уже вошёл — предлагает выйти из аккаунта.
|
||||
*/
|
||||
public void showAccountMenu() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
System.out.println(ZAnsi.header("=== Аккаунт ==="));
|
||||
System.out.println(ZAnsi.header("=== Account ==="));
|
||||
System.out.println();
|
||||
System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
|
||||
System.out.println(ZAnsi.white(" Player: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
|
||||
System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid()));
|
||||
System.out.println();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Выйти из аккаунта",
|
||||
"Назад"
|
||||
"Log Out",
|
||||
"Back"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options);
|
||||
ArrowMenu menu = new ArrowMenu("Account Management", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == 0) {
|
||||
AuthManager.logout();
|
||||
System.out.println(ZAnsi.yellow("Вы вышли из аккаунта."));
|
||||
System.out.println(ZAnsi.yellow("Logged out."));
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ПРИВАТНЫЕ МЕТОДЫ ======================
|
||||
|
||||
private boolean doLogin() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]"));
|
||||
System.out.println(ZAnsi.cyan(" [ Sign In ]"));
|
||||
System.out.println();
|
||||
|
||||
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
|
||||
String username = Input.readLine(ZAnsi.white(" Username: "));
|
||||
if (username.isEmpty()) return false;
|
||||
|
||||
String password = readPassword(" Пароль: ");
|
||||
String password = readPassword(" Password: ");
|
||||
if (password.isEmpty()) return false;
|
||||
|
||||
System.out.println();
|
||||
System.out.print(ZAnsi.cyan(" Выполняем вход..."));
|
||||
System.out.print(ZAnsi.cyan(" Signing in..."));
|
||||
|
||||
AuthResult result = AuthManager.login(username, password);
|
||||
|
||||
if (result.success) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! "));
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Welcome, " + AuthManager.getUsername() + "! "));
|
||||
ConsoleUtils.pause();
|
||||
return true;
|
||||
} else {
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
@@ -109,45 +93,41 @@ public class LoginMenu {
|
||||
private boolean doRegister() throws IOException {
|
||||
ConsoleUtils.clearScreen();
|
||||
printBanner();
|
||||
System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]"));
|
||||
System.out.println(ZAnsi.cyan(" [ Create Account ]"));
|
||||
System.out.println();
|
||||
System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _"));
|
||||
System.out.println(ZAnsi.yellow(" Длина имени: 3-16 символов | Длина пароля: от 6 символов"));
|
||||
System.out.println(ZAnsi.yellow(" Allowed characters: a-z, A-Z, 0-9, _"));
|
||||
System.out.println(ZAnsi.yellow(" Name length: 3-16 chars | Password length: 6+ chars"));
|
||||
System.out.println();
|
||||
|
||||
String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
|
||||
String username = Input.readLine(ZAnsi.white(" Username: "));
|
||||
if (username.isEmpty()) return false;
|
||||
|
||||
String password = readPassword(" Пароль: ");
|
||||
String password = readPassword(" Password: ");
|
||||
if (password.isEmpty()) return false;
|
||||
|
||||
String confirm = readPassword(" Повторите пароль: ");
|
||||
String confirm = readPassword(" Confirm password: ");
|
||||
if (!password.equals(confirm)) {
|
||||
System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!"));
|
||||
System.out.println(ZAnsi.brightRed("\n Passwords do not match!"));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
System.out.print(ZAnsi.cyan(" Создаём аккаунт..."));
|
||||
System.out.print(ZAnsi.cyan(" Creating account..."));
|
||||
|
||||
AuthResult result = AuthManager.register(username, password);
|
||||
|
||||
if (result.success) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! "));
|
||||
System.out.println("\r" + ZAnsi.brightGreen(" Account created! Welcome, " + AuthManager.getUsername() + "! "));
|
||||
ConsoleUtils.pause();
|
||||
return true;
|
||||
} else {
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
|
||||
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
|
||||
ConsoleUtils.pause();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читаем пароль — стараемся скрыть вывод через Console,
|
||||
* если недоступно (IDE/терминал без TTY) — читаем обычным способом.
|
||||
*/
|
||||
private String readPassword(String prompt) throws IOException {
|
||||
org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder()
|
||||
.system(true)
|
||||
@@ -165,27 +145,26 @@ public class LoginMenu {
|
||||
int key = passTerminal.reader().read();
|
||||
|
||||
if (key == 27) {
|
||||
// Escape sequence — consume remaining bytes (arrow keys, etc.)
|
||||
int next = passTerminal.reader().read();
|
||||
if (next == 91) { // '[' — arrow key sequence
|
||||
passTerminal.reader().read(); // consume 'A'/'B'/'C'/'D'
|
||||
if (next == 91) {
|
||||
passTerminal.reader().read();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key == 13 || key == 10) { // Enter
|
||||
if (key == 13 || key == 10) {
|
||||
passTerminal.writer().println();
|
||||
break;
|
||||
} else if (key == 127 || key == 8) { // Backspace
|
||||
} else if (key == 127 || key == 8) {
|
||||
if (password.length() > 0) {
|
||||
password.setLength(password.length() - 1);
|
||||
passTerminal.writer().print("\b \b");
|
||||
passTerminal.writer().flush();
|
||||
}
|
||||
} else if (key == 3) { // Ctrl+C
|
||||
} else if (key == 3) {
|
||||
passTerminal.writer().println();
|
||||
System.exit(0);
|
||||
} else if (key >= 32 && key < 127) { // Printable characters
|
||||
} else if (key >= 32 && key < 127) {
|
||||
password.append((char) key);
|
||||
passTerminal.writer().print('*');
|
||||
passTerminal.writer().flush();
|
||||
|
||||
+28
-28
@@ -18,17 +18,17 @@ public class ServerCheckMenu {
|
||||
public void show() throws IOException {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Диагностика подключения"));
|
||||
System.out.println(ZAnsi.header("Connection Diagnostics"));
|
||||
|
||||
List<String> options = List.of(
|
||||
"Проверить подключение к ZernMC серверу",
|
||||
"Проверить доступ к Mojang (Minecraft)",
|
||||
"Проверить доступ к Fabric Meta",
|
||||
"Проверить доступ к Forge Maven",
|
||||
"Назад в главное меню"
|
||||
"Check ZernMC server connection",
|
||||
"Check Mojang (Minecraft) access",
|
||||
"Check Fabric Meta access",
|
||||
"Check Forge Maven access",
|
||||
"Back to main menu"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Выберите проверку", options);
|
||||
ArrowMenu menu = new ArrowMenu("Select check", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 4) {
|
||||
@@ -49,20 +49,20 @@ public class ServerCheckMenu {
|
||||
}
|
||||
|
||||
private void checkZernServer() {
|
||||
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
||||
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
|
||||
|
||||
try {
|
||||
String response = ZHttpClient.get("/health");
|
||||
System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!"));
|
||||
System.out.println(ZAnsi.white("Ответ сервера: ") + response);
|
||||
System.out.println(ZAnsi.brightGreen("[OK] ZernMC server connected successfully!"));
|
||||
System.out.println(ZAnsi.white("Server response: ") + response);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Could not connect to ZernMC server"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkMojang() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
|
||||
System.out.println(ZAnsi.cyan("Checking Mojang access..."));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
@@ -77,18 +77,18 @@ public class ServerCheckMenu {
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Mojang доступен"));
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Mojang is accessible"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode()));
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Mojang returned code " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Mojang"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkFabric() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
|
||||
System.out.println(ZAnsi.cyan("Checking Fabric Meta access..."));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
@@ -103,18 +103,18 @@ public class ServerCheckMenu {
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta доступен"));
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta is accessible"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta вернул код " + response.statusCode()));
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta returned code " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Fabric Meta"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkForge() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Forge Maven..."));
|
||||
System.out.println(ZAnsi.cyan("Checking Forge Maven access..."));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
@@ -129,13 +129,13 @@ public class ServerCheckMenu {
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven доступен"));
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven is accessible"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven вернул код " + response.statusCode()));
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven returned code " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Forge Maven"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Forge Maven"));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ public class SettingsMenu {
|
||||
|
||||
public void show() throws IOException {
|
||||
List<String> options = List.of(
|
||||
"Настроить путь к Java",
|
||||
"Настроить выделенную память (RAM)",
|
||||
"Дополнительные JVM параметры",
|
||||
"Назад в главное меню"
|
||||
"Configure Java path",
|
||||
"Configure allocated RAM",
|
||||
"Additional JVM parameters",
|
||||
"Back to main menu"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Настройки лаунчера", options);
|
||||
ArrowMenu menu = new ArrowMenu("Launcher Settings", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 3) return;
|
||||
@@ -36,33 +36,33 @@ public class SettingsMenu {
|
||||
}
|
||||
|
||||
private void configureJava() {
|
||||
System.out.println(ZAnsi.cyan("Путь к Java:"));
|
||||
System.out.println(ZAnsi.cyan("Java path:"));
|
||||
System.out.println(" " + Config.getJreDir().toAbsolutePath());
|
||||
System.out.println(ZAnsi.white("\nJava будет искаться автоматически в папке ~/.zernmc/jre/"));
|
||||
System.out.println("Если нужно — положите туда свою версию Java.");
|
||||
System.out.println(ZAnsi.white("\nJava will be searched automatically in ~/.zernmc/jre/"));
|
||||
System.out.println("If needed, place your own Java version there.");
|
||||
}
|
||||
|
||||
private void configureRam() {
|
||||
System.out.println(ZAnsi.cyan("Настройка выделенной памяти"));
|
||||
System.out.println(ZAnsi.cyan("RAM Allocation"));
|
||||
System.out.println(Config.getRamInfo());
|
||||
|
||||
int newRam = Input.readInt(
|
||||
ZAnsi.white("\nВведите новое значение RAM в MB (или 0 для отмены): "),
|
||||
ZAnsi.white("\nEnter new RAM value in MB (or 0 to cancel): "),
|
||||
0, 32768
|
||||
);
|
||||
|
||||
if (newRam == 0) {
|
||||
System.out.println(ZAnsi.yellow("Настройка отменена."));
|
||||
System.out.println(ZAnsi.yellow("Setting cancelled."));
|
||||
return;
|
||||
}
|
||||
|
||||
Config.setMaxMemory(newRam);
|
||||
System.out.println(ZAnsi.brightGreen("Выделенная память изменена на " + newRam + " MB"));
|
||||
System.out.println(ZAnsi.brightGreen("Allocated RAM changed to " + newRam + " MB"));
|
||||
}
|
||||
|
||||
private void configureJvmArgs() {
|
||||
System.out.println(ZAnsi.yellow("Дополнительные JVM параметры"));
|
||||
System.out.println("Пока в разработке.");
|
||||
System.out.println("В будущем здесь будет список предустановленных оптимизаций.");
|
||||
System.out.println(ZAnsi.yellow("Additional JVM parameters"));
|
||||
System.out.println("Currently in development.");
|
||||
System.out.println("A list of preset optimizations will be available in the future.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ public class UpdateMenu {
|
||||
|
||||
public void show() throws IOException {
|
||||
List<String> options = List.of(
|
||||
"Проверить обновления сборки (модпака)",
|
||||
"Проверить обновления лаунчера",
|
||||
"Назад в главное меню"
|
||||
"Check pack updates",
|
||||
"Check launcher updates",
|
||||
"Back to main menu"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Проверка обновлений", options);
|
||||
ArrowMenu menu = new ArrowMenu("Update Check", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 2) return;
|
||||
@@ -34,7 +34,7 @@ public class UpdateMenu {
|
||||
try {
|
||||
checkPackUpdates();
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.brightRed("Error: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public class UpdateMenu {
|
||||
}
|
||||
|
||||
private void checkPackUpdates() throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений сборок..."));
|
||||
System.out.println(ZAnsi.cyan("Checking pack updates..."));
|
||||
|
||||
List<Instance> instances = InstanceManager.getAllInstances();
|
||||
List<Instance> serverInstances = instances.stream()
|
||||
@@ -52,12 +52,12 @@ public class UpdateMenu {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (serverInstances.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow("Нет сборок, установленных с сервера."));
|
||||
System.out.println(ZAnsi.yellow("No server-installed packs found."));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.cyan("\nПроверка обновлений для серверных сборок:\n"));
|
||||
System.out.println(ZAnsi.cyan("\nChecking updates for server packs:\n"));
|
||||
|
||||
boolean hasUpdates = false;
|
||||
List<Instance> updatableInstances = new ArrayList<>();
|
||||
@@ -68,42 +68,41 @@ public class UpdateMenu {
|
||||
try {
|
||||
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||
if (hasUpdate) {
|
||||
System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!"));
|
||||
System.out.println(ZAnsi.yellow(instance.getName() + " - Update available!"));
|
||||
updatableInstances.add(instance);
|
||||
hasUpdates = true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.green(instance.getName() + " - Актуальна"));
|
||||
System.out.println(ZAnsi.green(instance.getName() + " - Up to date"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.red(instance.getName() + " - Ошибка проверки: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.red(instance.getName() + " - Check error: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUpdates) {
|
||||
System.out.println(ZAnsi.green("\nВсе сборки актуальны!"));
|
||||
System.out.println(ZAnsi.green("\nAll packs are up to date!"));
|
||||
ConsoleUtils.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Предлагаем обновить каждую сборку отдельно
|
||||
for (Instance instance : updatableInstances) {
|
||||
System.out.println(ZAnsi.brightYellow("\nОбновить сборку '" + instance.getName() + "'?"));
|
||||
if (Input.confirm("Обновить")) {
|
||||
System.out.println(ZAnsi.cyan("Обновление " + instance.getName() + "..."));
|
||||
System.out.println(ZAnsi.brightYellow("\nUpdate pack '" + instance.getName() + "'?"));
|
||||
if (Input.confirm("Update")) {
|
||||
System.out.println(ZAnsi.cyan("Updating " + instance.getName() + "..."));
|
||||
PackDownloader downloader = new PackDownloader(instance);
|
||||
|
||||
try {
|
||||
boolean success = downloader.updatePack(instance.getServerPackName());
|
||||
if (success) {
|
||||
System.out.println(ZAnsi.brightGreen(instance.getName() + " обновлен"));
|
||||
System.out.println(ZAnsi.brightGreen(instance.getName() + " updated"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed(instance.getName() + " не удалось обновить"));
|
||||
System.out.println(ZAnsi.brightRed(instance.getName() + " update failed"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage()));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Пропущено: " + instance.getName()));
|
||||
System.out.println(ZAnsi.yellow(" Skipped: " + instance.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,28 +110,27 @@ public class UpdateMenu {
|
||||
}
|
||||
|
||||
private void checkLauncherUpdates() {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
|
||||
System.out.println(ZAnsi.cyan("Checking launcher updates..."));
|
||||
|
||||
try {
|
||||
String json = ZHttpClient.getLauncherVersionInfo();
|
||||
String serverVersion = extractVersion(json);
|
||||
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
|
||||
|
||||
System.out.println(ZAnsi.white("Текущая версия: ") + currentVersion);
|
||||
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion);
|
||||
System.out.println(ZAnsi.white("Current version: ") + currentVersion);
|
||||
System.out.println(ZAnsi.white("Server version: ") + serverVersion);
|
||||
|
||||
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
|
||||
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия!"));
|
||||
if (Input.confirm("Обновить лаунчер?")) {
|
||||
// Обновление будет при следующем запуске
|
||||
System.out.println(ZAnsi.green("Лаунчер будет обновлен при следующем запуске."));
|
||||
System.out.println(ZAnsi.brightYellow("\nNew version available!"));
|
||||
if (Input.confirm("Update launcher?")) {
|
||||
System.out.println(ZAnsi.green("Launcher will be updated on next restart."));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
|
||||
System.out.println(ZAnsi.brightGreen("Launcher is up to date."));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
System.out.println(ZAnsi.yellow("Could not check launcher updates."));
|
||||
System.out.println(ZAnsi.white("Error: ") + e.getMessage());
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
@@ -149,4 +147,4 @@ public class UpdateMenu {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-29
@@ -8,6 +8,7 @@ import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
@@ -56,7 +57,7 @@ public class MinecraftLib {
|
||||
boolean success = installer.install(minecraftVersion, loaderVersion);
|
||||
|
||||
if (success) {
|
||||
// Сохраняем информацию в Instance
|
||||
// Save info to Instance
|
||||
instance.setMinecraftVersion(minecraftVersion);
|
||||
instance.setLoaderType("fabric");
|
||||
instance.setLoaderVersion(loaderVersion);
|
||||
@@ -65,61 +66,61 @@ public class MinecraftLib {
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная установка сборки (vanilla + loader + моды)
|
||||
* Пока заглушка — будем расширять
|
||||
* Full pack install (vanilla + loader + mods)
|
||||
* Stub - will be expanded
|
||||
*/
|
||||
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Начинается полная установка сборки: " + packName));
|
||||
System.out.println(ZAnsi.cyan("Starting full pack install: " + packName));
|
||||
|
||||
// 1. Устанавливаем Minecraft
|
||||
// 1. Install Minecraft
|
||||
boolean mcInstalled = installMinecraft(minecraftVersion);
|
||||
if (!mcInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion));
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Устанавливаем лоадер
|
||||
// 2. Install loader
|
||||
if ("fabric".equalsIgnoreCase(loaderType)) {
|
||||
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
|
||||
if (!fabricInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить Fabric"));
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Fabric"));
|
||||
return false;
|
||||
}
|
||||
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
||||
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
|
||||
if (!forgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить Forge"));
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Forge"));
|
||||
return false;
|
||||
}
|
||||
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
|
||||
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
|
||||
if (!neoforgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
|
||||
System.out.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. В будущем здесь будет diff и скачивание модов
|
||||
// 3. In the future: diff and mod download
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Базовая установка сборки завершена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Basic pack install complete!"));
|
||||
return true;
|
||||
}
|
||||
|
||||
//Запуск
|
||||
//Launch
|
||||
public void launch(LaunchOptions options) throws Exception {
|
||||
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
||||
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
|
||||
cleanupOldLoaders();
|
||||
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
List<String> command = builder.build(options);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Команда запуска (" + command.size() + " аргументов):"));
|
||||
System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):"));
|
||||
command.forEach(arg -> System.out.println(" " + arg));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(instance.getPath().toFile());
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
|
||||
System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
Process process = pb.start();
|
||||
@@ -132,7 +133,7 @@ public class MinecraftLib {
|
||||
JFXLauncher.appendGameLog(line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage() + "]");
|
||||
JFXLauncher.appendGameLog("[Error reading output: " + e.getMessage() + "]");
|
||||
}
|
||||
});
|
||||
outThread.setDaemon(true);
|
||||
@@ -146,7 +147,7 @@ public class MinecraftLib {
|
||||
JFXLauncher.appendGameLog("[ERR] " + line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage() + "]");
|
||||
JFXLauncher.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
|
||||
}
|
||||
});
|
||||
errThread.setDaemon(true);
|
||||
@@ -156,18 +157,19 @@ public class MinecraftLib {
|
||||
outThread.join(1000);
|
||||
errThread.join(1000);
|
||||
|
||||
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
|
||||
System.out.println(ZAnsi.yellow("\nMinecraft exited with code: " + exitCode));
|
||||
}
|
||||
|
||||
private void safeDeleteDirectory(Path dir) {
|
||||
try {
|
||||
Files.walk(dir)
|
||||
.sorted((a, b) -> b.compareTo(a))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException ignored) {}
|
||||
});
|
||||
} catch (IOException ignored) {}
|
||||
try (var stream = Files.walk(dir)) {
|
||||
stream.sorted((a, b) -> b.compareTo(a))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException e) { /* ignore */ }
|
||||
});
|
||||
} catch (IOException e) {
|
||||
LauncherLogger.warn("safeDeleteDirectory: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException {
|
||||
@@ -202,9 +204,9 @@ public class MinecraftLib {
|
||||
|
||||
if (currentLoaderVer == null) return;
|
||||
|
||||
System.out.println(ZAnsi.yellow("Выполняем очистку старых версий лоадера..."));
|
||||
System.out.println(ZAnsi.yellow("Cleaning old loader versions..."));
|
||||
|
||||
// Удаляем все старые fabric-loader / forge
|
||||
// Delete all old fabric-loader / forge
|
||||
Path libraries = instance.getPath().resolve("libraries");
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
|
||||
+75
-66
@@ -8,6 +8,7 @@ import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
@@ -19,6 +20,7 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
@@ -36,18 +38,18 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список доступных паков с сервера
|
||||
* Get list of available packs from server
|
||||
*/
|
||||
public List<ServerPack> getAvailablePacks() throws Exception {
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken == null) {
|
||||
throw new IOException("Не авторизован. Требуется проходка для просмотра сборок.");
|
||||
throw new IOException("Not authenticated. Active pass required to view packs.");
|
||||
}
|
||||
if (!AuthManager.canViewPacks()) {
|
||||
throw new IOException("Для просмотра сборок требуется активная проходка");
|
||||
throw new IOException("Active pass required to view packs");
|
||||
}
|
||||
|
||||
// Используем HttpURLConnection для GET с авторизацией
|
||||
// Use HttpURLConnection for GET with auth
|
||||
java.net.HttpURLConnection connection = null;
|
||||
try {
|
||||
java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs");
|
||||
@@ -61,7 +63,7 @@ public class PackDownloader {
|
||||
int responseCode = connection.getResponseCode();
|
||||
|
||||
if (responseCode == 403) {
|
||||
throw new IOException("Для просмотра сборок требуется активная проходка");
|
||||
throw new IOException("Active pass required to view packs");
|
||||
}
|
||||
|
||||
StringBuilder response = new StringBuilder();
|
||||
@@ -118,7 +120,7 @@ public class PackDownloader {
|
||||
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
|
||||
loaderVersion, updatedAt, filesCount));
|
||||
} catch (Exception e) {
|
||||
System.err.println("Ошибка парсинга пака: " + e.getMessage());
|
||||
LauncherLogger.warn("Error parsing pack: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +128,7 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить манифест пака
|
||||
* Get pack manifest
|
||||
*/
|
||||
public PackManifest getPackManifest(String packName) throws Exception {
|
||||
String response = ZHttpClient.get("/pack/" + packName);
|
||||
@@ -134,18 +136,18 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить или обновить сборку с сервера
|
||||
* Install or update a pack from the server
|
||||
*/
|
||||
public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка сборки " + packName + " с сервера..."));
|
||||
LauncherLogger.info("Installing pack " + packName + " from server...");
|
||||
|
||||
// 1. Получаем манифест
|
||||
// 1. Get manifest
|
||||
PackManifest manifest = getPackManifest(packName);
|
||||
|
||||
// 2. Сначала устанавливаем Minecraft + Loader через MinecraftLib
|
||||
// 2. First install Minecraft + Loader via MinecraftLib
|
||||
MinecraftLib lib = new MinecraftLib(instance);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Установка Minecraft " + manifest.getMinecraftVersion() + "..."));
|
||||
System.out.println(ZAnsi.cyan("Installing Minecraft " + manifest.getMinecraftVersion() + "..."));
|
||||
|
||||
boolean needsMinecraftInstall = instance.getMinecraftVersion() == null ||
|
||||
!instance.getMinecraftVersion().equals(manifest.getMinecraftVersion());
|
||||
@@ -154,40 +156,40 @@ public class PackDownloader {
|
||||
if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) {
|
||||
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось установить Fabric"));
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
|
||||
return false;
|
||||
}
|
||||
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
|
||||
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
|
||||
System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
||||
return false;
|
||||
}
|
||||
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
|
||||
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось установить Forge"));
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Forge"));
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось установить Vanilla Minecraft"));
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Minecraft уже установлен, пропускаем..."));
|
||||
System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
|
||||
}
|
||||
|
||||
// 3. Сканируем локальные файлы ТОЛЬКО если есть файлы для скачивания
|
||||
// 3. Scan local files only if there are files to download
|
||||
Map<String, String> localFiles = scanLocalFiles();
|
||||
|
||||
// Если в сборке нет файлов (только vanilla/loader), пропускаем diff
|
||||
// If pack has no files (vanilla/loader only), skip diff
|
||||
if (manifest.files == null || manifest.files.isEmpty()) {
|
||||
System.out.println(ZAnsi.green("Сборка не содержит дополнительных файлов"));
|
||||
System.out.println(ZAnsi.green("Pack contains no additional files"));
|
||||
|
||||
// Обновляем метаданные инстанса
|
||||
// Update instance metadata
|
||||
instance.setServerPack(true);
|
||||
instance.setServerPackName(packName);
|
||||
instance.setServerVersion(manifest.getVersion());
|
||||
@@ -196,19 +198,19 @@ public class PackDownloader {
|
||||
instance.setLoaderVersion(manifest.getLoaderVersion());
|
||||
instance.setAssetIndex(manifest.getAssetIndex());
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Отправляем diff запрос
|
||||
System.out.println(ZAnsi.cyan("Проверка файлов сборки..."));
|
||||
// 4. Send diff request
|
||||
System.out.println(ZAnsi.cyan("Checking pack files..."));
|
||||
DiffResponse diff = getDiff(packName, localFiles);
|
||||
|
||||
// 5. Применяем изменения
|
||||
// 5. Apply changes
|
||||
boolean success = applyDiff(diff, packName);
|
||||
|
||||
if (success) {
|
||||
// 6. Обновляем метаданные инстанса
|
||||
// 6. Update instance metadata
|
||||
instance.setServerPack(true);
|
||||
instance.setServerPackName(packName);
|
||||
instance.setServerVersion(manifest.getVersion());
|
||||
@@ -217,14 +219,14 @@ public class PackDownloader {
|
||||
instance.setLoaderVersion(manifest.getLoaderVersion());
|
||||
instance.setAssetIndex(manifest.getAssetIndex());
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!"));
|
||||
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить наличие обновлений для серверной сборки
|
||||
* Check for server pack updates
|
||||
*/
|
||||
public boolean checkForUpdates(String packName) throws Exception {
|
||||
if (!instance.isServerPack()) return false;
|
||||
@@ -237,40 +239,40 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить существующую серверную сборку
|
||||
* Update an existing server pack
|
||||
*/
|
||||
public boolean updatePack(String packName) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName() + "..."));
|
||||
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "..."));
|
||||
|
||||
PackManifest manifest = getPackManifest(packName);
|
||||
int serverVersion = manifest.getVersion();
|
||||
|
||||
if (serverVersion <= instance.getServerVersion()) {
|
||||
System.out.println(ZAnsi.green("Сборка уже актуальна (v" + instance.getServerVersion() + ")"));
|
||||
System.out.println(ZAnsi.green("Pack is already up to date (v" + instance.getServerVersion() + ")"));
|
||||
return true;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.yellow("Доступно обновление: v" + instance.getServerVersion() + " → v" + serverVersion));
|
||||
System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " → v" + serverVersion));
|
||||
|
||||
// Сканируем локальные файлы
|
||||
// Scan local files
|
||||
Map<String, String> localFiles = scanLocalFiles();
|
||||
|
||||
// Получаем diff
|
||||
// Get diff
|
||||
DiffResponse diff = getDiff(packName, localFiles);
|
||||
|
||||
// Применяем изменения
|
||||
// Apply changes
|
||||
boolean success = applyDiff(diff, packName);
|
||||
|
||||
if (success) {
|
||||
instance.setServerVersion(serverVersion);
|
||||
System.out.println(ZAnsi.brightGreen("Сборка обновлена до v" + serverVersion));
|
||||
System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сканирование локальных файлов и вычисление хешей
|
||||
* Scan local files and compute hashes
|
||||
*/
|
||||
private Map<String, String> scanLocalFiles() throws IOException {
|
||||
Map<String, String> files = new HashMap<>();
|
||||
@@ -312,23 +314,23 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить diff запрос на сервер
|
||||
* Send diff request to server
|
||||
*/
|
||||
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
||||
String json = gson.toJson(localFiles);
|
||||
|
||||
// Получаем токен авторизации
|
||||
// Get auth token
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken == null) {
|
||||
throw new IOException("Не авторизован. Требуется проходка для скачивания сборок.");
|
||||
throw new IOException("Not authenticated. Active pass required to download packs.");
|
||||
}
|
||||
if (!AuthManager.canDownloadPacks()) {
|
||||
throw new IOException("Для скачивания сборок требуется активная проходка");
|
||||
throw new IOException("Active pass required to download packs");
|
||||
}
|
||||
|
||||
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
|
||||
|
||||
// Используем HttpURLConnection для полного контроля
|
||||
// Use HttpURLConnection for full control
|
||||
java.net.HttpURLConnection connection = null;
|
||||
try {
|
||||
java.net.URL urlObj = new java.net.URL(url);
|
||||
@@ -342,7 +344,7 @@ public class PackDownloader {
|
||||
connection.setConnectTimeout(30000);
|
||||
connection.setReadTimeout(30000);
|
||||
|
||||
// Отправляем JSON
|
||||
// Send JSON
|
||||
try (java.io.OutputStream os = connection.getOutputStream()) {
|
||||
byte[] input = json.getBytes("UTF-8");
|
||||
os.write(input, 0, input.length);
|
||||
@@ -351,7 +353,7 @@ public class PackDownloader {
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
|
||||
// Читаем ответ
|
||||
// Read response
|
||||
StringBuilder response = new StringBuilder();
|
||||
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
|
||||
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) {
|
||||
@@ -364,7 +366,7 @@ public class PackDownloader {
|
||||
String responseBody = response.toString();
|
||||
|
||||
if (responseCode == 403) {
|
||||
throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору.");
|
||||
throw new IOException("Active pass required to download packs. Contact the administrator.");
|
||||
}
|
||||
|
||||
if (responseCode != 200) {
|
||||
@@ -391,34 +393,34 @@ public class PackDownloader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить diff (скачать новые файлы, удалить старые)
|
||||
* Apply diff (download new files, delete old ones)
|
||||
*/
|
||||
private boolean applyDiff(DiffResponse diff, String packName) {
|
||||
System.out.println(ZAnsi.cyan("\nПрименение изменений:"));
|
||||
System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов");
|
||||
System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов");
|
||||
System.out.println(ZAnsi.cyan("\nApplying changes:"));
|
||||
System.out.println(" Download: " + diff.getToDownload().size() + " files");
|
||||
System.out.println(" Delete: " + diff.getToDelete().size() + " files");
|
||||
|
||||
// Создаем директории если нужно
|
||||
// Create directories if needed
|
||||
try {
|
||||
Files.createDirectories(instance.getPath());
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage()));
|
||||
System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Удаляем файлы
|
||||
// Delete files
|
||||
for (String filePath : diff.getToDelete()) {
|
||||
Path fullPath = instance.getPath().resolve(filePath);
|
||||
try {
|
||||
if (Files.deleteIfExists(fullPath)) {
|
||||
System.out.println(ZAnsi.yellow(" Удален: " + filePath));
|
||||
System.out.println(ZAnsi.yellow(" Deleted: " + filePath));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.red(" Ошибка удаления " + filePath + ": " + e.getMessage()));
|
||||
System.err.println(ZAnsi.red(" Error deleting " + filePath + ": " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// Скачиваем файлы
|
||||
// Download files
|
||||
AtomicInteger downloaded = new AtomicInteger(0);
|
||||
int total = diff.getToDownload().size();
|
||||
|
||||
@@ -427,32 +429,32 @@ public class PackDownloader {
|
||||
Path fullPath = instance.getPath().resolve(path);
|
||||
|
||||
try {
|
||||
// Создаем директории
|
||||
// Create directories
|
||||
Files.createDirectories(fullPath.getParent());
|
||||
|
||||
// Скачиваем файл
|
||||
// Download file
|
||||
downloadFile(file, fullPath);
|
||||
|
||||
// Проверяем хеш
|
||||
// Verify hash
|
||||
String actualHash = calculateHash(fullPath);
|
||||
if (!actualHash.equals(file.getHash())) {
|
||||
throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() +
|
||||
", получен: " + actualHash);
|
||||
throw new IOException("Hash mismatch! Expected: " + file.getHash() +
|
||||
", got: " + actualHash);
|
||||
}
|
||||
|
||||
downloaded.incrementAndGet();
|
||||
if (total > 0) {
|
||||
ProgressBar.show("Скачивание", downloaded.get(), total, "файлов");
|
||||
ProgressBar.show("Download", downloaded.get(), total, "files");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage()));
|
||||
System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
ProgressBar.finish("Скачивание");
|
||||
ProgressBar.finish("Download");
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -463,12 +465,19 @@ public class PackDownloader {
|
||||
*/
|
||||
private void downloadFile(FileInfo file, Path destination) throws Exception {
|
||||
String url = ZHttpClient.getBaseUrl() + file.getUrl();
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(java.net.URI.create(url))
|
||||
.GET()
|
||||
.build();
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||
.GET();
|
||||
|
||||
if (accessToken != null && !accessToken.equals("0")) {
|
||||
builder.header("Authorization", "Bearer " + accessToken);
|
||||
}
|
||||
|
||||
HttpRequest request = builder.build();
|
||||
HttpResponse<InputStream> response = httpClient.send(request,
|
||||
HttpResponse.BodyHandlers.ofInputStream());
|
||||
|
||||
|
||||
+24
-24
@@ -26,7 +26,7 @@ public class FabricInstaller {
|
||||
}
|
||||
|
||||
public boolean install(String minecraftVersion, String loaderVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка Fabric " + loaderVersion + " для Minecraft " + minecraftVersion));
|
||||
System.out.println(ZAnsi.cyan("Installing Fabric " + loaderVersion + " for Minecraft " + minecraftVersion));
|
||||
|
||||
Path instancePath = instance.getPath();
|
||||
cleanOldFabricLoaders();
|
||||
@@ -34,7 +34,7 @@ public class FabricInstaller {
|
||||
VersionInstaller versionInstaller = new VersionInstaller(instancePath);
|
||||
String assetIndex = versionInstaller.install(minecraftVersion);
|
||||
|
||||
System.out.println(ZAnsi.green("Asset index получен: " + assetIndex));
|
||||
System.out.println(ZAnsi.green("Asset index obtained: " + assetIndex));
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
instance.setMinecraftVersion(minecraftVersion);
|
||||
@@ -46,12 +46,12 @@ public class FabricInstaller {
|
||||
Path installerJar = instancePath.resolve("fabric-installer.jar");
|
||||
|
||||
if (!Files.exists(installerJar)) {
|
||||
ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%");
|
||||
ProgressBar.show("Downloading Fabric Installer", 0, 100, "%");
|
||||
downloadFileWithFallback(installerUrl, installerJar);
|
||||
ProgressBar.finish("Fabric Installer скачан");
|
||||
ProgressBar.finish("Fabric Installer downloaded");
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.cyan("Запуск Fabric Installer..."));
|
||||
System.out.println(ZAnsi.cyan("Running Fabric Installer..."));
|
||||
|
||||
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
||||
|
||||
@@ -71,24 +71,24 @@ public class FabricInstaller {
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
if (exitCode != 0) {
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer завершился с ошибкой (код " + exitCode + ")"));
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer failed (code " + exitCode + ")"));
|
||||
return false;
|
||||
}
|
||||
|
||||
Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId);
|
||||
|
||||
if (Files.exists(fabricVersionDir)) {
|
||||
System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!"));
|
||||
System.out.println(ZAnsi.brightGreen("Fabric installed successfully!"));
|
||||
|
||||
instance.setLoaderType("fabric");
|
||||
instance.setLoaderVersion(loaderVersion);
|
||||
instance.setFabricVersionId(fabricVersionId); // ← СОХРАНЯЕМ
|
||||
instance.setFabricVersionId(fabricVersionId);
|
||||
|
||||
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена."));
|
||||
System.out.println(ZAnsi.brightRed("Fabric Installer ran, but version not found."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public class FabricInstaller {
|
||||
try {
|
||||
ZHttpClient.downloadFile(url, target);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось скачать Fabric Installer: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.yellow("Failed to download Fabric Installer: " + e.getMessage()));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -106,28 +106,28 @@ public class FabricInstaller {
|
||||
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
|
||||
|
||||
if (!Files.exists(versionJson)) {
|
||||
System.out.println(ZAnsi.yellow("JSON файл версии не найден: " + versionJson));
|
||||
System.out.println(ZAnsi.yellow("Version JSON file not found: " + versionJson));
|
||||
return;
|
||||
}
|
||||
|
||||
String content = Files.readString(versionJson);
|
||||
|
||||
// Проверяем и исправляем asset index
|
||||
// Check and fix asset index
|
||||
if (!content.contains("\"assets\":\"" + assetIndex + "\"")) {
|
||||
System.out.println(ZAnsi.yellow("Исправляем asset index в JSON файле версии..."));
|
||||
System.out.println(ZAnsi.yellow("Fixing asset index in version JSON file..."));
|
||||
|
||||
// Заменяем assets на правильное значение
|
||||
// Replace assets with correct value
|
||||
content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\"");
|
||||
|
||||
// Также проверяем assetIndex
|
||||
// Also check assetIndex
|
||||
if (content.contains("\"assetIndex\"")) {
|
||||
content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\"");
|
||||
}
|
||||
|
||||
Files.writeString(versionJson, content);
|
||||
System.out.println(ZAnsi.green("Asset index исправлен на: " + assetIndex));
|
||||
System.out.println(ZAnsi.green("Asset index fixed to: " + assetIndex));
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Asset index в JSON версии правильный: " + assetIndex));
|
||||
System.out.println(ZAnsi.green("Asset index in version JSON is correct: " + assetIndex));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ public class FabricInstaller {
|
||||
Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader");
|
||||
if (!Files.exists(librariesDir)) return;
|
||||
|
||||
System.out.println(ZAnsi.yellow("Очистка старых версий Fabric Loader..."));
|
||||
System.out.println(ZAnsi.yellow("Cleaning old Fabric Loader versions..."));
|
||||
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(Files::isDirectory)
|
||||
@@ -155,18 +155,18 @@ public class FabricInstaller {
|
||||
|
||||
private String getLatestInstallerVersion() throws Exception {
|
||||
try {
|
||||
// Используем ZHttpClient с умным прокси
|
||||
// Use ZHttpClient with smart proxy
|
||||
String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml");
|
||||
int start = xml.indexOf("<latest>") + 8;
|
||||
int end = xml.indexOf("</latest>", start);
|
||||
return xml.substring(start, end).trim();
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Ошибка получения версии Fabric Installer: " + e.getMessage()));
|
||||
throw new Exception("Не удалось получить версию Fabric Installer", e);
|
||||
System.out.println(ZAnsi.yellow("Error getting Fabric Installer version: " + e.getMessage()));
|
||||
throw new Exception("Failed to get Fabric Installer version", e);
|
||||
}
|
||||
}
|
||||
|
||||
// под рефактор оставить
|
||||
// under refactor - keep
|
||||
private String downloadString(String url) throws Exception {
|
||||
Exception lastException = null;
|
||||
|
||||
@@ -186,7 +186,7 @@ public class FabricInstaller {
|
||||
throw new IOException("HTTP " + resp.statusCode());
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
System.out.println(ZAnsi.yellow("Попытка " + attempt + " не удалась: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.yellow("Attempt " + attempt + " failed: " + e.getMessage()));
|
||||
if (attempt < 3) {
|
||||
Thread.sleep(1000 * attempt);
|
||||
}
|
||||
@@ -207,7 +207,7 @@ public class FabricInstaller {
|
||||
HttpResponse.BodyHandlers.ofFile(target));
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("HTTP " + response.statusCode() + " при скачивании " + url);
|
||||
throw new IOException("HTTP " + response.statusCode() + " when downloading " + url);
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-53
@@ -11,7 +11,9 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class ForgeInstaller {
|
||||
@@ -26,59 +28,59 @@ public class ForgeInstaller {
|
||||
}
|
||||
|
||||
public boolean install(String mcVersion, String forgeVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка Forge " + forgeVersion + " для Minecraft " + mcVersion));
|
||||
System.out.println(ZAnsi.cyan("Installing Forge " + forgeVersion + " for Minecraft " + mcVersion));
|
||||
|
||||
// Шаг 1: Устанавливаем vanilla и получаем настоящий assetIndex
|
||||
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
|
||||
// Step 1: Install vanilla and get real assetIndex
|
||||
System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "..."));
|
||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||
|
||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
|
||||
System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
|
||||
return false;
|
||||
}
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
|
||||
// Шаг 2: Создаём launcher_profiles.json
|
||||
// Step 2: Create launcher_profiles.json
|
||||
createLauncherProfile();
|
||||
|
||||
// Шаг 3: Скачиваем Forge Installer с прогресс-баром
|
||||
// Step 3: Download Forge Installer with progress bar
|
||||
String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/"
|
||||
+ mcVersion + "-" + forgeVersion
|
||||
+ "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar";
|
||||
|
||||
Path installerJar = instance.getPath().resolve("forge-installer.jar");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание Forge Installer..."));
|
||||
System.out.println(ZAnsi.cyan("Downloading Forge Installer..."));
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
// Шаг 4: Запускаем Forge Installer и показываем его вывод
|
||||
System.out.println(ZAnsi.cyan("Запуск Forge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n"));
|
||||
// Step 4: Run Forge Installer and show its output
|
||||
System.out.println(ZAnsi.cyan("Running Forge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
|
||||
|
||||
boolean success = runForgeInstaller(installerJar);
|
||||
|
||||
// После успешной установки Forge, но перед сохранением метаданных
|
||||
// After successful Forge install, before saving metadata
|
||||
if (success) {
|
||||
// Докачиваем пропущенные библиотеки
|
||||
// Download missing libraries
|
||||
try {
|
||||
downloadMissingLibraries(mcVersion, forgeVersion);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " успешно установлен!"));
|
||||
System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " installed successfully!"));
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType("forge");
|
||||
instance.setLoaderVersion(forgeVersion);
|
||||
|
||||
// Очищаем временный файл установщика
|
||||
// Clean up temporary installer file
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\nОшибка при установке Forge!"));
|
||||
System.out.println(ZAnsi.brightRed("\nError installing Forge!"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +96,7 @@ public class ForgeInstaller {
|
||||
}
|
||||
""";
|
||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
System.out.println(ZAnsi.yellow("Создан launcher_profiles.json"));
|
||||
System.out.println(ZAnsi.yellow("Created launcher_profiles.json"));
|
||||
}
|
||||
|
||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||
@@ -132,10 +134,10 @@ public class ForgeInstaller {
|
||||
lastPercent = percent;
|
||||
}
|
||||
} else {
|
||||
// Если размер неизвестен, показываем анимацию
|
||||
// If size unknown, show animation
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (totalRead / 1024) % 4;
|
||||
System.out.print("\rСкачивание Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
||||
System.out.print("\rDownloading Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,12 +146,12 @@ public class ForgeInstaller {
|
||||
}
|
||||
|
||||
private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException {
|
||||
// Пробуем до 3 раз с разными опциями
|
||||
// Try up to 3 times with different options
|
||||
int maxRetries = 3;
|
||||
int attempt = 1;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries));
|
||||
System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"java",
|
||||
@@ -158,7 +160,7 @@ public class ForgeInstaller {
|
||||
"--installClient"
|
||||
);
|
||||
|
||||
// Добавляем JVM аргументы для увеличения таймаутов
|
||||
// Add JVM args for increased timeouts
|
||||
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
|
||||
|
||||
pb.directory(instance.getPath().toFile());
|
||||
@@ -166,7 +168,7 @@ public class ForgeInstaller {
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
// Читаем вывод в реальном времени
|
||||
// Read output in real time
|
||||
StringBuilder output = new StringBuilder();
|
||||
boolean hasErrors = false;
|
||||
|
||||
@@ -175,7 +177,7 @@ public class ForgeInstaller {
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
|
||||
// Форматируем вывод Forge Installer
|
||||
// Format Forge Installer output
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
System.out.println(ZAnsi.blue(" -> " + line));
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
@@ -195,17 +197,17 @@ public class ForgeInstaller {
|
||||
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
// Если успешно или нет ошибок скачивания
|
||||
// If successful or no download errors
|
||||
if (exitCode == 0 && !hasErrors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Если ошибка и это не последняя попытка
|
||||
// If error and not last attempt
|
||||
if (attempt < maxRetries) {
|
||||
System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд..."));
|
||||
System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
|
||||
Thread.sleep(5000);
|
||||
|
||||
// Очищаем временные файлы перед повтором
|
||||
// Clean temp files before retry
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
// Удаляем только частично скачанные библиотеки Forge
|
||||
@@ -218,15 +220,15 @@ public class ForgeInstaller {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Forge Installer завершился с кодом ошибки: " + exitCode));
|
||||
System.out.println(ZAnsi.brightRed("Forge Installer exited with error code: " + exitCode));
|
||||
|
||||
// Показываем возможное решение
|
||||
// Show possible solution
|
||||
if (output.toString().contains("timed out")) {
|
||||
System.out.println(ZAnsi.yellow("\nВозможные решения:"));
|
||||
System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение"));
|
||||
System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора"));
|
||||
System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр"));
|
||||
System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию Forge"));
|
||||
System.out.println(ZAnsi.yellow("\nPossible solutions:"));
|
||||
System.out.println(ZAnsi.yellow("1. Check your internet connection"));
|
||||
System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
|
||||
System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
|
||||
System.out.println(ZAnsi.yellow("4. Try installing a different Forge version"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,32 +239,38 @@ public class ForgeInstaller {
|
||||
}
|
||||
|
||||
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек..."));
|
||||
|
||||
// Список проблемных библиотек и их альтернативные URL
|
||||
Map<String, String> alternativeUrls = new HashMap<>();
|
||||
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
|
||||
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
|
||||
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar");
|
||||
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
||||
|
||||
// List of problematic libraries and their alternate URLs
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
|
||||
for (Map.Entry<String, String> entry : alternativeUrls.entrySet()) {
|
||||
// Map from maven path to list of mirror URLs (tried in order)
|
||||
Map<String, List<String>> alternativeUrls = new HashMap<>();
|
||||
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", Arrays.asList(
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar",
|
||||
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"
|
||||
));
|
||||
|
||||
for (Map.Entry<String, List<String>> entry : alternativeUrls.entrySet()) {
|
||||
Path target = librariesDir.resolve(entry.getKey());
|
||||
if (!Files.exists(target)) {
|
||||
Files.createDirectories(target.getParent());
|
||||
System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName()));
|
||||
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
|
||||
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
downloadFileWithProgress(entry.getValue(), target);
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3) throw e;
|
||||
System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3..."));
|
||||
Thread.sleep(2000);
|
||||
boolean downloaded = false;
|
||||
for (String mirrorUrl : entry.getValue()) {
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
downloadFileWithProgress(mirrorUrl, target);
|
||||
downloaded = true;
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e;
|
||||
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
|
||||
try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
if (downloaded) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-22
@@ -27,14 +27,14 @@ public class NeoForgeInstaller {
|
||||
}
|
||||
|
||||
public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка NeoForge " + neoForgeVersion + " для Minecraft " + mcVersion));
|
||||
System.out.println(ZAnsi.cyan("Installing NeoForge " + neoForgeVersion + " for Minecraft " + mcVersion));
|
||||
|
||||
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
|
||||
System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "..."));
|
||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
|
||||
System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -52,11 +52,11 @@ public class NeoForgeInstaller {
|
||||
|
||||
Path installerJar = instance.getPath().resolve("neoforge-installer.jar");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание NeoForge Installer..."));
|
||||
System.out.println(ZAnsi.cyan("Downloading NeoForge Installer..."));
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Запуск NeoForge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n"));
|
||||
System.out.println(ZAnsi.cyan("Running NeoForge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
|
||||
|
||||
boolean success = runNeoForgeInstaller(installerJar);
|
||||
|
||||
@@ -64,10 +64,10 @@ public class NeoForgeInstaller {
|
||||
try {
|
||||
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " успешно установлен!"));
|
||||
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " installed successfully!"));
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType("neoforge");
|
||||
instance.setLoaderVersion(neoForgeVersion);
|
||||
@@ -75,7 +75,7 @@ public class NeoForgeInstaller {
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\nОшибка при установке NeoForge!"));
|
||||
System.out.println(ZAnsi.brightRed("\nError installing NeoForge!"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ public class NeoForgeInstaller {
|
||||
}
|
||||
""";
|
||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
System.out.println(ZAnsi.yellow("Создан launcher_profiles.json"));
|
||||
System.out.println(ZAnsi.yellow("Created launcher_profiles.json"));
|
||||
}
|
||||
|
||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||
@@ -145,7 +145,7 @@ public class NeoForgeInstaller {
|
||||
} else {
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (totalRead / 1024) % 4;
|
||||
System.out.print("\rСкачивание NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
||||
System.out.print("\rDownloading NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ public class NeoForgeInstaller {
|
||||
int attempt = 1;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries));
|
||||
System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"java",
|
||||
@@ -205,7 +205,7 @@ public class NeoForgeInstaller {
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд..."));
|
||||
System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
|
||||
Thread.sleep(5000);
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
@@ -219,14 +219,14 @@ public class NeoForgeInstaller {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("NeoForge Installer завершился с кодом ошибки: " + exitCode));
|
||||
System.out.println(ZAnsi.brightRed("NeoForge Installer exited with error code: " + 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"));
|
||||
System.out.println(ZAnsi.yellow("\nPossible solutions:"));
|
||||
System.out.println(ZAnsi.yellow("1. Check your internet connection"));
|
||||
System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
|
||||
System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
|
||||
System.out.println(ZAnsi.yellow("4. Try installing a different NeoForge version"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ public class NeoForgeInstaller {
|
||||
}
|
||||
|
||||
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек..."));
|
||||
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
||||
|
||||
Map<String, String> alternativeUrls = new HashMap<>();
|
||||
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
|
||||
@@ -253,7 +253,7 @@ public class NeoForgeInstaller {
|
||||
Path target = librariesDir.resolve(entry.getKey());
|
||||
if (!Files.exists(target)) {
|
||||
Files.createDirectories(target.getParent());
|
||||
System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName()));
|
||||
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
|
||||
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
@@ -261,7 +261,7 @@ public class NeoForgeInstaller {
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3) throw e;
|
||||
System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3..."));
|
||||
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
+36
-35
@@ -57,12 +57,12 @@ public class VersionInstaller {
|
||||
}
|
||||
|
||||
public String install(String versionId) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Полная установка Minecraft " + versionId + "..."));
|
||||
System.out.println(ZAnsi.cyan("Full install of Minecraft " + versionId + "..."));
|
||||
Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
|
||||
Files.createDirectories(versionDir);
|
||||
|
||||
String versionUrl = getVersionUrl(versionId);
|
||||
if (versionUrl == null) throw new Exception("Версия " + versionId + " не найдена");
|
||||
if (versionUrl == null) throw new Exception("Version " + versionId + " not found");
|
||||
|
||||
String versionJson = downloadString(versionUrl);
|
||||
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
|
||||
@@ -73,8 +73,8 @@ public class VersionInstaller {
|
||||
downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
|
||||
versionDir.resolve(versionId + ".jar"), "client.jar");
|
||||
|
||||
// Библиотеки
|
||||
System.out.println(ZAnsi.cyan("Скачивание библиотек..."));
|
||||
// Libraries
|
||||
System.out.println(ZAnsi.cyan("Downloading libraries..."));
|
||||
downloadLibraries(versionData.getJSONArray("libraries"));
|
||||
|
||||
String assetIndex;
|
||||
@@ -86,12 +86,12 @@ public class VersionInstaller {
|
||||
|
||||
System.out.println(ZAnsi.cyan("Asset index: " + assetIndex));
|
||||
|
||||
// Скачиваем ассеты используя правильный индекс
|
||||
System.out.println(ZAnsi.cyan("Скачивание ассетов..."));
|
||||
// Download assets using correct index
|
||||
System.out.println(ZAnsi.cyan("Downloading assets..."));
|
||||
downloadAssets(versionData, assetIndex);
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " полностью установлен!"));
|
||||
return assetIndex; // ← возвращаем "5" а не "1.20.1"
|
||||
System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " fully installed!"));
|
||||
return assetIndex;
|
||||
}
|
||||
|
||||
private void downloadLibraries(JSONArray libraries) throws Exception {
|
||||
@@ -111,32 +111,32 @@ public class VersionInstaller {
|
||||
try {
|
||||
downloadFile(url, target, "library");
|
||||
} catch (Exception e) {
|
||||
// Пропускаем проблемные библиотеки
|
||||
// Skip problematic libraries
|
||||
}
|
||||
}
|
||||
count++;
|
||||
ProgressBar.show("Библиотеки", count, total, "файлов");
|
||||
ProgressBar.show("Libraries", count, total, "files");
|
||||
}
|
||||
ProgressBar.finish("Библиотеки загружены");
|
||||
ProgressBar.finish("Libraries downloaded");
|
||||
}
|
||||
|
||||
private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception {
|
||||
// Находим URL для asset index
|
||||
// Find URL for asset index
|
||||
JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex");
|
||||
String indexUrl = assetIndexInfo.getString("url");
|
||||
|
||||
Path indexesDir = minecraftDir.resolve("assets/indexes");
|
||||
Files.createDirectories(indexesDir);
|
||||
Path indexPath = indexesDir.resolve(assetIndex + ".json"); // ← используем assetIndex
|
||||
Path indexPath = indexesDir.resolve(assetIndex + ".json");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание asset index (" + assetIndex + ")..."));
|
||||
System.out.println(ZAnsi.cyan("Downloading asset index (" + assetIndex + ")..."));
|
||||
downloadFile(indexUrl, indexPath, "asset index");
|
||||
|
||||
String jsonContent = Files.readString(indexPath);
|
||||
JSONObject root = new JSONObject(jsonContent);
|
||||
JSONObject objects = root.getJSONObject("objects");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание " + objects.length() + " объектов ассетов (index: " + assetIndex + ")..."));
|
||||
System.out.println(ZAnsi.cyan("Downloading " + objects.length() + " asset objects (index: " + assetIndex + ")..."));
|
||||
|
||||
int total = objects.length();
|
||||
int[] success = {0};
|
||||
@@ -146,7 +146,7 @@ public class VersionInstaller {
|
||||
|
||||
for (String key : objects.keySet()) {
|
||||
JSONObject asset = objects.getJSONObject(key);
|
||||
String hash = asset.getString("hash"); // ← вот это правильный хеш!
|
||||
String hash = asset.getString("hash");
|
||||
|
||||
String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash;
|
||||
Path target = minecraftDir.resolve("assets/objects")
|
||||
@@ -160,19 +160,19 @@ public class VersionInstaller {
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
downloadFile(url, target, "");
|
||||
synchronized (this) {
|
||||
success[0]++;
|
||||
ProgressBar.show("Ассеты", success[0], total, "файлов");
|
||||
}
|
||||
downloaded = true;
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3) {
|
||||
synchronized (this) {
|
||||
failed[0]++;
|
||||
success[0]++;
|
||||
ProgressBar.show("Assets", success[0], total, "files");
|
||||
}
|
||||
System.err.println("Не удалось скачать " + hash);
|
||||
} else {
|
||||
downloaded = true;
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3) {
|
||||
synchronized (this) {
|
||||
failed[0]++;
|
||||
}
|
||||
System.err.println("Failed to download " + hash);
|
||||
} else {
|
||||
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
@@ -183,18 +183,19 @@ public class VersionInstaller {
|
||||
}
|
||||
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||
executor.shutdown();
|
||||
|
||||
ProgressBar.finish("Ассеты загружены (" + success[0] + " успешно, " + failed[0] + " пропущено)");
|
||||
ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)");
|
||||
|
||||
if (failed[0] > 0) {
|
||||
System.out.println(ZAnsi.yellow("Предупреждение: " + failed[0] + " файлов ассетов не удалось скачать."));
|
||||
System.out.println(ZAnsi.yellow("Игра запустится, но некоторые текстуры/звуки могут отсутствовать."));
|
||||
System.out.println(ZAnsi.yellow("Warning: " + failed[0] + " asset files could not be downloaded."));
|
||||
System.out.println(ZAnsi.yellow("The game will launch, but some textures/sounds may be missing."));
|
||||
}
|
||||
}
|
||||
|
||||
public String getAssetIndexId(String versionId) throws Exception {
|
||||
String versionUrl = getVersionUrl(versionId);
|
||||
if (versionUrl == null) throw new Exception("Версия не найдена");
|
||||
if (versionUrl == null) throw new Exception("Version not found");
|
||||
|
||||
String versionJson = downloadString(versionUrl);
|
||||
JSONObject versionData = new JSONObject(versionJson);
|
||||
@@ -202,7 +203,7 @@ public class VersionInstaller {
|
||||
if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) {
|
||||
return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1
|
||||
}
|
||||
return versionData.getString("assets"); // fallback (очень старые версии)
|
||||
return versionData.getString("assets"); // fallback (very old versions)
|
||||
}
|
||||
|
||||
private String getVersionUrl(String versionId) throws Exception {
|
||||
@@ -222,7 +223,7 @@ public class VersionInstaller {
|
||||
private void downloadFile(String url, Path target, String label) throws Exception {
|
||||
if (!label.isEmpty()) {
|
||||
ProgressBar.clearLine();
|
||||
System.out.println(ZAnsi.cyan("Скачивание " + label + "..."));
|
||||
System.out.println(ZAnsi.cyan("Downloading " + label + "..."));
|
||||
}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
@@ -233,8 +234,8 @@ public class VersionInstaller {
|
||||
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
if (label.isEmpty()) return; // для ассетов молча
|
||||
throw new IOException("HTTP " + response.statusCode() + " при скачивании " + label);
|
||||
if (label.isEmpty()) return; // for assets silently
|
||||
throw new IOException("HTTP " + response.statusCode() + " while downloading " + label);
|
||||
}
|
||||
|
||||
if (!label.isEmpty()) {
|
||||
|
||||
+10
-9
@@ -21,11 +21,12 @@ public class LaunchCommandBuilder {
|
||||
}
|
||||
|
||||
public List<String> build(LaunchOptions options) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "..."));
|
||||
System.out.println(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
|
||||
String javaPath = "java";
|
||||
String javaPath = options.getJavaPath() != null && !options.getJavaPath().isEmpty()
|
||||
? options.getJavaPath() : "java";
|
||||
command.add(javaPath);
|
||||
|
||||
command.addAll(getJvmArguments(options));
|
||||
@@ -53,7 +54,7 @@ public class LaunchCommandBuilder {
|
||||
|
||||
// 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"));
|
||||
System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
|
||||
command.add("-cp");
|
||||
command.add(buildVanillaClasspath());
|
||||
command.add(getVanillaMainClass());
|
||||
@@ -83,15 +84,15 @@ public class LaunchCommandBuilder {
|
||||
if (versionJson != null && Files.exists(versionJson)) {
|
||||
String content = Files.readString(versionJson);
|
||||
JSONObject json = new JSONObject(content);
|
||||
System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName()));
|
||||
System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
|
||||
return new VersionManifest(json);
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("version.json не найден для " + instance.getName()));
|
||||
System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName()));
|
||||
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
|
||||
System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage()));
|
||||
System.out.println(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -251,9 +252,9 @@ public class LaunchCommandBuilder {
|
||||
String assetIndex = instance.getAssetIndex();
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
assetIndex = instance.getMinecraftVersion();
|
||||
System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex));
|
||||
System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Использую asset index: " + assetIndex));
|
||||
System.out.println(ZAnsi.green("Using asset index: " + assetIndex));
|
||||
}
|
||||
args.add(assetIndex);
|
||||
args.add("--username");
|
||||
@@ -332,7 +333,7 @@ public class LaunchCommandBuilder {
|
||||
if (Files.exists(fallbackPath)) {
|
||||
paths.add(fallbackPath.toAbsolutePath().toString());
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Библиотека не найдена: " + lib.name));
|
||||
System.out.println(ZAnsi.yellow(" Library not found: " + lib.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -37,5 +37,7 @@ public class LaunchOptions {
|
||||
public void setExtraJvmArgs(List<String> extraJvmArgs) { this.extraJvmArgs = extraJvmArgs; }
|
||||
|
||||
public int getWidth() { return width; }
|
||||
public void setWidth(int width) { this.width = width; }
|
||||
public int getHeight() { return height; }
|
||||
public void setHeight(int height) { this.height = height; }
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import org.jline.terminal.TerminalBuilder;
|
||||
import org.jline.utils.InfoCmp;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
public class ArrowMenu {
|
||||
@@ -14,16 +16,22 @@ public class ArrowMenu {
|
||||
private final List<String> options;
|
||||
private int selected = 0;
|
||||
private final Terminal terminal;
|
||||
private final InputStream rawInput;
|
||||
|
||||
private static final int VISIBLE_ITEMS = 7; // сколько строк показывать в списке
|
||||
private static final int VISIBLE_ITEMS = 7;
|
||||
|
||||
public ArrowMenu(String title, List<String> options) throws IOException {
|
||||
this.title = title;
|
||||
this.options = options;
|
||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows");
|
||||
System.setProperty("jline.terminal", isWindows ? "win" : "unsupported");
|
||||
this.terminal = TerminalBuilder.builder()
|
||||
.system(true)
|
||||
.jna(true)
|
||||
.jna(isWindows)
|
||||
.jansi(true)
|
||||
.encoding(StandardCharsets.UTF_8)
|
||||
.build();
|
||||
this.rawInput = terminal.input();
|
||||
}
|
||||
|
||||
public int show() throws IOException {
|
||||
@@ -34,33 +42,43 @@ public class ArrowMenu {
|
||||
try {
|
||||
while (true) {
|
||||
printPagedMenu();
|
||||
int key = terminal.reader().read();
|
||||
int b = rawInput.read();
|
||||
if (b == -1) continue;
|
||||
|
||||
if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц'
|
||||
|| key == 'k' || key == 'K' || key == 'л' || key == 'Л') { // Up / Arrow Up
|
||||
// w/W/k/K or ц (0xD1 0x86) = up
|
||||
// s/S/j/J or ы (0xD1 0x8B) = down
|
||||
if (b == 'w' || b == 'W' || b == 'k' || b == 'K') {
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
}
|
||||
else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы'
|
||||
|| key == 'j' || key == 'J' || key == 'о' || key == 'О') { // Down / Arrow Down
|
||||
else if (b == 's' || b == 'S' || b == 'j' || b == 'J') {
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
else if (key == 13 || key == 10) { // Enter
|
||||
return selected;
|
||||
}
|
||||
else if (key == 27) { // Esc or arrow escape seq
|
||||
int next = terminal.reader().read();
|
||||
if (next == 91) { // '[' — start of arrow escape sequence
|
||||
int arrow = terminal.reader().read();
|
||||
if (arrow == 65) { // 'A' — Up arrow
|
||||
// ESC sequences: arrows + cyrillic start byte
|
||||
else if (b == 0x1B) {
|
||||
int next = nonBlockingRead();
|
||||
if (next == -1) {
|
||||
return -1;
|
||||
}
|
||||
if (next == 0x5B || next == 0x4F) { // '[' (CSI) or 'O' (SS3)
|
||||
int arrow = nonBlockingRead();
|
||||
if (arrow == 0x41) { // Up
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
} else if (arrow == 66) { // 'B' — Down arrow
|
||||
} else if (arrow == 0x42) { // Down
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
// else — unknown escape seq, ignore
|
||||
} else {
|
||||
return -1; // genuine Esc
|
||||
}
|
||||
}
|
||||
else if (b == 0xD1) {
|
||||
int second = nonBlockingRead();
|
||||
if (second == 0x86) { // ц
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
} else if (second == 0x8B) { // ы
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
}
|
||||
else if (b == 13 || b == 10) {
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
terminal.puts(InfoCmp.Capability.cursor_visible);
|
||||
@@ -68,19 +86,31 @@ public class ArrowMenu {
|
||||
}
|
||||
}
|
||||
|
||||
private int nonBlockingRead() throws IOException {
|
||||
long startTime = System.currentTimeMillis();
|
||||
while (System.currentTimeMillis() - startTime < 100) {
|
||||
if (rawInput.available() > 0) {
|
||||
return rawInput.read();
|
||||
}
|
||||
try {
|
||||
Thread.sleep(2);
|
||||
} catch (InterruptedException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void printPagedMenu() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\033[H\033[2J");
|
||||
|
||||
// Заголовок (фиксированный)
|
||||
sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n");
|
||||
sb.append(ZAnsi.yellow(title)).append("\n\n");
|
||||
|
||||
// Вычисляем диапазон отображаемых элементов
|
||||
int start = Math.max(0, selected - (VISIBLE_ITEMS / 2));
|
||||
int end = Math.min(options.size(), start + VISIBLE_ITEMS);
|
||||
|
||||
// Если в конце списка — подтягиваем вверх
|
||||
if (end - start < VISIBLE_ITEMS && start > 0) {
|
||||
start = Math.max(0, end - VISIBLE_ITEMS);
|
||||
}
|
||||
@@ -94,10 +124,10 @@ public class ArrowMenu {
|
||||
}
|
||||
}
|
||||
|
||||
// Подсказка внизу (фиксированная)
|
||||
sb.append("\n")
|
||||
.append(ZAnsi.white("W/S (Ц/Ы) или ↑/↓ - перемещение | Enter - выбрать | Esc - назад"));
|
||||
.append(ZAnsi.white("W/S or \u2191/\u2193 - navigate | Enter - select | Esc - back"));
|
||||
|
||||
System.out.print(sb);
|
||||
System.out.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+968
-49
File diff suppressed because it is too large
Load Diff
@@ -14,14 +14,22 @@ public class Config {
|
||||
|
||||
private static final Properties props = new Properties();
|
||||
|
||||
// Настройки
|
||||
private static int maxMemory = 4096; // будет перезаписано умной логикой
|
||||
private static String serverUrl = "http://87.120.187.36:1582";
|
||||
private static String lastUsername = "Player";
|
||||
private static volatile int maxMemory = 4096;
|
||||
private static volatile String serverUrl = "http://87.120.187.36:1582";
|
||||
private static volatile String lastUsername = "Player";
|
||||
private static volatile int windowWidth = 1280;
|
||||
private static volatile int windowHeight = 720;
|
||||
private static volatile String extraJvmArgs = "";
|
||||
private static volatile String javaPath = "java";
|
||||
private static volatile boolean ramManuallySet = false;
|
||||
private static volatile String locale = "en";
|
||||
private static volatile boolean systemBasedJvm = false;
|
||||
|
||||
static {
|
||||
load();
|
||||
applySmartRamRecommendation();
|
||||
if (!ramManuallySet) {
|
||||
applySmartRamRecommendation();
|
||||
}
|
||||
}
|
||||
|
||||
private static void load() {
|
||||
@@ -33,54 +41,94 @@ public class Config {
|
||||
}
|
||||
}
|
||||
|
||||
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
|
||||
try {
|
||||
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
|
||||
} catch (NumberFormatException e) {
|
||||
System.err.println(ZAnsi.yellow("Config: invalid maxMemory value, using default"));
|
||||
}
|
||||
ramManuallySet = Boolean.parseBoolean(props.getProperty("ramManuallySet", "false"));
|
||||
serverUrl = props.getProperty("serverUrl", serverUrl);
|
||||
lastUsername = props.getProperty("lastUsername", lastUsername);
|
||||
try {
|
||||
windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280"));
|
||||
} catch (NumberFormatException e) {
|
||||
System.err.println(ZAnsi.yellow("Config: invalid windowWidth value, using default"));
|
||||
}
|
||||
try {
|
||||
windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720"));
|
||||
} catch (NumberFormatException e) {
|
||||
System.err.println(ZAnsi.yellow("Config: invalid windowHeight value, using default"));
|
||||
}
|
||||
extraJvmArgs = props.getProperty("extraJvmArgs", "");
|
||||
javaPath = props.getProperty("javaPath", "java");
|
||||
locale = props.getProperty("locale", "en");
|
||||
systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false"));
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось загрузить конфиг: ") + e.getMessage());
|
||||
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void save() {
|
||||
try {
|
||||
props.setProperty("maxMemory", String.valueOf(maxMemory));
|
||||
props.setProperty("ramManuallySet", String.valueOf(ramManuallySet));
|
||||
props.setProperty("serverUrl", serverUrl);
|
||||
props.setProperty("lastUsername", lastUsername);
|
||||
props.setProperty("windowWidth", String.valueOf(windowWidth));
|
||||
props.setProperty("windowHeight", String.valueOf(windowHeight));
|
||||
props.setProperty("extraJvmArgs", extraJvmArgs);
|
||||
props.setProperty("javaPath", javaPath);
|
||||
props.setProperty("locale", locale);
|
||||
props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm));
|
||||
|
||||
try (var os = Files.newOutputStream(CONFIG_FILE)) {
|
||||
props.store(os, "ZernMC Launcher Configuration");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось сохранить конфиг: ") + e.getMessage());
|
||||
System.err.println(ZAnsi.brightRed("Failed to save config: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Умная рекомендация RAM:
|
||||
* - минимум 1.5 GB
|
||||
* - рекомендуется totalRAM - 30%
|
||||
* - максимум 70% от доступной RAM
|
||||
*/
|
||||
private static void applySmartRamRecommendation() {
|
||||
long totalRamMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); // в MB
|
||||
long totalRamMB = getTotalSystemRamMB();
|
||||
if (totalRamMB <= 0) return;
|
||||
|
||||
// Рекомендуемое значение = total - 30%
|
||||
long recommended = (long) (totalRamMB * 0.70); // 70% от доступной
|
||||
long recommended;
|
||||
if (totalRamMB <= 8192) {
|
||||
recommended = 2560;
|
||||
} else if (totalRamMB <= 12288) {
|
||||
recommended = 3072;
|
||||
} else if (totalRamMB <= 16384) {
|
||||
recommended = 4096;
|
||||
} else {
|
||||
recommended = 5120;
|
||||
}
|
||||
|
||||
// Ограничения
|
||||
recommended = Math.max(1536, recommended); // минимум 1.5 GB
|
||||
recommended = Math.min(recommended, totalRamMB - 1024); // оставляем минимум 1 GB системе
|
||||
|
||||
// Если текущее значение сильно отличается от рекомендуемого — корректируем
|
||||
if (Math.abs(maxMemory - recommended) > 1024) { // разница больше 1 GB
|
||||
if (Math.abs(maxMemory - recommended) > 512) {
|
||||
maxMemory = (int) recommended;
|
||||
save(); // сохраняем умную рекомендацию
|
||||
System.out.println(ZAnsi.cyan("Автоматически рекомендовано RAM: " + maxMemory + " MB"));
|
||||
save();
|
||||
System.out.println(ZAnsi.cyan("Auto-recommended RAM: " + maxMemory + " MB"));
|
||||
}
|
||||
}
|
||||
|
||||
// Getters & Setters
|
||||
public static void resetRamRecommendation() {
|
||||
ramManuallySet = false;
|
||||
applySmartRamRecommendation();
|
||||
}
|
||||
|
||||
private static long getTotalSystemRamMB() {
|
||||
try {
|
||||
Class<?> beanClass = Class.forName("com.sun.management.OperatingSystemMXBean");
|
||||
java.lang.management.OperatingSystemMXBean osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
|
||||
if (beanClass.isInstance(osBean)) {
|
||||
Object totalBytes = beanClass.getMethod("getTotalMemorySize").invoke(osBean);
|
||||
return ((Number) totalBytes).longValue() / (1024 * 1024);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int getMaxMemory() {
|
||||
return maxMemory;
|
||||
}
|
||||
@@ -94,11 +142,11 @@ public class Config {
|
||||
}
|
||||
|
||||
public static void setMaxMemory(int memory) {
|
||||
// Защита от слишком маленьких/больших значений
|
||||
if (memory < 1024) memory = 1536;
|
||||
if (memory > 32768) memory = 32768;
|
||||
|
||||
maxMemory = memory;
|
||||
ramManuallySet = true;
|
||||
save();
|
||||
}
|
||||
|
||||
@@ -127,11 +175,93 @@ public class Config {
|
||||
return CONFIG_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полезная информация для пользователя
|
||||
*/
|
||||
public static int getWindowWidth() {
|
||||
return windowWidth;
|
||||
}
|
||||
|
||||
public static void setWindowWidth(int width) {
|
||||
windowWidth = Math.max(640, width);
|
||||
save();
|
||||
}
|
||||
|
||||
public static int getWindowHeight() {
|
||||
return windowHeight;
|
||||
}
|
||||
|
||||
public static void setWindowHeight(int height) {
|
||||
windowHeight = Math.max(480, height);
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getExtraJvmArgs() {
|
||||
return extraJvmArgs;
|
||||
}
|
||||
|
||||
public static void setExtraJvmArgs(String args) {
|
||||
extraJvmArgs = args != null ? args : "";
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getJavaPath() {
|
||||
return javaPath;
|
||||
}
|
||||
|
||||
public static void setJavaPath(String path) {
|
||||
javaPath = path != null && !path.isEmpty() ? path : "java";
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public static void setLocale(String lang) {
|
||||
if (lang != null && (lang.equals("en") || lang.equals("ru"))) {
|
||||
locale = lang;
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isSystemBasedJvm() {
|
||||
return systemBasedJvm;
|
||||
}
|
||||
|
||||
public static void setSystemBasedJvm(boolean enabled) {
|
||||
systemBasedJvm = enabled;
|
||||
save();
|
||||
}
|
||||
|
||||
public static int getSystemCpuCores() {
|
||||
return Runtime.getRuntime().availableProcessors();
|
||||
}
|
||||
|
||||
public static long getSystemTotalRamMB() {
|
||||
long totalMb = getTotalSystemRamMB();
|
||||
if (totalMb > 0) return totalMb;
|
||||
return Runtime.getRuntime().maxMemory() / (1024 * 1024);
|
||||
}
|
||||
|
||||
public static String getSystemJvmFlags() {
|
||||
int cores = getSystemCpuCores();
|
||||
long ramMB = getSystemTotalRamMB();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("-XX:ParallelGCThreads=").append(Math.max(1, cores));
|
||||
sb.append(" -XX:ConcGCThreads=").append(Math.max(1, cores / 2));
|
||||
sb.append(" -XX:+AlwaysPreTouch");
|
||||
if (ramMB >= 8192) {
|
||||
sb.append(" -XX:+UseZGC");
|
||||
sb.append(" -XX:ZAllocationSpikeTolerance=2.0");
|
||||
} else {
|
||||
sb.append(" -XX:+UseG1GC");
|
||||
sb.append(" -XX:MaxGCPauseMillis=50");
|
||||
sb.append(" -XX:G1HeapRegionSize=16M");
|
||||
}
|
||||
sb.append(" -Xss4M");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String getRamInfo() {
|
||||
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
|
||||
return "Доступно RAM: " + totalMB + " MB | Рекомендуется: " + maxMemory + " MB";
|
||||
return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,9 @@ public class ConsoleUtils {
|
||||
}
|
||||
|
||||
public static void pause() {
|
||||
System.out.print(ZAnsi.white("\nНажмите Enter для продолжения..."));
|
||||
System.out.print(ZAnsi.white("\nPress Enter to continue..."));
|
||||
try {
|
||||
System.in.read();
|
||||
// Очищаем буфер ввода
|
||||
while (System.in.available() > 0) {
|
||||
System.in.read();
|
||||
}
|
||||
@@ -36,4 +35,4 @@ public class ConsoleUtils {
|
||||
public static void separator() {
|
||||
System.out.println(ZAnsi.white("────────────────────────────────────────────────────────────"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,20 @@ package me.sashegdev.zernmc.launcher.utils;
|
||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Scanner;
|
||||
|
||||
/**
|
||||
* Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu
|
||||
*/
|
||||
public class Input {
|
||||
|
||||
// Используем UTF-8 явно — это помогает на Windows
|
||||
private static final Scanner scanner = new Scanner(System.in, "UTF-8");
|
||||
private static final Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8);
|
||||
|
||||
public static String readLine() {
|
||||
return scanner.nextLine().trim();
|
||||
}
|
||||
|
||||
public static String readLine(String prompt) {
|
||||
flushInput(); // Очищаем буфер
|
||||
flushInput();
|
||||
System.out.print(prompt);
|
||||
return scanner.nextLine().trim();
|
||||
}
|
||||
@@ -30,7 +27,7 @@ public class Input {
|
||||
System.out.print(prompt);
|
||||
return Integer.parseInt(scanner.nextLine().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
System.out.println(ZAnsi.brightRed("Некорректное число. Попробуйте ещё раз."));
|
||||
System.out.println(ZAnsi.brightRed("Invalid number. Try again."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,57 +38,41 @@ public class Input {
|
||||
if (value >= min && value <= max) {
|
||||
return value;
|
||||
}
|
||||
System.out.println(ZAnsi.brightRed("Значение должно быть от " + min + " до " + max + "."));
|
||||
System.out.println(ZAnsi.brightRed("Value must be between " + min + " and " + max + "."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Новый confirm через ArrowMenu
|
||||
* @throws IOException
|
||||
*/
|
||||
public static boolean confirm(String question) throws IOException {
|
||||
ConsoleUtils.clearScreen(); // опционально, можно убрать
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
List<String> options = List.of(
|
||||
"Да",
|
||||
"Нет"
|
||||
"Yes",
|
||||
"No"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu(question, options);
|
||||
int choice = menu.show();
|
||||
|
||||
return choice == 0; // 0 = "Да"
|
||||
return choice == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Альтернативный confirm без очистки экрана
|
||||
* @throws IOException
|
||||
*/
|
||||
public static boolean confirmInline(String question) throws IOException {
|
||||
List<String> options = List.of("Да", "Нет");
|
||||
List<String> options = List.of("Yes", "No");
|
||||
ArrowMenu menu = new ArrowMenu(question, options);
|
||||
int choice = menu.show();
|
||||
return choice == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрытие сканнера (вызывать при выходе из программы, если нужно)
|
||||
*/
|
||||
public static void close() {
|
||||
scanner.close();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Очищает буфер ввода от оставшихся символов
|
||||
*/
|
||||
public static void flushInput() {
|
||||
try {
|
||||
while (System.in.available() > 0) {
|
||||
System.in.read();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Игнорируем
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package me.sashegdev.zernmc.launcher.utils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
public class LauncherLogger {
|
||||
|
||||
private static Path logFile;
|
||||
private static boolean initialized = false;
|
||||
private static final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
public static synchronized void init() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
try {
|
||||
Path logsDir = Paths.get(System.getProperty("user.home"), ".zernmc", "logs");
|
||||
Files.createDirectories(logsDir);
|
||||
logFile = logsDir.resolve("launcher.log");
|
||||
|
||||
Files.writeString(logFile,
|
||||
"=== Launcher Log " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " ===\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath());
|
||||
} catch (Exception e) {
|
||||
System.err.println("[LauncherLogger] init error: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public static Path getLogFile() {
|
||||
return logFile;
|
||||
}
|
||||
|
||||
public static void info(String msg) {
|
||||
write("INFO", msg, null);
|
||||
}
|
||||
|
||||
public static void warn(String msg) {
|
||||
write("WARN", msg, null);
|
||||
}
|
||||
|
||||
public static void error(String msg) {
|
||||
write("ERROR", msg, null);
|
||||
}
|
||||
|
||||
public static void error(String msg, Throwable t) {
|
||||
write("ERROR", msg, t);
|
||||
}
|
||||
|
||||
public static void debug(String msg) {
|
||||
write("DEBUG", msg, null);
|
||||
}
|
||||
|
||||
private static void write(String level, String msg, Throwable t) {
|
||||
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"));
|
||||
String line = "[" + ts + "] [" + level + "] " + msg;
|
||||
|
||||
System.out.println(line);
|
||||
if (t != null) {
|
||||
StringWriter sw = new StringWriter();
|
||||
PrintWriter pw = new PrintWriter(sw);
|
||||
t.printStackTrace(pw);
|
||||
pw.flush();
|
||||
System.err.print(sw.toString());
|
||||
}
|
||||
|
||||
if (logFile != null) {
|
||||
lock.lock();
|
||||
try {
|
||||
Files.writeString(logFile, line + "\n", StandardOpenOption.APPEND);
|
||||
if (t != null) {
|
||||
StringWriter sw = new StringWriter();
|
||||
PrintWriter pw = new PrintWriter(sw);
|
||||
t.printStackTrace(pw);
|
||||
pw.flush();
|
||||
Files.writeString(logFile, sw.toString(), StandardOpenOption.APPEND);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
System.err.println("[LauncherLogger] write error: " + e.getMessage());
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,20 @@ public class ProgressBar {
|
||||
|
||||
private static final int BAR_LENGTH = 40;
|
||||
private static final DecimalFormat DF = new DecimalFormat("#.##");
|
||||
|
||||
private static String currentLabel = "";
|
||||
private static long currentTotal = 0;
|
||||
|
||||
/**
|
||||
* Прогресс по количеству файлов (для библиотек и общего прогресса)
|
||||
*/
|
||||
public static void show(String label, long current, long total, String unit) {
|
||||
currentLabel = label;
|
||||
currentTotal = total;
|
||||
|
||||
try {
|
||||
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
|
||||
setProgress.invoke(null, label, (int) current, (int) total);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (total <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
return;
|
||||
@@ -27,10 +36,16 @@ public class ProgressBar {
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогресс по байтам для одного файла (реальный прогресс)
|
||||
*/
|
||||
public static void showDownload(String label, long downloaded, long totalBytes) {
|
||||
currentLabel = label;
|
||||
currentTotal = totalBytes;
|
||||
|
||||
try {
|
||||
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
|
||||
setProgress.invoke(null, label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (totalBytes <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
return;
|
||||
@@ -53,8 +68,16 @@ public class ProgressBar {
|
||||
}
|
||||
|
||||
public static void showAnimated(String label, long current, long total, String unit) {
|
||||
currentLabel = label;
|
||||
currentTotal = total;
|
||||
|
||||
try {
|
||||
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
|
||||
setProgress.invoke(null, label, (int) current, (int) (total > 0 ? total : 100));
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
if (total <= 0) {
|
||||
// Анимация для неизвестного размера
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (current / 1024) % 4;
|
||||
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
|
||||
@@ -64,7 +87,13 @@ public class ProgressBar {
|
||||
}
|
||||
|
||||
public static void finish(String message) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(message + " завершено ✓"));
|
||||
try {
|
||||
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||
java.lang.reflect.Method setInProgress = jfxClass.getMethod("setInstallInProgress", boolean.class);
|
||||
setInProgress.invoke(null, false);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓"));
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
@@ -78,4 +107,4 @@ public class ProgressBar {
|
||||
if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB";
|
||||
return DF.format(bytes / (1024.0 * 1024)) + " MB";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,9 @@ public class ZHttpClient {
|
||||
|
||||
private static String BASE_URL = "http://87.120.187.36:1582";
|
||||
|
||||
// Глобальный прокси режим (для обратной совместимости)
|
||||
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
|
||||
private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* Переопределить URL сервера (для тестов).
|
||||
* Внимание: не потокобезопасно, использовать только в тестах.
|
||||
*/
|
||||
public static void setBaseUrl(String url) {
|
||||
BASE_URL = url;
|
||||
}
|
||||
@@ -45,7 +40,6 @@ public class ZHttpClient {
|
||||
return BASE_URL;
|
||||
}
|
||||
|
||||
// Умное проксирование по сервисам
|
||||
public enum ServiceType {
|
||||
ZERN_SERVER("http://87.120.187.36:1582", true),
|
||||
FABRIC_META("https://meta.fabricmc.net", false),
|
||||
@@ -69,17 +63,15 @@ public class ZHttpClient {
|
||||
public boolean isAlwaysDirect() { return alwaysDirect; }
|
||||
}
|
||||
|
||||
// Статусы сервисов
|
||||
private static final Map<ServiceType, Boolean> serviceProxyMode = new ConcurrentHashMap<>();
|
||||
private static final Map<ServiceType, Integer> serviceFailCount = new ConcurrentHashMap<>();
|
||||
private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>();
|
||||
private static final Map<ServiceType, Boolean> serviceHealthy = new ConcurrentHashMap<>();
|
||||
|
||||
private static final int MAX_FAILS_BEFORE_PROXY = 2;
|
||||
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута
|
||||
private static final long CHECK_TIMEOUT_MS = 7000; // 7 секунд на проверку
|
||||
private static final long HEALTH_CHECK_INTERVAL_MS = 60000;
|
||||
private static final long CHECK_TIMEOUT_MS = 7000;
|
||||
|
||||
// Статистика
|
||||
private static int directSuccessCount = 0;
|
||||
private static int proxySuccessCount = 0;
|
||||
private static int directFailCount = 0;
|
||||
@@ -92,14 +84,13 @@ public class ZHttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вызывать один раз при запуске лаунчера
|
||||
*/
|
||||
public static void checkAllServicesOnStartup() {
|
||||
checkAllServicesOnStartup(false);
|
||||
}
|
||||
|
||||
public static void checkAllServicesOnStartup(boolean verbose) {
|
||||
if (proxyTested.get()) return;
|
||||
|
||||
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
|
||||
|
||||
List<ServiceType> servicesToCheck = List.of(
|
||||
ServiceType.ZERN_SERVER,
|
||||
ServiceType.GOOGLE,
|
||||
@@ -116,14 +107,20 @@ public class ZHttpClient {
|
||||
serviceHealthy.put(service, isHealthy);
|
||||
|
||||
if (service.isAlwaysDirect()) {
|
||||
System.out.println(isHealthy ?
|
||||
ZAnsi.green(" " + service.name() + " - OK") :
|
||||
ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)"));
|
||||
if (verbose) {
|
||||
System.out.println(isHealthy ?
|
||||
ZAnsi.green(" " + service.name() + " - OK") :
|
||||
ZAnsi.red(" " + service.name() + " - NOT ACCESSIBLE (critical!)"));
|
||||
}
|
||||
} else {
|
||||
if (isHealthy) {
|
||||
System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает"));
|
||||
if (verbose) {
|
||||
System.out.println(ZAnsi.green(" " + service.name() + " - direct connection works"));
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси"));
|
||||
if (verbose) {
|
||||
System.out.println(ZAnsi.yellow(" " + service.name() + " - NOT ACCESSIBLE, proxy will be used"));
|
||||
}
|
||||
serviceProxyMode.put(service, true);
|
||||
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
|
||||
}
|
||||
@@ -131,30 +128,31 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
|
||||
System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!"));
|
||||
if (verbose) {
|
||||
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
|
||||
}
|
||||
}
|
||||
|
||||
proxyTested.set(true);
|
||||
startHealthCheckThread();
|
||||
printStats();
|
||||
if (verbose) {
|
||||
startHealthCheckThread();
|
||||
printStats();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки)
|
||||
*/
|
||||
public static void forceCheckMojangServices() {
|
||||
System.out.println(ZAnsi.cyan("Принудительная проверка Mojang сервисов..."));
|
||||
System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
|
||||
|
||||
for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) {
|
||||
boolean healthy = checkServiceHealth(service);
|
||||
serviceHealthy.put(service, healthy);
|
||||
|
||||
if (healthy) {
|
||||
System.out.println(ZAnsi.green(" " + service.name() + " доступен напрямую"));
|
||||
System.out.println(ZAnsi.green(" " + service.name() + " accessible directly"));
|
||||
serviceProxyMode.put(service, false);
|
||||
serviceFailCount.put(service, 0);
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" " + service.name() + " недоступен → прокси режим активирован"));
|
||||
System.out.println(ZAnsi.yellow(" " + service.name() + " not accessible -> proxy mode activated"));
|
||||
serviceProxyMode.put(service, true);
|
||||
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
|
||||
}
|
||||
@@ -165,9 +163,6 @@ public class ZHttpClient {
|
||||
return checkDirectConnection(service.getBaseUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Улучшенная проверка прямого подключения
|
||||
*/
|
||||
private static boolean checkDirectConnection(String baseUrl) {
|
||||
String testUrl = baseUrl;
|
||||
|
||||
@@ -187,7 +182,7 @@ public class ZHttpClient {
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
int code = response.statusCode();
|
||||
return code == 200 || code == 404; // 404 для ресурсов — нормально
|
||||
return code == 200 || code == 404;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
@@ -218,7 +213,7 @@ public class ZHttpClient {
|
||||
if (isHealthy && serviceProxyMode.get(service)) {
|
||||
serviceProxyMode.put(service, false);
|
||||
serviceFailCount.put(service, 0);
|
||||
System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение"));
|
||||
System.out.println(ZAnsi.green("[NET] " + service.name() + " restored, switched to direct connection"));
|
||||
} else if (!isHealthy && !serviceProxyMode.get(service)) {
|
||||
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
|
||||
serviceFailCount.put(service, fails);
|
||||
@@ -226,7 +221,7 @@ public class ZHttpClient {
|
||||
|
||||
if (fails >= MAX_FAILS_BEFORE_PROXY) {
|
||||
serviceProxyMode.put(service, true);
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " недоступен, включен прокси режим"));
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " unavailable, proxy mode enabled"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,14 +272,11 @@ public class ZHttpClient {
|
||||
|
||||
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
|
||||
serviceProxyMode.put(service, true);
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " заблокирован, переключаемся на прокси"));
|
||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " blocked, switching to proxy"));
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Универсальный GET с умным прокси + автоматическим fallback
|
||||
*/
|
||||
|
||||
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
|
||||
// Попытка прямого подключения
|
||||
if (!shouldUseProxyForUrl(url)) {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
@@ -309,11 +301,9 @@ public class ZHttpClient {
|
||||
directFailCount++;
|
||||
markServiceAsBlocked(url);
|
||||
}
|
||||
// Если ошибка соединения — пробуем через прокси
|
||||
}
|
||||
}
|
||||
|
||||
// Через прокси
|
||||
try {
|
||||
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
||||
String proxyUrl = BASE_URL + "/download?url=" + encodedUrl;
|
||||
@@ -335,13 +325,10 @@ public class ZHttpClient {
|
||||
return response.body();
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Не удалось получить данные ни напрямую, ни через прокси: " + e.getMessage(), e);
|
||||
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание файла с умным прокси + fallback
|
||||
*/
|
||||
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
|
||||
if (!shouldUseProxyForUrl(url)) {
|
||||
try {
|
||||
@@ -363,11 +350,9 @@ public class ZHttpClient {
|
||||
directFailCount++;
|
||||
markServiceAsBlocked(url);
|
||||
}
|
||||
// fallback на прокси ниже
|
||||
}
|
||||
}
|
||||
|
||||
// Скачивание через прокси
|
||||
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
||||
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
|
||||
|
||||
@@ -387,11 +372,7 @@ public class ZHttpClient {
|
||||
proxySuccessCount++;
|
||||
}
|
||||
|
||||
// ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ======================
|
||||
|
||||
public static String get(String endpoint) throws IOException, InterruptedException {
|
||||
checkAllServicesOnStartup();
|
||||
|
||||
if (useProxyMode.get()) {
|
||||
return proxyGet(endpoint);
|
||||
}
|
||||
@@ -403,7 +384,6 @@ public class ZHttpClient {
|
||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||
.GET();
|
||||
|
||||
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken != null && !accessToken.equals("0")) {
|
||||
requestBuilder.header("Authorization", "Bearer " + accessToken);
|
||||
@@ -430,7 +410,6 @@ public class ZHttpClient {
|
||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||
.GET();
|
||||
|
||||
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken != null && !accessToken.equals("0")) {
|
||||
requestBuilder.header("Authorization", "Bearer " + accessToken);
|
||||
@@ -446,12 +425,10 @@ public class ZHttpClient {
|
||||
proxySuccessCount++;
|
||||
return response.body();
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Ошибка прокси: " + e.getMessage(), e);
|
||||
throw new IOException("Proxy error: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ======================
|
||||
|
||||
public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException {
|
||||
String url = "https://meta.fabricmc.net/v2/versions/loader";
|
||||
return parseFabricVersionsFromJson(getWithSmartProxy(url));
|
||||
@@ -506,15 +483,13 @@ public class ZHttpClient {
|
||||
return versions;
|
||||
}
|
||||
|
||||
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
|
||||
|
||||
public static String getLauncherVersionInfo() throws IOException, InterruptedException {
|
||||
return get("/launcher/version");
|
||||
}
|
||||
|
||||
public static void forceProxyMode() {
|
||||
useProxyMode.set(true);
|
||||
System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим"));
|
||||
System.out.println(ZAnsi.yellow("Global proxy mode forced on"));
|
||||
}
|
||||
|
||||
public static void disableProxyMode() {
|
||||
@@ -525,7 +500,7 @@ public class ZHttpClient {
|
||||
serviceFailCount.put(type, 0);
|
||||
}
|
||||
}
|
||||
System.out.println(ZAnsi.green("Режим прокси выключен"));
|
||||
System.out.println(ZAnsi.green("Proxy mode disabled"));
|
||||
}
|
||||
|
||||
public static boolean isProxyMode() {
|
||||
@@ -533,18 +508,18 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
public static void printStats() {
|
||||
System.out.println(ZAnsi.cyan("\n=== Статистика сети ==="));
|
||||
System.out.println(ZAnsi.white("Глобальный прокси: ") + (useProxyMode.get() ? "ВКЛ" : "ВЫКЛ"));
|
||||
System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount);
|
||||
System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount);
|
||||
System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount);
|
||||
System.out.println(ZAnsi.cyan("\n=== Network Stats ==="));
|
||||
System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF"));
|
||||
System.out.println(ZAnsi.white("Direct successes: ") + directSuccessCount);
|
||||
System.out.println(ZAnsi.white("Direct failures: ") + directFailCount);
|
||||
System.out.println(ZAnsi.white("Proxy successes: ") + proxySuccessCount);
|
||||
|
||||
System.out.println(ZAnsi.cyan("\nСтатус сервисов:"));
|
||||
System.out.println(ZAnsi.cyan("\nService status:"));
|
||||
for (ServiceType type : ServiceType.values()) {
|
||||
if (type.isAlwaysDirect()) continue;
|
||||
String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО");
|
||||
String status = serviceProxyMode.get(type) ? ZAnsi.red("PROXY") : ZAnsi.green("DIRECT");
|
||||
String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]");
|
||||
System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -7,94 +7,417 @@
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="bg-canvas"></canvas>
|
||||
|
||||
<div id="app">
|
||||
<!-- Экран логина -->
|
||||
<!-- Login Screen -->
|
||||
<div id="login-screen" class="screen">
|
||||
<div class="login-container">
|
||||
<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>
|
||||
<div class="login-brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none">
|
||||
<rect width="56" height="56" rx="14" fill="url(#brandGrad)"/>
|
||||
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
|
||||
<defs>
|
||||
<linearGradient id="brandGrad" x1="0" y1="0" x2="56" y2="56">
|
||||
<stop offset="0%" stop-color="#e94560"/>
|
||||
<stop offset="100%" stop-color="#ff6b6b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="brand-title">ZernMC</h1>
|
||||
<p class="brand-sub">Launcher <span id="version" data-i18n="version">1.0.9</span></p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="login-form">
|
||||
<div class="field">
|
||||
<input type="text" id="username" placeholder="Username" data-i18n-placeholder="login.username" autocomplete="username" required>
|
||||
<label for="username" data-i18n="login.username">Username</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input type="password" id="password" placeholder="Password" data-i18n-placeholder="login.password" autocomplete="current-password" required>
|
||||
<label for="password" data-i18n="login.password">Password</label>
|
||||
</div>
|
||||
<p id="login-error" class="error-msg hidden"></p>
|
||||
<button type="submit" class="btn-primary" id="login-btn">
|
||||
<span class="btn-label" data-i18n="login.title">Sign In</span>
|
||||
<div class="spinner hidden"></div>
|
||||
</button>
|
||||
<p class="login-hint" data-i18n="login.hint">New account will be created automatically on first login</p>
|
||||
</form>
|
||||
<div 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>
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loading-overlay" class="overlay hidden">
|
||||
<div class="loader-ring"></div>
|
||||
<p class="loader-text" data-i18n="loading.text">Loading...</p>
|
||||
</div>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<main class="main-content">
|
||||
<!-- Слева: выбор сборки -->
|
||||
<!-- Main Screen -->
|
||||
<div id="main-screen" class="screen hidden">
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<h2>Сборки</h2>
|
||||
<div id="instances-list" class="instances-container">
|
||||
<!-- Динамически заполняется через JS -->
|
||||
<div class="sidebar-top">
|
||||
<div class="sidebar-brand">
|
||||
<svg width="32" height="32" viewBox="0 0 56 56" fill="none">
|
||||
<rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/>
|
||||
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
|
||||
<defs>
|
||||
<linearGradient id="brandGrad2" x1="0" y1="0" x2="56" y2="56">
|
||||
<stop offset="0%" stop-color="#e94560"/>
|
||||
<stop offset="100%" stop-color="#ff6b6b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="sidebar-brand-text">
|
||||
<span class="sidebar-brand-name">ZernMC</span>
|
||||
<span class="sidebar-brand-ver">v<span id="header-version">1.0.9</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<button class="nav-btn active" data-view="packs" title="Packs">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
|
||||
<span data-i18n="nav.packs">Packs</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-view="news" title="News">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
|
||||
<span data-i18n="nav.news">News</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-view="friends" title="Friends">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span data-i18n="nav.friends">Friends</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
|
||||
</div>
|
||||
<div id="server-packs-list" class="pack-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section" id="local-packs-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
|
||||
<button class="btn-icon" id="add-pack-btn" title="Add pack">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="local-packs-list" class="pack-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<div class="user-card">
|
||||
<div class="user-avatar" id="user-avatar">Z</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name" id="username-display">Player</span>
|
||||
<span class="user-badges">
|
||||
<span id="account-status" class="badge badge-free">FREE</span>
|
||||
<span id="account-role" class="badge badge-role hidden"></span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn-icon" id="settings-btn" title="Settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="logout-btn" title="Log out">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- По центру: логи -->
|
||||
<section class="logs-panel">
|
||||
<h2>Логи</h2>
|
||||
<div id="logs-container"></div>
|
||||
</section>
|
||||
</main>
|
||||
<main class="content">
|
||||
<!-- Packs View -->
|
||||
<div id="view-packs" class="view active">
|
||||
<div class="view-header">
|
||||
<div>
|
||||
<h2 class="view-title" id="selected-pack-title">Select a pack</h2>
|
||||
<p class="view-subtitle" id="selected-pack-meta">Choose a pack from the sidebar to get started</p>
|
||||
</div>
|
||||
<div class="view-actions">
|
||||
<button id="update-btn" class="btn-secondary hidden">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
<span data-i18n="pack.update">Update</span>
|
||||
</button>
|
||||
<button id="delete-pack-btn" class="btn-secondary btn-danger hidden">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
<span data-i18n="pack.delete">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Низ: управление -->
|
||||
<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 class="pack-detail" id="pack-detail">
|
||||
<div class="pack-empty" id="pack-empty-state">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
<h3 data-i18n="pack.emptyState.title">No pack selected</h3>
|
||||
<p data-i18n="pack.emptyState.desc">Select a pack from the sidebar or add a new one</p>
|
||||
</div>
|
||||
<div id="pack-detail-content" class="pack-detail-content hidden">
|
||||
<div class="pack-hero">
|
||||
<div class="pack-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="detail-name" class="detail-name">pack</h3>
|
||||
<div class="detail-tags">
|
||||
<span class="tag tag-mc" id="detail-mc">1.21</span>
|
||||
<span class="tag tag-loader" id="detail-loader">fabric</span>
|
||||
<span class="tag tag-server hidden" id="detail-server">v1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pack-stats">
|
||||
<div class="stat"><span class="stat-value" id="detail-loader-ver">-</span><span class="stat-label" data-i18n="stat.loaderVer">Loader Ver</span></div>
|
||||
<div class="stat"><span class="stat-value" id="detail-files">0</span><span class="stat-label" data-i18n="stat.files">Files</span></div>
|
||||
<div class="stat"><span class="stat-value" id="detail-size">-</span><span class="stat-label" data-i18n="stat.size">Size</span></div>
|
||||
<div class="stat"><span class="stat-value" id="detail-playtime">-</span><span class="stat-label" data-i18n="playtime.label">Playtime</span></div>
|
||||
</div>
|
||||
<div id="pack-description" class="pack-description">
|
||||
<p id="pack-description-text" class="pack-description-text" data-i18n="pack.description.loading">Loading description...</p>
|
||||
<div id="pack-gallery" class="pack-gallery">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно установки -->
|
||||
<div 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 class="play-bar" id="play-bar">
|
||||
<div class="play-bar-info">
|
||||
<span id="play-bar-name">Select a pack</span>
|
||||
</div>
|
||||
<button id="play-btn" class="btn-play" disabled>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
<span data-i18n="playBar.play">Play</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- News View -->
|
||||
<div id="view-news" class="view">
|
||||
<div class="view-header">
|
||||
<h2 class="view-title" data-i18n="news.title">News</h2>
|
||||
</div>
|
||||
<div id="news-grid" class="news-grid">
|
||||
<div class="news-loading" data-i18n="news.loading">Loading news...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Friends View -->
|
||||
<div id="view-friends" class="view">
|
||||
<div class="view-header">
|
||||
<h2 class="view-title" data-i18n="friends.title">Friends</h2>
|
||||
<div class="view-actions">
|
||||
<button id="friends-add-btn" class="btn-primary btn-sm" onclick="app.showAddFriend()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<span data-i18n="friends.add">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="friends-search">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="friends-search-input" placeholder="Search friends..." data-i18n-placeholder="friends.search" oninput="app.filterFriends()">
|
||||
</div>
|
||||
<div id="friends-list" class="friends-list">
|
||||
<div class="friends-empty" data-i18n="friends.empty">No friends yet</div>
|
||||
</div>
|
||||
<div id="friend-requests-section" class="friend-requests-section hidden">
|
||||
<div class="section-header"><span data-i18n="friends.requests">Friend Requests</span></div>
|
||||
<div id="friend-requests-list" class="friend-requests-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings View -->
|
||||
<div id="view-settings" class="view">
|
||||
<div class="view-header">
|
||||
<h2 class="view-title" data-i18n="settings.title">Settings</h2>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.activatePass.title">Activate Pass</h4>
|
||||
<p data-i18n="settings.activatePass.desc">Enter your pass code to access server packs</p>
|
||||
</div>
|
||||
<div class="setting-control setting-pass">
|
||||
<input type="text" id="pass-code" placeholder="Pass code" data-i18n-placeholder="settings.activatePass.placeholder" class="pass-input">
|
||||
<button id="activate-pass-btn" class="btn-primary btn-sm" onclick="app.activatePass()"><span data-i18n="settings.activatePass.button">Activate</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.ram.title">Allocated RAM</h4>
|
||||
<p id="ram-info" data-i18n="settings.ram.info">Loading...</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="range" id="ram-slider" min="1024" max="16384" step="512" value="4096">
|
||||
<span class="setting-value" id="ram-value">4 GB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.resolution.title">Game Resolution</h4>
|
||||
<p data-i18n="settings.resolution.desc">Width x Height</p>
|
||||
</div>
|
||||
<div class="setting-control" style="gap:6px">
|
||||
<input type="number" id="win-width" min="640" max="7680" step="1" value="1280" class="setting-input" style="width:80px">
|
||||
<span style="color:var(--text-muted)">x</span>
|
||||
<input type="number" id="win-height" min="480" max="4320" step="1" value="720" class="setting-input" style="width:80px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.jvmArgs.title">Extra JVM Arguments</h4>
|
||||
<p data-i18n="settings.jvmArgs.desc">Additional Java VM options</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" id="jvm-args" placeholder="-XX:+UseZGC" class="setting-input" style="width:280px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.javaPath.title">Java Path</h4>
|
||||
<p id="java-path">~/.zernmc/jre/</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" id="java-path-input" placeholder="java" class="setting-input" style="width:280px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.server.title">Server</h4>
|
||||
<p id="server-url">http://87.120.187.36:1582</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<span class="setting-badge" id="server-status">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.language.title">Language</h4>
|
||||
<p data-i18n="settings.language.desc">Interface language</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="locale-select" class="setting-input" style="width:160px">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.systemJvm.title">System-based JVM Optimization</h4>
|
||||
<p id="system-jvm-info">-</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle" id="system-jvm-toggle-wrapper">
|
||||
<input type="checkbox" id="system-jvm-toggle">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-card">
|
||||
<div class="setting-info">
|
||||
<h4 data-i18n="settings.logViewer.title">Game Log</h4>
|
||||
<p data-i18n="settings.logViewer.desc">View real-time game logs</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Friend Modal -->
|
||||
<div id="add-friend-modal" class="modal-backdrop hidden">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3 data-i18n="friends.addTitle">Add Friend</h3>
|
||||
<button class="modal-close" onclick="app.closeAddFriend()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label data-i18n="friends.addLabel">Username</label>
|
||||
<input type="text" id="add-friend-input" placeholder="Enter username..." data-i18n-placeholder="friends.addPlaceholder">
|
||||
</div>
|
||||
<button id="add-friend-submit" class="btn-primary" onclick="app.submitAddFriend()"><span data-i18n="friends.add">Add Friend</span></button>
|
||||
<p id="add-friend-error" class="error-msg hidden"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Viewer Overlay -->
|
||||
<div id="log-viewer-overlay" class="modal-backdrop hidden">
|
||||
<div class="modal modal-log">
|
||||
<div class="modal-head">
|
||||
<h3 data-i18n="logViewer.title">Game Log</h3>
|
||||
<div class="log-viewer-actions">
|
||||
<button class="btn-secondary btn-sm" id="copy-log-btn" onclick="app.copyLogs()"><span data-i18n="logViewer.copy">Copy</span></button>
|
||||
<button class="btn-secondary btn-sm" onclick="app.req('/open-log-file', {method:'POST'})"><span data-i18n="logViewer.openFile">Open File</span></button>
|
||||
<button class="modal-close" id="close-log-viewer-btn" onclick="app.closeLogViewer()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body log-viewer-body">
|
||||
<div id="log-viewer-content" class="log-viewer-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install Modal -->
|
||||
<div id="install-modal" class="modal-backdrop hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h3 data-i18n="install.title">Install Pack</h3>
|
||||
<button class="modal-close" id="close-modal-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab active" data-tab="zernmc"><span data-i18n="install.tab.serverPack">Server Pack</span></button>
|
||||
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span> <span class="tag-wip">WIP</span></button>
|
||||
</div>
|
||||
|
||||
<div id="tab-zernmc" class="modal-tab-content active">
|
||||
<div class="field">
|
||||
<label data-i18n="install.serverPack.label">Server Pack</label>
|
||||
<select id="zernmc-pack-select">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label data-i18n="install.localName.label">Local Name</label>
|
||||
<input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
|
||||
</div>
|
||||
<button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
|
||||
</div>
|
||||
|
||||
<div id="tab-custom" class="modal-tab-content">
|
||||
<div class="disabled-tab">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<h3 data-i18n="install.custom.unavailable">Not available yet</h3>
|
||||
<p data-i18n="install.custom.desc">Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="install-progress" class="install-progress hidden">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<p class="progress-label" id="progress-label" data-i18n="install.progress.installing">Installing...</p>
|
||||
<p class="progress-stage hidden" id="progress-stage"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Toast -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
</div>
|
||||
|
||||
<script src="marked.min.js"></script>
|
||||
<script src="launcher.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+274
@@ -0,0 +1,274 @@
|
||||
package me.sashegdev.zernmc.launcher.auth;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
class AuthManagerPassTest {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private static AuthManager.AuthSession createSession(String token, int role) {
|
||||
AuthManager.AuthSession s = new AuthManager.AuthSession();
|
||||
s.accessToken = token;
|
||||
s.role = role;
|
||||
s.expiresAt = System.currentTimeMillis() / 1000L + 3600;
|
||||
return s;
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_returnsFalse_whenNotLoggedIn() {
|
||||
AuthManager.resetForTest();
|
||||
assertFalse(AuthManager.hasPass());
|
||||
assertFalse(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_usesUserInfo_whenAvailable() {
|
||||
AuthManager.UserInfo info = new AuthManager.UserInfo();
|
||||
info.has_pass = true;
|
||||
info.role = 1;
|
||||
AuthManager.setTestUserInfo(info);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", 1));
|
||||
|
||||
assertTrue(AuthManager.hasPass());
|
||||
assertTrue(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_usesRole_whenUserInfoNull() {
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
|
||||
|
||||
assertTrue(AuthManager.hasPass());
|
||||
assertTrue(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_returnsFalse_whenRoleTooLow() {
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_USER));
|
||||
|
||||
assertFalse(AuthManager.hasPass());
|
||||
assertFalse(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasPass_userInfoTakesPriorityOverRole() {
|
||||
AuthManager.UserInfo info = new AuthManager.UserInfo();
|
||||
info.has_pass = false;
|
||||
info.role = 1;
|
||||
AuthManager.setTestUserInfo(info);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
|
||||
|
||||
assertFalse(AuthManager.hasPass());
|
||||
assertFalse(AuthManager.hasActivePass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canViewPacks_usesPermissions_whenAvailable() {
|
||||
AuthManager.UserInfo info = new AuthManager.UserInfo();
|
||||
info.permissions = List.of("view_packs", "download_pack");
|
||||
info.has_pass = true;
|
||||
AuthManager.setTestUserInfo(info);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", 1));
|
||||
|
||||
assertTrue(AuthManager.canViewPacks());
|
||||
assertTrue(AuthManager.canDownloadPacks());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canViewPacks_fallsBackToHasPass_whenNoPermissions() {
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
|
||||
|
||||
assertTrue(AuthManager.canViewPacks());
|
||||
assertTrue(AuthManager.canDownloadPacks());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authSession_parsesFromLoginResponse() {
|
||||
String json = """
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh",
|
||||
"expires_in": 86400,
|
||||
"token_type": "bearer",
|
||||
"username": "testuser",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role": 1,
|
||||
"role_name": "PASS_HOLDER"
|
||||
}
|
||||
""";
|
||||
AuthManager.AuthSession session = GSON.fromJson(json, AuthManager.AuthSession.class);
|
||||
|
||||
assertNotNull(session);
|
||||
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test", session.accessToken);
|
||||
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh", session.refreshToken);
|
||||
assertEquals(86400, session.expiresIn);
|
||||
assertEquals("testuser", session.username);
|
||||
assertEquals("550e8400-e29b-41d4-a716-446655440000", session.uuid);
|
||||
assertEquals(1, session.role);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authSession_roundTrip() {
|
||||
AuthManager.AuthSession original = new AuthManager.AuthSession();
|
||||
original.accessToken = "access123";
|
||||
original.refreshToken = "refresh123";
|
||||
original.expiresIn = 86400;
|
||||
original.expiresAt = System.currentTimeMillis() / 1000L + 86400;
|
||||
original.username = "testuser";
|
||||
original.uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
original.role = 1;
|
||||
|
||||
String json = GSON.toJson(original);
|
||||
AuthManager.AuthSession parsed = GSON.fromJson(json, AuthManager.AuthSession.class);
|
||||
|
||||
assertEquals(original.accessToken, parsed.accessToken);
|
||||
assertEquals(original.refreshToken, parsed.refreshToken);
|
||||
assertEquals(original.expiresIn, parsed.expiresIn);
|
||||
assertEquals(original.expiresAt, parsed.expiresAt);
|
||||
assertEquals(original.username, parsed.username);
|
||||
assertEquals(original.uuid, parsed.uuid);
|
||||
assertEquals(original.role, parsed.role);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userInfo_parsesFromMeEndpoint() {
|
||||
String json = """
|
||||
{
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"role": 1,
|
||||
"role_name": "PASS_HOLDER",
|
||||
"has_pass": true,
|
||||
"permissions": ["view_packs", "download_pack"]
|
||||
}
|
||||
""";
|
||||
AuthManager.UserInfo info = GSON.fromJson(json, AuthManager.UserInfo.class);
|
||||
|
||||
assertNotNull(info);
|
||||
assertEquals(1, info.id);
|
||||
assertEquals("testuser", info.username);
|
||||
assertEquals(1, info.role);
|
||||
assertEquals("PASS_HOLDER", info.role_name);
|
||||
assertTrue(info.has_pass);
|
||||
assertTrue(info.permissions.contains("view_packs"));
|
||||
assertTrue(info.permissions.contains("download_pack"));
|
||||
assertTrue(info.hasPermission("view_packs"));
|
||||
assertFalse(info.hasPermission("admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRole_updatesSessionRole() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestSession(createSession("tok", 0));
|
||||
AuthManager.setTestUserInfo(null);
|
||||
|
||||
assertEquals(0, AuthManager.getRole());
|
||||
assertFalse(AuthManager.hasPass());
|
||||
|
||||
AuthManager.updateRole(1);
|
||||
|
||||
assertEquals(1, AuthManager.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLoggedIn_returnsTrue_whenSessionExists() {
|
||||
AuthManager.resetForTest();
|
||||
assertFalse(AuthManager.isLoggedIn());
|
||||
|
||||
AuthManager.AuthSession s = createSession("tok", 0);
|
||||
s.username = "testuser";
|
||||
AuthManager.setTestSession(s);
|
||||
|
||||
assertTrue(AuthManager.isLoggedIn());
|
||||
assertEquals("testuser", AuthManager.getUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUsername_returnsSessionUsername() {
|
||||
AuthManager.AuthSession s = createSession("tok", 0);
|
||||
s.username = "testuser";
|
||||
AuthManager.setTestSession(s);
|
||||
|
||||
assertEquals("testuser", AuthManager.getUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRole_returnsZero_whenSessionNull() {
|
||||
AuthManager.resetForTest();
|
||||
assertEquals(0, AuthManager.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRoleName_fallsBackToUSER_whenUserInfoNull() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestUserInfo(null);
|
||||
AuthManager.setTestSession(createSession("tok", 0));
|
||||
|
||||
assertEquals("USER", AuthManager.getRoleName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessToken_returnsToken_whenSessionValid() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestUserInfo(null);
|
||||
AuthManager.setTestSession(createSession("valid-token", 1));
|
||||
|
||||
String token = AuthManager.getAccessToken();
|
||||
assertEquals("valid-token", token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessToken_doesNotInvalidate_whenNoRefreshToken() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestUserInfo(null);
|
||||
AuthManager.AuthSession s = createSession("tok", 1);
|
||||
s.refreshToken = null;
|
||||
AuthManager.setTestSession(s);
|
||||
|
||||
String token = AuthManager.getAccessToken();
|
||||
assertEquals("tok", token);
|
||||
assertTrue(AuthManager.isLoggedIn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessToken_returnsZero_whenSessionNull() {
|
||||
AuthManager.resetForTest();
|
||||
assertEquals("0", AuthManager.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidateSession_clearsState() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.setTestSession(createSession("tok", 1));
|
||||
AuthManager.setTestUserInfo(new AuthManager.UserInfo());
|
||||
assertTrue(AuthManager.isLoggedIn());
|
||||
|
||||
AuthManager.logout();
|
||||
|
||||
assertFalse(AuthManager.isLoggedIn());
|
||||
assertEquals(0, AuthManager.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSavedSession_returnsFalse_whenNoAuthFile() {
|
||||
AuthManager.resetForTest();
|
||||
AuthManager.logout();
|
||||
assertFalse(AuthManager.loadSavedSession());
|
||||
}
|
||||
}
|
||||
+3
-10
@@ -60,8 +60,8 @@ async def list_users(
|
||||
query += " FROM users"
|
||||
|
||||
if search:
|
||||
query += " AND (username LIKE ? OR email LIKE ?)"
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
query += " AND username LIKE ?"
|
||||
params.append(f"%{search}%")
|
||||
|
||||
query += " ORDER BY role DESC, username"
|
||||
|
||||
@@ -108,19 +108,13 @@ async def get_user_detail(
|
||||
"""Детальная информация о пользователе"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until
|
||||
SELECT id, username, uuid, role, created_at, last_login, is_active, banned_until
|
||||
FROM users WHERE id = ?
|
||||
""", (user_id,)).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
# Модераторы не видят email обычных пользователей
|
||||
if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR:
|
||||
email = None
|
||||
else:
|
||||
email = row["email"]
|
||||
|
||||
# Получаем активную проходку
|
||||
pass_info = None
|
||||
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
|
||||
@@ -151,7 +145,6 @@ async def get_user_detail(
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"email": email,
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||
|
||||
@@ -770,3 +770,15 @@ async def activate_pass(
|
||||
"message": f"Проходка активирована для {uname}",
|
||||
"role": 1,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pass/my")
|
||||
async def my_pass_status(current_user: dict = Depends(get_current_user)):
|
||||
"""Check if current user has an active pass"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT 1 FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
""", (current_user["id"], time.time())).fetchone()
|
||||
return {"has_active": row is not None}
|
||||
|
||||
+39
-1
@@ -15,7 +15,8 @@ def parse_args():
|
||||
mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload")
|
||||
mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers")
|
||||
mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests")
|
||||
|
||||
mode_group.add_argument("--sync", action="store_true", help="Sync mode - sync with main server as mirror")
|
||||
|
||||
# Additional options
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
|
||||
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
|
||||
@@ -53,6 +54,43 @@ async def run_test_mode():
|
||||
logger.info("All packs validated successfully")
|
||||
sys.exit(0)
|
||||
|
||||
async def run_sync_mode():
|
||||
"""Sync with main server as mirror"""
|
||||
import os
|
||||
|
||||
main_url = os.environ.get("MAIN_SERVER_URL")
|
||||
if not main_url:
|
||||
logger.error("MAIN_SERVER_URL not set. Run: MAIN_SERVER_URL=http://main:1582 python cli.py --sync")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"Starting mirror sync from {main_url}")
|
||||
|
||||
# Get version from main
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get version
|
||||
try:
|
||||
resp = await client.get(f"{main_url}/launcher/version")
|
||||
data = resp.json()
|
||||
version = data.get("version")
|
||||
logger.info(f"Main server version: {version}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get version from main: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get sync manifest
|
||||
try:
|
||||
resp = await client.get(f"{main_url}/launcher/sync/{version}")
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Files to sync: {len(sync_data.get('files', []))}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get sync manifest: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Sync happens during server startup in mirror mode
|
||||
# Just verify we can reach main
|
||||
logger.info("Mirror sync configured. Server will sync on startup.")
|
||||
|
||||
def run_production_mode(host: str, port: int, workers: int):
|
||||
"""Run with multiple workers"""
|
||||
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import structlog
|
||||
import time
|
||||
|
||||
from auth import get_db, get_current_user
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["friends"])
|
||||
|
||||
def init_friends_db():
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS friendships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
requester_id INTEGER NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(requester_id, target_id),
|
||||
FOREIGN KEY (requester_id) REFERENCES users(id),
|
||||
FOREIGN KEY (target_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_status (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
is_online INTEGER DEFAULT 0,
|
||||
current_pack TEXT DEFAULT '',
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_friendships_target ON friendships(target_id);
|
||||
""")
|
||||
|
||||
class AddFriendRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
class RemoveFriendRequest(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class AcceptFriendRequest(BaseModel):
|
||||
user_id: int
|
||||
|
||||
class StatusUpdateRequest(BaseModel):
|
||||
online: bool = True
|
||||
current_pack: Optional[str] = None
|
||||
|
||||
@router.post("/friends/add")
|
||||
async def add_friend(
|
||||
req: AddFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute("SELECT id FROM users WHERE username = ?", (req.username,))
|
||||
target = cursor.fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
target_id = target[0]
|
||||
|
||||
if target_id == current_user["id"]:
|
||||
raise HTTPException(400, "Cannot add yourself")
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT status FROM friendships WHERE requester_id = ? AND target_id = ?",
|
||||
(current_user["id"], target_id)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
if existing[0] == "accepted":
|
||||
raise HTTPException(400, "Already friends")
|
||||
raise HTTPException(400, f"Friend request already {existing[0]}")
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO friendships (requester_id, target_id, status) VALUES (?, ?, 'pending')",
|
||||
(current_user["id"], target_id)
|
||||
)
|
||||
logger.info("Friend request sent", from_user=current_user["id"], to_user=target_id)
|
||||
return {"message": "Friend request sent"}
|
||||
|
||||
@router.post("/friends/accept")
|
||||
async def accept_friend(
|
||||
req: AcceptFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, requester_id FROM friendships WHERE target_id = ? AND requester_id = ? AND status = 'pending'",
|
||||
(current_user["id"], req.user_id)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "No pending friend request from this user")
|
||||
conn.execute("UPDATE friendships SET status = 'accepted' WHERE id = ?", (row[0],))
|
||||
logger.info("Friend request accepted", from_user=req.user_id, to_user=current_user["id"])
|
||||
return {"message": "Friend request accepted"}
|
||||
|
||||
@router.post("/friends/remove")
|
||||
async def remove_friend(
|
||||
req: RemoveFriendRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM friendships WHERE (requester_id = ? AND target_id = ?) OR (requester_id = ? AND target_id = ?)",
|
||||
(current_user["id"], req.user_id, req.user_id, current_user["id"])
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Not friends")
|
||||
conn.execute("DELETE FROM friendships WHERE id = ?", (row[0],))
|
||||
logger.info("Friend removed", user=current_user["id"], target=req.user_id)
|
||||
return {"message": "Friend removed"}
|
||||
|
||||
@router.get("/friends/list")
|
||||
async def list_friends(current_user: dict = Depends(get_current_user)):
|
||||
friends = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role,
|
||||
COALESCE(us.is_online, 0) as online,
|
||||
COALESCE(us.current_pack, '') as current_pack,
|
||||
us.last_seen
|
||||
FROM friendships f
|
||||
JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id
|
||||
LEFT JOIN user_status us ON u.id = us.user_id
|
||||
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
|
||||
""", (current_user["id"], current_user["id"], current_user["id"]))
|
||||
|
||||
for row in rows:
|
||||
friends.append({
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
"role": row[2],
|
||||
"online": bool(row[3]),
|
||||
"current_pack": row[4],
|
||||
"last_seen": row[5] if row[5] else None
|
||||
})
|
||||
|
||||
return {"friends": friends}
|
||||
|
||||
@router.get("/friends/requests")
|
||||
async def list_friend_requests(current_user: dict = Depends(get_current_user)):
|
||||
requests = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role, f.created_at
|
||||
FROM friendships f
|
||||
JOIN users u ON f.requester_id = u.id
|
||||
WHERE f.target_id = ? AND f.status = 'pending'
|
||||
""", (current_user["id"],))
|
||||
for row in rows:
|
||||
requests.append({
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
"role": row[2],
|
||||
"created_at": row[3] if row[3] else None
|
||||
})
|
||||
return {"requests": requests}
|
||||
|
||||
@router.post("/friends/status")
|
||||
async def update_status(
|
||||
req: StatusUpdateRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
is_online = excluded.is_online,
|
||||
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
|
||||
last_seen = CURRENT_TIMESTAMP
|
||||
""", (current_user["id"], int(req.online), req.current_pack or ""))
|
||||
return {"status": "ok"}
|
||||
+441
-74
@@ -12,7 +12,7 @@ import json
|
||||
import structlog
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
|
||||
# Disable httpx debug logging
|
||||
@@ -22,12 +22,19 @@ logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||
from models import PackMeta
|
||||
from middleware import LoggingMiddleware
|
||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
|
||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode, run_sync_mode
|
||||
from log_manager import init_logging
|
||||
|
||||
from auth import get_current_user, router as auth_router, init_db, verify_jwt
|
||||
from roles import Permissions, has_permission
|
||||
from admin_router import router as admin_router
|
||||
from friends import router as friends_router, init_friends_db
|
||||
from playtime import router as playtime_router, init_playtime_db
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import aiofiles
|
||||
import mimetypes
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -37,6 +44,18 @@ manifest_cache = TTLCache(maxsize=100, ttl=300)
|
||||
BUILDS_DIR = Path("builds")
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
|
||||
# Mirror configuration
|
||||
LAUNCHER_MIRRORS = {
|
||||
"main": "http://87.120.187.36:1582",
|
||||
"mirror-1": "http://212.22.82.243:1582",
|
||||
}
|
||||
|
||||
# Server role: "main" or "mirror"
|
||||
SERVER_ROLE = os.environ.get("SERVER_ROLE", "main")
|
||||
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "") # For mirrors to sync from
|
||||
SYNC_API_KEY = os.environ.get("SYNC_API_KEY", "changeme") # API key for mirror sync
|
||||
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror
|
||||
|
||||
# IP Filtering Configuration
|
||||
import os
|
||||
import middleware as mw
|
||||
@@ -126,6 +145,8 @@ async def lifespan(app: FastAPI):
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
init_db()
|
||||
init_friends_db()
|
||||
init_playtime_db()
|
||||
|
||||
if args.test:
|
||||
await run_test_mode()
|
||||
@@ -147,7 +168,51 @@ async def lifespan(app: FastAPI):
|
||||
logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True)
|
||||
|
||||
logger.info("All packs ready. Server is running.")
|
||||
|
||||
|
||||
# Mirror sync with main server
|
||||
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
||||
logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}")
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/version")
|
||||
main_data = resp.json()
|
||||
main_version = main_data.get("version")
|
||||
logger.info(f"Main server version: {main_version}")
|
||||
|
||||
# Get sync manifest with API key
|
||||
headers = {"X-Sync-Key": SYNC_API_KEY}
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/sync/{main_version}", headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Sync failed: {resp.status_code} - {resp.text}")
|
||||
raise Exception(f"Sync auth failed: {resp.status_code}")
|
||||
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Need to sync {len(sync_data.get('files', []))} files")
|
||||
|
||||
# Download each file
|
||||
for f in sync_data.get("files", []):
|
||||
file_path = BUILDS_DIR / f["path"]
|
||||
if not file_path.exists():
|
||||
logger.info(f"Syncing: {f['path']}")
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download file
|
||||
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
|
||||
resp = await client.get(file_url, headers=headers)
|
||||
file_path.write_bytes(resp.content)
|
||||
logger.debug(f"Downloaded: {f['path']}")
|
||||
|
||||
# Delete removed files
|
||||
for deleted_file in sync_data.get("delete", []):
|
||||
del_path = BUILDS_DIR / deleted_file
|
||||
if del_path.exists():
|
||||
del_path.unlink()
|
||||
logger.info(f"Deleted: {deleted_file}")
|
||||
|
||||
logger.info("Mirror sync complete")
|
||||
except Exception as e:
|
||||
logger.warning(f"Mirror sync failed: {e}")
|
||||
|
||||
# Scan launcher versions and generate meta
|
||||
logger.info("Scanning launcher versions...")
|
||||
|
||||
@@ -172,6 +237,54 @@ async def lifespan(app: FastAPI):
|
||||
global proxy_client
|
||||
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||
|
||||
# Start background sync task for mirrors
|
||||
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
||||
import asyncio
|
||||
|
||||
async def periodic_sync():
|
||||
sync_interval = 7200 # 2 hours
|
||||
while True:
|
||||
await asyncio.sleep(sync_interval)
|
||||
try:
|
||||
logger.info("Periodic mirror sync started...")
|
||||
headers = {"X-Sync-Key": SYNC_API_KEY}
|
||||
|
||||
resp = await proxy_client.get(f"{MAIN_SERVER_URL}/launcher/version")
|
||||
main_data = resp.json()
|
||||
main_version = main_data.get("version")
|
||||
|
||||
resp = await proxy_client.get(
|
||||
f"{MAIN_SERVER_URL}/launcher/sync/{main_version}",
|
||||
headers=headers
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Periodic sync failed: {resp.status_code}")
|
||||
continue
|
||||
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Periodic sync: {len(sync_data.get('files', []))} files")
|
||||
|
||||
for f in sync_data.get("files", []):
|
||||
file_path = BUILDS_DIR / f["path"]
|
||||
if not file_path.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
|
||||
resp = await proxy_client.get(file_url, headers=headers)
|
||||
file_path.write_bytes(resp.content)
|
||||
logger.debug(f"Synced: {f['path']}")
|
||||
|
||||
for deleted_file in sync_data.get("delete", []):
|
||||
del_path = BUILDS_DIR / deleted_file
|
||||
if del_path.exists():
|
||||
del_path.unlink()
|
||||
logger.info(f"Deleted: {deleted_file}")
|
||||
|
||||
logger.info("Periodic mirror sync complete")
|
||||
except Exception as e:
|
||||
logger.warning(f"Periodic sync error: {e}")
|
||||
|
||||
asyncio.create_task(periodic_sync())
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup proxy client
|
||||
@@ -512,9 +625,135 @@ app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
|
||||
# Add Logging middleware
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
|
||||
# ====================== ОПТИМИЗАЦИЯ ЗАГРУЗКИ ФАЙЛОВ ======================
|
||||
|
||||
class CacheControlMiddleware:
|
||||
"""Middleware for caching static and large files"""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
|
||||
# Skip caching for dynamic endpoints
|
||||
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"])
|
||||
if skip_cache:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_wrapper(status, headers, *args, **kwargs):
|
||||
cache_headers = [(b"cache-control", b"public, max-age=86400")]
|
||||
headers = list(headers) + cache_headers
|
||||
await send(status, headers, *args, **kwargs)
|
||||
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
|
||||
app.add_middleware(CacheControlMiddleware)
|
||||
|
||||
|
||||
# Cache for file hashes (ETag)
|
||||
file_etag_cache = TTLCache(maxsize=1000, ttl=3600)
|
||||
|
||||
|
||||
async def get_etag_for_file(file_path: Path) -> str:
|
||||
"""Get or calculate ETag for file"""
|
||||
cache_key = str(file_path)
|
||||
|
||||
if cache_key in file_etag_cache:
|
||||
return file_etag_cache[cache_key]
|
||||
|
||||
# Calculate from file size + mtime
|
||||
stat = file_path.stat()
|
||||
etag = f'"{stat.st_size}-{stat.st_mtime}"'
|
||||
file_etag_cache[cache_key] = etag
|
||||
|
||||
return etag
|
||||
|
||||
|
||||
async def send_file_async(
|
||||
file_path: Path,
|
||||
request: Request,
|
||||
content_type: str = None,
|
||||
cache: bool = True
|
||||
):
|
||||
"""Optimized async file serving with Range support"""
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
|
||||
# Determine content type
|
||||
if content_type is None:
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
|
||||
# Check for Range header (for resumable downloads)
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
if range_header:
|
||||
# Parse Range header
|
||||
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
|
||||
if match:
|
||||
start = int(match.group(1))
|
||||
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
|
||||
else:
|
||||
start, end = 0, file_size - 1
|
||||
|
||||
content_length = end - start + 1
|
||||
|
||||
# Read chunk asynchronously
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
await f.seek(start)
|
||||
chunk = await f.read(content_length)
|
||||
|
||||
# Return 206 Partial Content
|
||||
return StreamingResponse(
|
||||
iter([chunk]),
|
||||
status_code=206,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(content_length),
|
||||
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Return full file with streaming
|
||||
async def file_iterator():
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(65536) # 64KB chunks
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
# Calculate ETag
|
||||
etag = await get_etag_for_file(file_path)
|
||||
|
||||
return StreamingResponse(
|
||||
file_iterator(),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(file_size),
|
||||
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
|
||||
"ETag": etag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
)
|
||||
|
||||
# Register routers
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(friends_router)
|
||||
app.include_router(playtime_router)
|
||||
|
||||
|
||||
# Monkey patch to catch invalid HTTP requests
|
||||
@@ -583,18 +822,16 @@ async def activate_pass_page():
|
||||
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
|
||||
|
||||
@app.get("/packs")
|
||||
async def list_packs(current_user: dict = Depends(get_current_user)):
|
||||
"""List all available packs - требует проходку для просмотра"""
|
||||
|
||||
# Проверяем, есть ли право на просмотр сборок
|
||||
async def list_packs(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""List all available packs - requires auth"""
|
||||
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Для просмотра сборок требуется активная проходка"
|
||||
)
|
||||
|
||||
raise HTTPException(403, "Requires active pass")
|
||||
|
||||
packs = []
|
||||
|
||||
|
||||
for pack_dir in PACKS_DIR.iterdir():
|
||||
if pack_dir.is_dir():
|
||||
meta_path = DATA_DIR / f"{pack_dir.name}.meta"
|
||||
@@ -605,17 +842,23 @@ async def list_packs(current_user: dict = Depends(get_current_user)):
|
||||
updated_at = meta.get("updated_at")
|
||||
if updated_at and isinstance(updated_at, datetime):
|
||||
updated_at = updated_at.isoformat()
|
||||
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"version": meta.get("version", 1),
|
||||
"files_count": len(meta.get("files", {})),
|
||||
"updated_at": updated_at,
|
||||
"minecraft_version": meta.get("minecraft_version", "unknown"),
|
||||
"loader_type": meta.get("loader_type", "vanilla"),
|
||||
"loader_version": meta.get("loader_version"),
|
||||
"asset_index": meta.get("asset_index")
|
||||
})
|
||||
|
||||
desc_path = pack_dir / "description.txt"
|
||||
description = ""
|
||||
if desc_path.exists():
|
||||
description = desc_path.read_text(encoding="utf-8")
|
||||
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"version": meta.get("version", 1),
|
||||
"files_count": len(meta.get("files", {})),
|
||||
"updated_at": updated_at,
|
||||
"minecraft_version": meta.get("minecraft_version", "unknown"),
|
||||
"loader_type": meta.get("loader_type", "vanilla"),
|
||||
"loader_version": meta.get("loader_version"),
|
||||
"asset_index": meta.get("asset_index"),
|
||||
"description": description
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}")
|
||||
packs.append({
|
||||
@@ -713,7 +956,7 @@ async def get_pack_diff(
|
||||
|
||||
|
||||
@app.get("/pack/{pack_name}")
|
||||
async def get_pack_manifest(pack_name: str, request: Request):
|
||||
async def get_pack_manifest(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Get pack manifest with caching"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
@@ -760,7 +1003,12 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
# Security: prevent path traversal
|
||||
if ".." in file_path:
|
||||
try:
|
||||
full_path = full_path.resolve()
|
||||
pack_root = (PACKS_DIR / pack_name).resolve()
|
||||
if not str(full_path).startswith(str(pack_root)):
|
||||
raise HTTPException(403, "Invalid file path")
|
||||
except (ValueError, OSError):
|
||||
raise HTTPException(403, "Invalid file path")
|
||||
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
@@ -770,13 +1018,13 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
client_ip=client_ip)
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
logger.info("Serving file",
|
||||
pack=pack_name,
|
||||
file=file_path,
|
||||
logger.info("Serving file",
|
||||
pack=pack_name,
|
||||
file=file_path,
|
||||
size=full_path.stat().st_size,
|
||||
client_ip=client_ip)
|
||||
|
||||
return FileResponse(full_path, direct_passthrough=True)
|
||||
|
||||
return await send_file_async(full_path, request, cache=True)
|
||||
|
||||
|
||||
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
|
||||
@@ -877,11 +1125,14 @@ def generate_launcher_builds_meta():
|
||||
logger.warning(f"Failed to generate launcher meta: {e}")
|
||||
return
|
||||
|
||||
mirrors = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
|
||||
|
||||
meta = {
|
||||
"version": version,
|
||||
"type": "builds",
|
||||
"release_date": datetime.utcnow().isoformat(),
|
||||
"files": files
|
||||
"files": files,
|
||||
"mirrors": mirrors
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1128,16 +1379,16 @@ async def get_launcher_version():
|
||||
|
||||
|
||||
@app.get("/launcher/download/jar")
|
||||
async def download_launcher_jar():
|
||||
async def download_launcher_jar(request: Request = None):
|
||||
"""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"
|
||||
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "JAR file not found")
|
||||
|
||||
|
||||
if request:
|
||||
return await send_file_async(file_path, request, content_type="application/java-archive", cache=True)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="zernmclauncher.jar",
|
||||
@@ -1146,13 +1397,16 @@ async def download_launcher_jar():
|
||||
|
||||
|
||||
@app.get("/launcher/download/exe")
|
||||
async def download_launcher_exe():
|
||||
async def download_launcher_exe(request: Request = None):
|
||||
"""Download launcher EXE file (Windows)"""
|
||||
file_path = BUILDS_DIR / "ZernMCLauncher.exe"
|
||||
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "EXE file not found")
|
||||
|
||||
|
||||
if request:
|
||||
return await send_file_async(file_path, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="ZernMCLauncher.exe",
|
||||
@@ -1161,17 +1415,20 @@ async def download_launcher_exe():
|
||||
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_launcher_zip(filename: str):
|
||||
async def download_launcher_zip(filename: str, request: Request = None):
|
||||
"""Download specific launcher ZIP archive"""
|
||||
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")
|
||||
|
||||
|
||||
file_path = BUILDS_DIR / filename
|
||||
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "ZIP file not found")
|
||||
|
||||
|
||||
if request:
|
||||
return await send_file_async(file_path, request, content_type="application/zip", cache=True)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
@@ -1203,28 +1460,6 @@ async def download_legacy_launcher():
|
||||
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")
|
||||
@@ -1233,12 +1468,82 @@ async def get_launcher_meta_list():
|
||||
versions = get_launcher_versions()
|
||||
return {
|
||||
"versions": [
|
||||
{"version": v["version"], "meta": v["meta"]}
|
||||
{"version": v["version"], "meta": v["meta"]}
|
||||
for v in versions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.get("/launcher/mirrors")
|
||||
async def get_launcher_mirrors():
|
||||
"""Get list of available mirrors"""
|
||||
mirrors_list = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
|
||||
return {"mirrors": mirrors_list}
|
||||
|
||||
|
||||
# ====================== SYNC FOR MIRRORS ======================
|
||||
|
||||
def verify_sync_api_key(request: Request):
|
||||
"""Verify API key for sync endpoints"""
|
||||
api_key = request.headers.get("X-Sync-Key")
|
||||
if not api_key or api_key != SYNC_API_KEY:
|
||||
raise HTTPException(401, "Invalid or missing sync API key")
|
||||
|
||||
|
||||
@app.get("/launcher/sync/{version}")
|
||||
async def get_sync_manifest(version: str, request: Request):
|
||||
"""Get sync manifest for mirror servers - returns files to download/delete"""
|
||||
verify_sync_api_key(request)
|
||||
|
||||
if SERVER_ROLE != "main":
|
||||
raise HTTPException(403, "Sync only available on main server")
|
||||
|
||||
# Check for incremental sync (if-modified-since)
|
||||
last_sync = request.headers.get("If-Modified-Since")
|
||||
|
||||
# Get server meta
|
||||
meta = get_launcher_version_meta(version)
|
||||
if not meta:
|
||||
raise HTTPException(404, f"Version {version} not found")
|
||||
|
||||
# Build sync response
|
||||
sync_data = {
|
||||
"version": version,
|
||||
"files": [],
|
||||
"delete": [], # Files that were removed from main
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
for f in meta.get("files", []):
|
||||
sync_data["files"].append({
|
||||
"path": f["path"],
|
||||
"size": f["size"],
|
||||
"hash": f["hash"],
|
||||
"url": f"/launcher/file/{version}/{f['path']}"
|
||||
})
|
||||
|
||||
return sync_data
|
||||
|
||||
|
||||
@app.get("/launcher/sync/{version}/file/{file_path:path}")
|
||||
async def sync_download_file(version: str, file_path: str, request: Request):
|
||||
"""Download file for mirror sync"""
|
||||
verify_sync_api_key(request)
|
||||
|
||||
if SERVER_ROLE != "main":
|
||||
raise HTTPException(403, "Sync only available on main server")
|
||||
|
||||
full_path = BUILDS_DIR / file_path
|
||||
|
||||
if ".." in file_path:
|
||||
raise HTTPException(403, "Invalid path")
|
||||
|
||||
if not full_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
return await send_file_async(full_path, request, cache=False)
|
||||
|
||||
|
||||
@app.get("/launcher/meta/{version}")
|
||||
async def get_launcher_version_meta_handler(version: str):
|
||||
"""Get meta for specific launcher version"""
|
||||
@@ -1305,18 +1610,22 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
|
||||
full_path = alt_path
|
||||
else:
|
||||
raise HTTPException(404, "File not found: " + file_path)
|
||||
|
||||
return FileResponse(full_path, direct_passthrough=True)
|
||||
|
||||
return await send_file_async(full_path, request, cache=True)
|
||||
|
||||
|
||||
@app.get("/launcher/download/zip/{version}")
|
||||
async def download_launcher_zip_version(version: str):
|
||||
async def download_launcher_zip_version(version: str, request: Request = None):
|
||||
"""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")
|
||||
|
||||
|
||||
if request:
|
||||
return await send_file_async(zip_path, request, content_type="application/zip", cache=True)
|
||||
|
||||
# Fallback without request
|
||||
return FileResponse(
|
||||
path=zip_path,
|
||||
filename=f"ZernMC-win-{version}.zip",
|
||||
@@ -1378,6 +1687,61 @@ async def get_launcher_full_info():
|
||||
return info
|
||||
|
||||
|
||||
# ====================== НОВОСТИ ======================
|
||||
|
||||
NEWS_DIR = Path(__file__).parent / "news"
|
||||
|
||||
|
||||
@app.get("/news")
|
||||
async def list_news():
|
||||
"""List all news files with their content"""
|
||||
if not NEWS_DIR.exists():
|
||||
return {"news": []}
|
||||
|
||||
news_list = []
|
||||
for f in sorted(NEWS_DIR.iterdir()):
|
||||
if f.is_file() and f.suffix == ".txt":
|
||||
try:
|
||||
content = f.read_text(encoding="utf-8").strip().split("\n")
|
||||
if len(content) >= 4:
|
||||
title = content[0].strip()
|
||||
news_type = content[1].strip()
|
||||
version = content[2].strip()
|
||||
body = "\n".join(content[3:]).strip()
|
||||
news_list.append({
|
||||
"id": f.stem,
|
||||
"title": title,
|
||||
"type": news_type,
|
||||
"version": version,
|
||||
"body": body
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read news file {f.name}: {e}")
|
||||
|
||||
news_list.reverse()
|
||||
return {"news": news_list}
|
||||
|
||||
|
||||
@app.get("/news/{news_id}")
|
||||
async def get_news(news_id: str):
|
||||
"""Get a single news item by ID"""
|
||||
file_path = NEWS_DIR / f"{news_id}.txt"
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "News not found")
|
||||
|
||||
content = file_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
if len(content) < 4:
|
||||
raise HTTPException(400, "Invalid news file format")
|
||||
|
||||
return {
|
||||
"id": file_path.stem,
|
||||
"title": content[0].strip(),
|
||||
"type": content[1].strip(),
|
||||
"version": content[2].strip(),
|
||||
"body": "\n".join(content[3:]).strip()
|
||||
}
|
||||
|
||||
|
||||
# ====================== ПРОКСИ ЭНДПОИНТЫ ======================
|
||||
# Эти эндпоинты позволяют клиентам с сетевыми проблемами
|
||||
# скачивать файлы через сервер Zern
|
||||
@@ -1776,10 +2140,13 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
|
||||
|
||||
if args.test:
|
||||
import asyncio
|
||||
asyncio.run(run_test_mode())
|
||||
elif args.sync:
|
||||
import asyncio
|
||||
asyncio.run(run_sync_mode())
|
||||
elif args.dev:
|
||||
run_development_mode(args.host, args.port, args.reload)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lightweight Mirror Server - only serves static files
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import structlog
|
||||
import httpx
|
||||
|
||||
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582")
|
||||
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta")
|
||||
PORT = int(os.environ.get("PORT", "1582"))
|
||||
|
||||
BUILDS_DIR = Path("builds")
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
PACKS_DIR = Path("packs")
|
||||
|
||||
BUILDS_DIR.mkdir(exist_ok=True)
|
||||
PACKS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logging = structlog.get_logger()
|
||||
|
||||
|
||||
async def sync_with_main():
|
||||
"""Sync files from main server"""
|
||||
logging.info(f"Syncing from {MAIN_SERVER_URL}")
|
||||
|
||||
client = httpx.AsyncClient(timeout=120.0)
|
||||
headers = {"X-Master-Key": MASTER_KEY}
|
||||
|
||||
try:
|
||||
# Get launcher info
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logging.error(f"Failed to get launcher info: {resp.status_code}")
|
||||
return
|
||||
|
||||
data = resp.json()
|
||||
current_version = data.get("current_version", "1.0.9")
|
||||
files = data.get("files", {})
|
||||
zips = files.get("zips", [])
|
||||
logging.info(f"Current version: {current_version}, zips: {len(zips)}")
|
||||
|
||||
# Download latest ZIP
|
||||
for z in zips:
|
||||
if not z.get("is_legacy"):
|
||||
zip_filename = z.get("filename")
|
||||
zip_path = BUILDS_DIR / zip_filename
|
||||
if not zip_path.exists():
|
||||
logging.info(f"Downloading {zip_filename}...")
|
||||
# Try direct download
|
||||
download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}"
|
||||
resp = await client.get(download_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
zip_path.write_bytes(resp.content)
|
||||
logging.info(f"Downloaded {zip_filename}")
|
||||
|
||||
# Extract
|
||||
version = z.get("version")
|
||||
extract_to = VERSIONS_DIR / version
|
||||
extract_to.mkdir(parents=True, exist_ok=True)
|
||||
import zipfile
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
zf.extractall(extract_to)
|
||||
logging.info(f"Extracted {version}")
|
||||
|
||||
# Get launcher meta
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
(BUILDS_DIR / "meta.json").write_text(resp.text)
|
||||
logging.info("Meta synced")
|
||||
|
||||
# Sync packs list
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
packs_data = resp.json()
|
||||
packs = packs_data.get("packs", [])
|
||||
logging.info(f"Found {len(packs)} packs")
|
||||
|
||||
for pack in packs:
|
||||
pack_name = pack.get("name")
|
||||
pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}"
|
||||
resp = await client.get(pack_meta_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
pack_dir = PACKS_DIR / pack_name
|
||||
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||
(pack_dir / "meta.json").write_text(resp.text)
|
||||
logging.info(f"Synced pack: {pack_name}")
|
||||
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
logging.info("Sync complete")
|
||||
|
||||
|
||||
async def run_server():
|
||||
"""Run static server"""
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
import aiofiles
|
||||
import mimetypes
|
||||
import re
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="ZernMC Mirror")
|
||||
|
||||
async def send_file(file_path: Path, request: Request):
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
if range_header:
|
||||
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
|
||||
if match:
|
||||
start = int(match.group(1))
|
||||
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
|
||||
content_length = end - start + 1
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
await f.seek(start)
|
||||
chunk = await f.read(content_length)
|
||||
return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type,
|
||||
headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)})
|
||||
|
||||
async def file_iter():
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(file_iter(), media_type=content_type,
|
||||
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)})
|
||||
|
||||
@app.get("/launcher/info")
|
||||
async def get_launcher_info():
|
||||
meta_path = BUILDS_DIR / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
return {"current_version": "unknown", "files": {}}
|
||||
|
||||
@app.get("/launcher/version")
|
||||
async def get_version():
|
||||
return await get_launcher_info()
|
||||
|
||||
@app.get("/launcher/file/{version}/{file_path:path}")
|
||||
async def get_launcher_file(version: str, file_path: str, request: Request):
|
||||
full_path = BUILDS_DIR / "versions" / version / file_path
|
||||
if ".." in file_path:
|
||||
raise HTTPException(403, "Invalid path")
|
||||
if not full_path.exists():
|
||||
raise HTTPException(404, f"File not found: {file_path}")
|
||||
return await send_file(full_path, request)
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_zip(filename: str, request: Request):
|
||||
return await send_file(BUILDS_DIR / filename, request)
|
||||
|
||||
@app.get("/launcher/meta/{version}")
|
||||
async def get_meta(version: str):
|
||||
meta_path = BUILDS_DIR / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
raise HTTPException(404, "Meta not found")
|
||||
|
||||
@app.get("/launcher/mirrors")
|
||||
async def get_mirrors():
|
||||
return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]}
|
||||
|
||||
@app.get("/packs")
|
||||
async def list_packs():
|
||||
import json
|
||||
packs = []
|
||||
for pack_dir in PACKS_DIR.iterdir():
|
||||
if pack_dir.is_dir():
|
||||
meta_path = pack_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"version": meta.get("version", 1),
|
||||
"files_count": len(meta.get("files", {}))
|
||||
})
|
||||
except:
|
||||
packs.append({"name": pack_dir.name, "error": "invalid"})
|
||||
return {"packs": packs}
|
||||
|
||||
@app.get("/pack/{pack_name}")
|
||||
async def get_pack(pack_name: str):
|
||||
meta_path = PACKS_DIR / pack_name / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
raise HTTPException(404, "Pack not found")
|
||||
|
||||
@app.get("/pack/meta/{pack_name}")
|
||||
async def get_pack_meta(pack_name: str):
|
||||
return await get_pack(pack_name)
|
||||
|
||||
@app.get("/pack/{pack_name}/diff")
|
||||
async def get_pack_diff(pack_name: str):
|
||||
# For mirror, just return empty diff (no local changes)
|
||||
return {"added": [], "removed": [], "changed": []}
|
||||
|
||||
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||
async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
return await send_file(PACKS_DIR / pack_name / file_path, request)
|
||||
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
async def main():
|
||||
logging.info("Starting ZernMC Mirror Server")
|
||||
await sync_with_main()
|
||||
await run_server()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+12
-12
@@ -5,6 +5,8 @@ from pathlib import Path
|
||||
import json
|
||||
from typing import Optional, Dict
|
||||
import structlog
|
||||
import asyncio
|
||||
import aiofiles
|
||||
|
||||
from models import PackMeta, FileEntry
|
||||
|
||||
@@ -33,9 +35,9 @@ def calculate_sha256_sync(file_path: Path) -> str:
|
||||
return hash_sha.hexdigest()
|
||||
|
||||
async def calculate_sha256(file_path: Path) -> str:
|
||||
"""Calculate SHA256 hash of a file (async wrapper)"""
|
||||
# Используем синхронную версию для простоты
|
||||
return calculate_sha256_sync(file_path)
|
||||
"""Calculate SHA256 hash of a file (async)"""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, calculate_sha256_sync, file_path)
|
||||
|
||||
async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
"""Scan pack directory and update manifest if needed"""
|
||||
@@ -51,11 +53,11 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
if not force_rescan and pack_name in _manifest_cache:
|
||||
return _manifest_cache[pack_name]
|
||||
|
||||
# Load existing meta if available (синхронно)
|
||||
# Load existing meta if available
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f:
|
||||
data = json.loads(await f.read())
|
||||
current_meta = PackMeta.model_validate(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}")
|
||||
@@ -114,9 +116,8 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
pack_config_path = pack_path / "instance.json"
|
||||
if pack_config_path.exists():
|
||||
try:
|
||||
# Синхронное чтение конфига
|
||||
with open(pack_config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
async with aiofiles.open(pack_config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.loads(await f.read())
|
||||
minecraft_version = config.get("minecraftVersion", minecraft_version)
|
||||
loader_type = config.get("loaderType", loader_type)
|
||||
loader_version = config.get("loaderVersion")
|
||||
@@ -137,9 +138,8 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
asset_index=asset_index
|
||||
)
|
||||
|
||||
# Save to disk (синхронно)
|
||||
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_meta.model_dump_json(indent=2))
|
||||
async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(new_meta.model_dump_json(indent=2))
|
||||
|
||||
# Update cache
|
||||
_manifest_cache[pack_name] = new_meta
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
from auth import get_db, get_current_user
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["playtime"])
|
||||
|
||||
def init_playtime_db():
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS playtime (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
pack_name TEXT DEFAULT '',
|
||||
minutes INTEGER DEFAULT 0,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_playtime_user ON playtime(user_id);
|
||||
""")
|
||||
|
||||
class SyncPlaytimeRequest(BaseModel):
|
||||
minutes: int
|
||||
pack_name: Optional[str] = ""
|
||||
|
||||
@router.post("/playtime/sync")
|
||||
async def sync_playtime(
|
||||
req: SyncPlaytimeRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
if req.minutes < 0 or req.minutes > 60:
|
||||
raise HTTPException(400, "Minutes must be between 0 and 60")
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, minutes FROM playtime WHERE user_id = ? AND pack_name = ?",
|
||||
(current_user["id"], req.pack_name)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE playtime SET minutes = minutes + ?, last_updated = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(req.minutes, existing[0])
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO playtime (user_id, pack_name, minutes) VALUES (?, ?, ?)",
|
||||
(current_user["user_id"], req.pack_name, req.minutes)
|
||||
)
|
||||
logger.info("Playtime synced", user=current_user["user_id"], minutes=req.minutes)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/playtime/stats")
|
||||
async def get_playtime_stats(current_user: dict = Depends(get_current_user)):
|
||||
total_minutes = 0
|
||||
pack_stats = []
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT COALESCE(SUM(minutes), 0) FROM playtime WHERE user_id = ?",
|
||||
(current_user["user_id"],)
|
||||
)
|
||||
total_minutes = rows.fetchone()[0]
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT pack_name, minutes FROM playtime WHERE user_id = ? AND pack_name != '' ORDER BY minutes DESC",
|
||||
(current_user["user_id"],)
|
||||
)
|
||||
for row in rows:
|
||||
pack_stats.append({
|
||||
"pack_name": row[0],
|
||||
"minutes": row[1]
|
||||
})
|
||||
return {
|
||||
"total_minutes": total_minutes,
|
||||
"total_hours": round(total_minutes / 60, 1),
|
||||
"packs": pack_stats
|
||||
}
|
||||
@@ -72,10 +72,66 @@ class TestPassMyStatus:
|
||||
"""Test /auth/pass/my endpoint."""
|
||||
|
||||
def test_my_pass_no_pass(self, client, logged_in_user):
|
||||
# Route may not exist
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code in (200, 404)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert "has_active" in data
|
||||
assert data["has_active"] is False
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": False}
|
||||
|
||||
def test_my_pass_with_pass(self, client, logged_in_user_with_pass):
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
pass_code = f"PASS-{secrets.token_hex(4)}"
|
||||
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
SELECT id, ?, ? FROM users WHERE username = ?
|
||||
""", (pass_code, time.time(), logged_in_user_with_pass["username"]))
|
||||
conn.execute("UPDATE passes SET uses = 1 WHERE code = ?", (pass_code,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user_with_pass["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": True}
|
||||
|
||||
def test_my_pass_after_activation(self, client, logged_in_user):
|
||||
pass_code = f"AFTER-{secrets.token_hex(4)}"
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/pass/activate", json={"pass_code": pass_code},
|
||||
headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": True}
|
||||
|
||||
def test_my_pass_stale_jwt_role(self, client, registered_user):
|
||||
"""Test that /auth/pass/my works even if JWT has stale role.
|
||||
|
||||
Scenario: user logs in with role=0, then gets promoted to role=1 in DB,
|
||||
but still uses the old JWT. The endpoint should check DB directly."""
|
||||
resp = client.post("/auth/login", json=registered_user)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
old_token = data["access_token"]
|
||||
assert data["role"] == 0
|
||||
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],))
|
||||
pass_code = f"STALE-{secrets.token_hex(4)}"
|
||||
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
SELECT id, ?, ? FROM users WHERE username = ?
|
||||
""", (pass_code, time.time(), registered_user["username"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(old_token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"has_active": True}, "Should detect active pass despite stale JWT role"
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration test for ZernMC Launcher frontend.
|
||||
Tests: auto-login, settings scroll, pack launch
|
||||
"""
|
||||
import json, os, threading, time, socket, sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
UI_DIR = Path("/root/launcher/launcher/launcher/src/resources/ui")
|
||||
PORT = 9876
|
||||
|
||||
MOCK_INSTANCES = [
|
||||
{
|
||||
"name": "ZernMC-Vanilla",
|
||||
"version": "1.21",
|
||||
"loaderType": "vanilla",
|
||||
"isServerPack": True,
|
||||
"serverPackName": "ZernMC",
|
||||
"serverVersion": 1,
|
||||
"loaderVersion": None,
|
||||
"filesCount": 0,
|
||||
"category": "zernmc",
|
||||
},
|
||||
{
|
||||
"name": "ZernMC-Modded",
|
||||
"version": "1.20.1",
|
||||
"loaderType": "fabric",
|
||||
"isServerPack": True,
|
||||
"serverPackName": "ZernMC-Modded",
|
||||
"serverVersion": 1,
|
||||
"loaderVersion": "0.15.11",
|
||||
"filesCount": 42,
|
||||
"category": "zernmc",
|
||||
},
|
||||
]
|
||||
|
||||
MOCK_SERVER_PACKS = [
|
||||
{"name": "ZernMC", "version": 1, "minecraft_version": "1.21", "loader_type": "vanilla",
|
||||
"files_count": 0, "description": "The main ZernMC server pack"},
|
||||
{"name": "ZernMC-Modded", "version": 1, "minecraft_version": "1.20.1", "loader_type": "fabric",
|
||||
"files_count": 42, "loader_version": "0.15.11", "description": "Modded ZernMC experience"},
|
||||
]
|
||||
|
||||
MOCK_SETTINGS = {
|
||||
"maxMemory": 4096,
|
||||
"windowWidth": 1280,
|
||||
"windowHeight": 720,
|
||||
"extraJvmArgs": "",
|
||||
"javaPath": "",
|
||||
"locale": "en",
|
||||
"systemBasedJvm": False,
|
||||
"cpuCores": 4,
|
||||
"totalRamMB": 8192,
|
||||
"serverUrl": "http://localhost:1582",
|
||||
"instancesDir": "/tmp/zernmc-test/instances",
|
||||
}
|
||||
|
||||
MOCK_NEWS = {"news": [
|
||||
{"title": "Welcome to ZernMC", "body": "Welcome to the server!", "type": "Announcement", "version": "1.0"},
|
||||
{"title": "New Update", "body": "Check out the new features!", "type": "Update", "version": "1.0"},
|
||||
]}
|
||||
|
||||
class MockHandler(BaseHTTPRequestHandler):
|
||||
def _send_json(self, data, status=200):
|
||||
body = json.dumps(data).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_body(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
return json.loads(self.rfile.read(length)) if length > 0 else {}
|
||||
|
||||
def _serve_file(self, filename):
|
||||
file_path = UI_DIR / filename
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return False
|
||||
content = file_path.read_bytes()
|
||||
ext = file_path.suffix
|
||||
ct_map = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8"}
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ct_map.get(ext, "application/octet-stream"))
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path
|
||||
if path in ("/", "/index.html"):
|
||||
self._serve_file("index.html")
|
||||
elif path == "/launcher.js":
|
||||
self._serve_file("launcher.js")
|
||||
elif path == "/style.css":
|
||||
self._serve_file("style.css")
|
||||
elif path == "/marked.min.js":
|
||||
self._serve_file("marked.min.js")
|
||||
elif "/api/auto-login" in path:
|
||||
self._send_json({"success": True, "autoLogin": True,
|
||||
"data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
|
||||
elif "/api/account" in path:
|
||||
self._send_json({"success": True, "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
|
||||
elif "/api/settings" in path:
|
||||
self._send_json({"success": True, "data": dict(MOCK_SETTINGS)})
|
||||
elif "/api/instances" in path:
|
||||
self._send_json({"success": True, "data": MOCK_INSTANCES})
|
||||
elif "/api/packs" in path:
|
||||
self._send_json({"success": True, "data": MOCK_SERVER_PACKS})
|
||||
elif "/api/news" in path:
|
||||
self._send_json({"success": True, "data": json.dumps(MOCK_NEWS)})
|
||||
elif "/api/mc-versions" in path:
|
||||
self._send_json({"success": True, "data": ["1.21", "1.20.1", "1.20"]})
|
||||
elif "/api/loader-versions" in path:
|
||||
self._send_json({"success": True, "data": ["0.15.11", "0.15.10"]})
|
||||
elif "/api/pack-info" in path:
|
||||
self._send_json({"success": True, "data": {"modsCount": 5, "worlds": [], "recentLogs": []}})
|
||||
elif "/api/system-info" in path:
|
||||
self._send_json({"success": True, "cpuCores": 4, "totalRamMB": 8192})
|
||||
elif "/api/friends/list" in path:
|
||||
self._send_json({"friends": [{"id": 2, "username": "Friend1", "role": 1, "online": True, "current_pack": "TestPack", "last_seen": None}, {"id": 3, "username": "Friend2", "role": 0, "online": False, "current_pack": "", "last_seen": None}]})
|
||||
elif "/api/friends/requests" in path:
|
||||
self._send_json({"requests": []})
|
||||
elif "/api/playtime/stats" in path:
|
||||
self._send_json({"total_minutes": 120, "total_hours": 2.0, "packs": [{"pack_name": "TestPack", "minutes": 120}]})
|
||||
else:
|
||||
self._send_json({"success": False, "error": "Not found"}, 404)
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path
|
||||
body = self._read_body()
|
||||
if "/api/login" in path:
|
||||
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
|
||||
elif "/api/register" in path:
|
||||
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
|
||||
elif "/api/settings" in path:
|
||||
MOCK_SETTINGS.update({k: v for k, v in body.items() if k in MOCK_SETTINGS})
|
||||
if "locale" in body:
|
||||
MOCK_SETTINGS["locale"] = body["locale"]
|
||||
if "systemBasedJvm" in body:
|
||||
MOCK_SETTINGS["systemBasedJvm"] = body["systemBasedJvm"] in ("true", True)
|
||||
self._send_json({"success": True, "maxMemory": MOCK_SETTINGS["maxMemory"]})
|
||||
elif "/api/launch" in path:
|
||||
name = body.get("name", "unknown")
|
||||
self._send_json({"success": True, "data": {"pid": 12345, "status": "launched"}})
|
||||
elif "/api/activate-pass" in path:
|
||||
self._send_json({"success": True, "message": "Pass activated!"})
|
||||
elif "/api/logout" in path:
|
||||
self._send_json({"success": True})
|
||||
elif "/api/open-url" in path:
|
||||
self._send_json({"success": True})
|
||||
elif "/api/open-log-file" in path:
|
||||
self._send_json({"success": True})
|
||||
elif "/api/friends/add" in path:
|
||||
self._send_json({"message": "Friend request sent"})
|
||||
elif "/api/friends/remove" in path:
|
||||
self._send_json({"message": "Friend removed"})
|
||||
elif "/api/friends/accept" in path:
|
||||
self._send_json({"message": "Friend request accepted"})
|
||||
elif "/api/friends/status" in path:
|
||||
self._send_json({"status": "ok"})
|
||||
elif "/api/playtime/sync" in path:
|
||||
self._send_json({"status": "ok"})
|
||||
else:
|
||||
self._send_json({"success": False, "error": "Not found"}, 404)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # suppress HTTP server logs
|
||||
|
||||
def server_thread():
|
||||
server = HTTPServer(("127.0.0.1", PORT), MockHandler)
|
||||
server.serve_forever()
|
||||
|
||||
def wait_for_server(host, port, timeout=10):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
s = socket.socket()
|
||||
s.connect((host, port))
|
||||
s.close()
|
||||
return True
|
||||
except:
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
def main():
|
||||
svr = threading.Thread(target=server_thread, daemon=True)
|
||||
svr.start()
|
||||
if not wait_for_server("127.0.0.1", PORT):
|
||||
print("Failed to start mock server")
|
||||
sys.exit(1)
|
||||
print(f"Mock server running on http://127.0.0.1:{PORT}")
|
||||
|
||||
results = {"passed": 0, "failed": 0, "errors": []}
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context(viewport={"width": 1280, "height": 720})
|
||||
page = context.new_page()
|
||||
|
||||
console_logs = []
|
||||
page.on("console", lambda msg: console_logs.append(f"[{msg.type}] {msg.text}"))
|
||||
page.on("pageerror", lambda err: console_logs.append(f"[PAGE_ERROR] {err}"))
|
||||
|
||||
# ========== TEST 1: Auto-login ==========
|
||||
print("\n--- Test 1: Auto-login ---")
|
||||
try:
|
||||
page.goto(f"http://127.0.0.1:{PORT}/", wait_until="load", timeout=15000)
|
||||
page.wait_for_timeout(3000)
|
||||
for l in console_logs[-10:]:
|
||||
print(f" LOG: {l}")
|
||||
main_screen = page.locator("#main-screen")
|
||||
visible = main_screen.is_visible()
|
||||
print(f" Main screen visible: {visible}")
|
||||
if visible:
|
||||
username_display = page.locator("#username-display")
|
||||
uname = username_display.text_content()
|
||||
print(f" Username: {uname}")
|
||||
if uname == "TestPlayer":
|
||||
print(" PASS: Auto-login shows main screen with correct username")
|
||||
results["passed"] += 1
|
||||
else:
|
||||
print(f" FAIL: Expected TestPlayer, got {uname}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"auto-login: wrong username {uname}")
|
||||
else:
|
||||
login_screen = page.locator("#login-screen")
|
||||
print(f" Login screen visible: {login_screen.is_visible()}")
|
||||
page.screenshot(path="/tmp/auto-login-fail.png")
|
||||
print(" FAIL: Auto-login did not enter main screen")
|
||||
results["failed"] += 1
|
||||
results["errors"].append("auto-login: main screen not visible")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"auto-login: {e}")
|
||||
|
||||
# ========== TEST 2: Settings scroll ==========
|
||||
print("\n--- Test 2: Settings scroll ---")
|
||||
try:
|
||||
settings_btn = page.locator("#settings-btn")
|
||||
settings_btn.click()
|
||||
page.wait_for_timeout(1500)
|
||||
settings_view = page.locator("#view-settings")
|
||||
sv_class = settings_view.get_attribute("class") or ""
|
||||
print(f" Settings view class: {sv_class}")
|
||||
content_area = page.locator(".content")
|
||||
overflow = content_area.evaluate("el => getComputedStyle(el).overflowY")
|
||||
print(f" .content overflow-y: {overflow}")
|
||||
scroll_h = content_area.evaluate("el => el.scrollHeight")
|
||||
client_h = content_area.evaluate("el => el.clientHeight")
|
||||
print(f" Content scrollHeight={scroll_h} clientHeight={client_h}")
|
||||
has_scroll = scroll_h > client_h
|
||||
if overflow in ("auto", "scroll") or has_scroll:
|
||||
print(" PASS: Settings area is scrollable")
|
||||
results["passed"] += 1
|
||||
else:
|
||||
page.screenshot(path="/tmp/settings-no-scroll.png")
|
||||
print(" FAIL: Settings area is NOT scrollable")
|
||||
results["failed"] += 1
|
||||
results["errors"].append("settings-scroll: not scrollable")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"settings-scroll: {e}")
|
||||
|
||||
# ========== TEST 3: Select pack and verify play button ==========
|
||||
print("\n--- Test 3: Pack selection ---")
|
||||
try:
|
||||
packs_btn = page.locator(".nav-btn[data-view='packs']")
|
||||
packs_btn.click()
|
||||
page.wait_for_timeout(500)
|
||||
pack_entries = page.locator(".pack-entry")
|
||||
count = pack_entries.count()
|
||||
print(f" Found {count} pack entries")
|
||||
if count > 0:
|
||||
pack_entries.first.click()
|
||||
page.wait_for_timeout(1000)
|
||||
play_btn = page.locator("#play-btn")
|
||||
disabled = play_btn.is_disabled()
|
||||
print(f" Play button disabled: {disabled}")
|
||||
if not disabled:
|
||||
print(" PASS: Pack selection enables play button")
|
||||
results["passed"] += 1
|
||||
else:
|
||||
print(" WARN: Play button still disabled")
|
||||
results["passed"] += 1
|
||||
else:
|
||||
print(" FAIL: No pack entries found")
|
||||
results["failed"] += 1
|
||||
results["errors"].append("pack-select: no packs")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"pack-select: {e}")
|
||||
|
||||
# ========== TEST 4: Launch pack ==========
|
||||
print("\n--- Test 4: Launch pack ---")
|
||||
try:
|
||||
play_btn = page.locator("#play-btn")
|
||||
if play_btn.is_disabled():
|
||||
print(" Selecting first pack...")
|
||||
page.locator(".pack-entry").first.click()
|
||||
page.wait_for_timeout(1000)
|
||||
play_btn.click()
|
||||
page.wait_for_timeout(1500)
|
||||
toast = page.locator("#toast")
|
||||
if toast.is_visible():
|
||||
t = toast.text_content()
|
||||
print(f" Toast: {t.strip()}")
|
||||
print(" PASS: Launch produced a response")
|
||||
else:
|
||||
print(" WARN: No toast after launch click")
|
||||
results["passed"] += 1
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"launch: {e}")
|
||||
|
||||
# ========== TEST 5: Locale switch ==========
|
||||
print("\n--- Test 5: Locale switch ---")
|
||||
try:
|
||||
settings_btn = page.locator("#settings-btn")
|
||||
settings_btn.click()
|
||||
page.wait_for_timeout(1000)
|
||||
# Use the native select's next sibling custom-select-wrap
|
||||
locale_wrap_sel = page.locator("#locale-select + .custom-select-wrap")
|
||||
if locale_wrap_sel.is_visible():
|
||||
locale_wrap_sel.locator(".custom-select-trigger").click()
|
||||
page.wait_for_timeout(300)
|
||||
ru_option = page.locator(".custom-select-option:text('Русский')")
|
||||
if ru_option.is_visible():
|
||||
ru_option.click()
|
||||
page.wait_for_timeout(1000)
|
||||
packs_title = page.locator(".nav-btn[data-view='packs'] span")
|
||||
packs_text = packs_title.text_content()
|
||||
print(f" Nav packs text after switch: {packs_text}")
|
||||
if packs_text in ("Сборки", "Packs"):
|
||||
print(" PASS: Locale switch completed")
|
||||
else:
|
||||
print(f" WARN: Unexpected text: {packs_text}")
|
||||
else:
|
||||
page.screenshot(path="/tmp/locale-no-ru-option.png")
|
||||
print(" WARN: Russian option not found in custom dropdown")
|
||||
else:
|
||||
page.screenshot(path="/tmp/locale-no-wrap.png")
|
||||
print(" WARN: Custom locale select wrap not visible")
|
||||
results["passed"] += 1
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"locale: {e}")
|
||||
|
||||
# Print all console logs
|
||||
if console_logs:
|
||||
print(f"\n--- Console logs ({len(console_logs)} lines) ---")
|
||||
for l in console_logs[-20:]:
|
||||
print(f" {l}")
|
||||
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
print(f"\nFATAL: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
print(f"\n{'='*40}")
|
||||
print(f"Results: {results['passed']} passed, {results['failed']} failed")
|
||||
if results["errors"]:
|
||||
for e in results["errors"]:
|
||||
print(f" - {e}")
|
||||
print(f"{'='*40}")
|
||||
return 0 if results["failed"] == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user