10 Commits

34 changed files with 3681 additions and 1724 deletions
@@ -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;
@@ -21,10 +23,15 @@ 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 Path getLauncherJar() {
return binDir.resolve(JAR_NAME);
@@ -36,34 +43,134 @@ 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"));
// Проверка и обновление лаунчера
String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion();
log("Локальная версия: " + currentVersion);
log("Версия на сервере: " + serverVersion);
log("Local version: " + currentVersion);
log("Server version: " + 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...");
}));
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) {
@@ -85,7 +192,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 +225,7 @@ public class Bootstrap {
}
}
} catch (Exception e) {
log("Ошибка получения версии: " + e.getMessage());
log("Error fetching version: " + e.getMessage());
}
return "unknown";
}
@@ -139,21 +246,18 @@ 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;
@@ -170,17 +274,17 @@ public class Bootstrap {
}
if (localHash != null) {
log("Обновление: " + filePath);
log("Updating: " + filePath);
} else {
log("Скачивание: " + filePath);
log("Downloading: " + filePath);
}
downloadFile(newVersion, filePath, serverMeta.size);
downloaded++;
}
log("Обновлено файлов: " + downloaded + ", пропущено: " + skipped);
log("Обновлено до v" + newVersion);
log("Updated files: " + downloaded + ", skipped: " + skipped);
log("Updated to v" + newVersion);
}
private static Map<String, FileMeta> fetchServerMeta(String version) {
@@ -210,7 +314,7 @@ public class Bootstrap {
}
}
} catch (Exception e) {
log("Ошибка получения мета: " + e.getMessage());
log("Error fetching meta: " + e.getMessage());
}
return files;
}
@@ -248,38 +352,90 @@ 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();
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;
System.out.print(String.format("\r[%s] %s - %.1f/%.1f MB (%.1f MB/s",
getProgressBar(downloaded, expectedSize),
fileName,
downloadedMB,
totalMB,
speed
));
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);
System.out.println(String.format("\r[%s] %s - %.1f MB (%.1f MB/s) - Done!",
getProgressBar(downloaded, expectedSize),
fileName,
downloaded / 1024.0 / 1024.0,
speed
));
}
// Выводим прогресс
System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB");
private static String getProgressBar(long current, long total) {
if (total <= 0) return "====";
int filled = (int) ((current * 20) / total);
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < 20; i++) {
sb.append(i < filled ? "=" : " ");
}
sb.append("]");
return sb.toString();
}
private static class FileMeta {
@@ -291,120 +447,45 @@ 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)) {
private static void loadMirrors() {
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());
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;
}
}
}
@@ -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 {
@@ -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,7 +247,7 @@ 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,23 @@ 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");
System.setProperty("java.stdout.encoding", "UTF-8");
System.setProperty("java.stderr.encoding", "UTF-8");
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
// Для Windows CMD - пытаемся переключить в UTF-8 режим
try {
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
try {
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
}
} catch (Exception ignored) {}
}
ZAnsi.install();
System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
// Определяем режим запуска
List<String> argList = List.of(args);
boolean jfxMode = argList.contains("--jfx");
boolean cliMode = argList.contains("--cli");
@@ -42,23 +41,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 +64,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 +102,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,38 +125,36 @@ 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,12 @@ 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.ZHttpClient;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Центральный фасад для внутреннего API лаунчера.
* Используется как единая точка входа для UI и других компонентов.
*/
public class LauncherAPI {
private final AuthService authService;
@@ -34,8 +33,6 @@ public class LauncherAPI {
return launchService;
}
// ====================== Удобные методы ======================
public boolean isLoggedIn() {
return authService.isLoggedIn();
}
@@ -56,6 +53,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 +76,104 @@ 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((a, b) -> b.compareTo(a));
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((a, b) -> b.compareTo(a));
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 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) {
return ApiResponse.error("Not logged in");
}
String response = ZHttpClient.get("/packs");
org.json.JSONArray arr = new org.json.JSONArray(response);
List<Map<String, String>> packs = new ArrayList<>();
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("mcVersion", ""));
packInfo.put("loader", pack.optString("loader", "vanilla"));
packInfo.put("description", pack.optString("description", ""));
packs.add(packInfo);
}
return ApiResponse.success(packs);
} catch (Exception e) {
System.out.println("[API] Packs fetch failed: " + e.getMessage());
return ApiResponse.error("Failed to load packs: " + e.getMessage());
}
}
}
@@ -8,6 +8,27 @@ import java.io.IOException;
public class AuthService {
public ApiResponse<LoginResult> register(String username, String password) {
try {
String response = post("/auth/register",
"{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}");
// 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 +36,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 +47,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,9 +64,9 @@ 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());
}
}
@@ -55,7 +76,7 @@ public class AuthService {
"{\"code\":\"" + passCode + "\"}");
return ApiResponse.success(true);
} catch (Exception e) {
return ApiResponse.error("Ошибка активации проходки: " + e.getMessage());
return ApiResponse.error("Pass activation error: " + e.getMessage());
}
}
@@ -105,6 +126,10 @@ public class AuthService {
return AuthManager.getUsername();
}
public String getCurrentToken() {
return AuthManager.getAccessToken();
}
public static class LoginResult {
private String username;
private String token;
@@ -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; }
}
}
@@ -7,24 +7,40 @@ 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 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);
}
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions();
LaunchOptions options = createOptions();
List<String> command = builder.build(options);
@@ -35,7 +51,7 @@ public class LaunchService {
);
return ApiResponse.success(info);
} catch (Exception e) {
return ApiResponse.error("Ошибка подготовки запуска: " + e.getMessage());
return ApiResponse.error("Error preparing launch: " + e.getMessage());
}
}
@@ -43,15 +59,11 @@ 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());
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());
@@ -64,57 +76,64 @@ public class LaunchService {
processBuilder.directory(instance.getPath().toFile());
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
System.out.println("[LAUNCH] Process started, pid=" + process.pid());
Path logsDir = instance.getPath().resolve("logs");
java.nio.file.Files.createDirectories(logsDir);
Path gameLog = logsDir.resolve("game.log");
// Capture output (stdout)
Thread outThread = new Thread(() -> {
Process process = processBuilder.start();
long pid = process.pid();
runningProcesses.put(pid, process);
System.out.println("[LAUNCH] Process started, pid=" + pid);
java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true);
Thread logReader = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
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());
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 +141,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 +149,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 +161,28 @@ 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());
String args = Config.getExtraJvmArgs();
if (args != null && !args.isEmpty()) {
List<String> extraArgs = new ArrayList<>();
for (String arg : args.split("\\s+")) {
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;
@@ -26,14 +26,12 @@ 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";
@@ -56,7 +54,6 @@ public class AuthManager {
}
}
// ====================== АВТОРИЗАЦИЯ ======================
public static AuthResult login(String username, String password) {
return authRequest("/auth/login", username, password);
}
@@ -77,13 +74,13 @@ public class AuthManager {
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());
}
}
@@ -149,16 +146,14 @@ public class AuthManager {
Files.createDirectories(AUTH_FILE.getParent());
Files.writeString(AUTH_FILE, GSON.toJson(session));
} catch (IOException e) {
System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage()));
System.err.println(ZAnsi.yellow("Failed to save session: " + e.getMessage()));
}
}
// ==================== ПОЛУЧЕНИЕ ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ====================
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,12 +180,11 @@ public class AuthManager {
if (conn != null) conn.disconnect();
}
} catch (Exception e) {
System.err.println("Не удалось получить UserInfo: " + e.getMessage());
System.err.println("Failed to get UserInfo: " + e.getMessage());
return null;
}
}
// ==================== ПРОВЕРКИ ПРАВ ====================
public static boolean hasPass() {
if (userInfo != null) return userInfo.has_pass;
return getRole() >= ROLE_PASS_HOLDER;
@@ -200,14 +194,14 @@ public class AuthManager {
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 +215,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;
@@ -291,24 +284,23 @@ public class AuthManager {
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());
System.err.println(ZAnsi.red("Failed to check pass: ") + e.getMessage());
return false;
}
}
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;
@@ -351,7 +343,6 @@ public class AuthManager {
}
}
// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ======================
class SimpleHttpResponse {
final int statusCode;
final String 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,7 +758,6 @@ public class LaunchMenu {
index = end;
}
} catch (Exception e) {
// Skip if one maven doesn't have the artifact
}
}
@@ -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();
@@ -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();
@@ -56,7 +56,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 +65,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 +132,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 +146,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,7 +156,7 @@ 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) {
@@ -202,9 +202,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)) {
@@ -36,18 +36,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 +61,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 +118,7 @@ public class PackDownloader {
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
loaderVersion, updatedAt, filesCount));
} catch (Exception e) {
System.err.println("Ошибка парсинга пака: " + e.getMessage());
System.err.println("Error parsing pack: " + e.getMessage());
}
}
@@ -126,7 +126,7 @@ public class PackDownloader {
}
/**
* Получить манифест пака
* Get pack manifest
*/
public PackManifest getPackManifest(String packName) throws Exception {
String response = ZHttpClient.get("/pack/" + packName);
@@ -134,18 +134,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 + " с сервера..."));
System.out.println(ZAnsi.cyan("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 +154,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 +196,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 +217,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 +237,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 +312,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 +342,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 +351,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 +364,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 +391,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 +427,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;
@@ -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);
}
}
}
@@ -26,59 +26,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 +94,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 +132,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 +144,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 +158,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 +166,7 @@ public class ForgeInstaller {
Process process = pb.start();
// Читаем вывод в реальном времени
// Read output in real time
StringBuilder output = new StringBuilder();
boolean hasErrors = false;
@@ -175,7 +175,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 +195,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 +218,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,9 +237,9 @@ public class ForgeInstaller {
}
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек..."));
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
// Список проблемных библиотек и их альтернативные URL
// List of problematic libraries and their alternate URLs
Map<String, String> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
@@ -252,7 +252,7 @@ public class ForgeInstaller {
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 {
@@ -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);
}
}
@@ -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")
@@ -162,7 +162,7 @@ public class VersionInstaller {
downloadFile(url, target, "");
synchronized (this) {
success[0]++;
ProgressBar.show("Ассеты", success[0], total, "файлов");
ProgressBar.show("Assets", success[0], total, "files");
}
downloaded = true;
break;
@@ -171,7 +171,7 @@ public class VersionInstaller {
synchronized (this) {
failed[0]++;
}
System.err.println("Не удалось скачать " + hash);
System.err.println("Failed to download " + hash);
} else {
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
}
@@ -184,17 +184,17 @@ public class VersionInstaller {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
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 +202,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 +222,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 +233,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()) {
@@ -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));
}
}
}
@@ -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
// ESC sequences: arrows + cyrillic start byte
else if (b == 0x1B) {
int next = nonBlockingRead();
if (next == -1) {
return -1;
}
if (next == 0x5B) { // '['
int arrow = nonBlockingRead();
if (arrow == 0x41) { // Up
selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 0x42) { // Down
selected = (selected + 1) % options.size();
}
}
}
else if (b == 0xD1) {
int second = nonBlockingRead();
if (second == 0x86) { // ц
selected = (selected - 1 + options.size()) % options.size();
} else if (second == 0x8B) { // ы
selected = (selected + 1) % options.size();
}
}
else if (b == 13 || b == 10) {
return selected;
}
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
selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 66) { // 'B' — Down arrow
selected = (selected + 1) % options.size();
}
// else — unknown escape seq, ignore
} else {
return -1; // genuine Esc
}
}
}
} finally {
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();
}
}
@@ -1,6 +1,7 @@
package me.sashegdev.zernmc.launcher.ui.jfx;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.web.WebView;
import javafx.scene.web.WebEngine;
@@ -8,9 +9,13 @@ import javafx.stage.Stage;
import javafx.concurrent.Worker;
import com.google.gson.Gson;
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.utils.Config;
import java.io.BufferedReader;
import java.io.InputStream;
@@ -42,12 +47,65 @@ public class JFXLauncher extends Application {
private final LauncherAPI api = new LauncherAPI();
private final Gson gson = new Gson();
private HttpServer server;
private StringBuilder logBuffer = new StringBuilder();
private static StringBuilder launcherLogBuffer = new StringBuilder();
private static StringBuilder gameLogBuffer = new StringBuilder();
private static Path gameLogFile;
private static Path launcherLogFile;
private Stage mainStage;
private static volatile String installProgressLabel = "";
private static volatile int installProgressCurrent = 0;
private static volatile int installProgressTotal = 100;
private static volatile boolean installInProgress = false;
private static final java.util.concurrent.CopyOnWriteArrayList<LogConsumer> logConsumers = new java.util.concurrent.CopyOnWriteArrayList<>();
private static final java.util.concurrent.CopyOnWriteArrayList<LogConsumer> gameLogConsumers = new java.util.concurrent.CopyOnWriteArrayList<>();
public interface LogConsumer {
void onLog(String line);
}
public static void addLogConsumer(LogConsumer consumer) {
logConsumers.add(consumer);
}
public static void removeLogConsumer(LogConsumer consumer) {
logConsumers.remove(consumer);
}
public static void addGameLogConsumer(LogConsumer consumer) {
gameLogConsumers.add(consumer);
}
public static void removeGameLogConsumer(LogConsumer consumer) {
gameLogConsumers.remove(consumer);
}
public static void setInstallProgress(String label, int current, int total) {
installProgressLabel = label;
installProgressCurrent = current;
installProgressTotal = total;
}
public static void setInstallInProgress(boolean inProgress) {
installInProgress = inProgress;
}
public static boolean isInstallInProgress() {
return installInProgress;
}
public static void appendLauncherLog(String log) {
synchronized (launcherLogBuffer) {
launcherLogBuffer.append(log).append("\n");
}
for (LogConsumer consumer : logConsumers) {
try { consumer.onLog(log); } catch (Exception ignored) {}
}
}
public static void appendGameLog(String log) {
System.out.println("[GAME] " + log);
synchronized (gameLogBuffer) {
gameLogBuffer.append(log).append("\n");
@@ -59,6 +117,9 @@ public class JFXLauncher extends Application {
} catch (Exception ignored) {}
}
}
for (LogConsumer consumer : gameLogConsumers) {
try { consumer.onLog(log); } catch (Exception ignored) {}
}
}
public static void initGameLog(Path instanceDir) {
@@ -74,13 +135,29 @@ public class JFXLauncher extends Application {
} catch (Exception ignored) {}
}
public static void clearGameLog() {
synchronized (gameLogBuffer) {
gameLogBuffer.setLength(0);
}
}
public static String getGameLogs() {
synchronized (gameLogBuffer) {
return gameLogBuffer.toString();
}
}
public static String getLauncherLogs() {
synchronized (launcherLogBuffer) {
return launcherLogBuffer.toString();
}
}
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[JFX] Shutdown hook triggered");
LaunchService.killAllProcesses();
}));
launch(args);
}
@@ -93,15 +170,15 @@ public class JFXLauncher extends Application {
String serverVersion = getServerVersion();
if (serverVersion != null && !serverVersion.isEmpty()) {
System.out.println("[JFX] Загрузка assets через мета для версии " + serverVersion);
System.out.println("[JFX] Loading assets via meta for version " + serverVersion);
if (downloadAssetsFromMeta(serverVersion)) {
System.out.println("[JFX] Assets загружены через мета");
System.out.println("[JFX] Assets loaded via meta");
return;
}
System.out.println("[JFX] Мета недоступна, использую fallback");
System.out.println("[JFX] Meta unavailable, using fallback");
}
System.out.println("[JFX] Извлечение assets из JAR...");
System.out.println("[JFX] Extracting assets from JAR...");
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
try (JarFile jar = new JarFile(jarPath.toFile())) {
@@ -121,10 +198,10 @@ public class JFXLauncher extends Application {
}
}
}
System.out.println("[JFX] Assets извлечены из JAR");
System.out.println("[JFX] Assets extracted from JAR");
}
} catch (Exception e) {
System.out.println("[JFX] Ошибка извлечения assets: " + e.getMessage());
System.out.println("[JFX] Error extracting assets: " + e.getMessage());
}
}
@@ -194,7 +271,7 @@ public class JFXLauncher extends Application {
return true;
}
} catch (Exception e) {
System.out.println("[JFX] Ошибка загрузки через мета: " + e.getMessage());
System.out.println("[JFX] Error loading via meta: " + e.getMessage());
return false;
}
}
@@ -204,8 +281,15 @@ public class JFXLauncher extends Application {
this.mainStage = stage;
try {
// Initialize launcher log file
Path logsDir = Paths.get("logs");
Files.createDirectories(logsDir);
launcherLogFile = logsDir.resolve("launcher.log");
Files.writeString(launcherLogFile, "=== Launcher Log " + LocalDateTime.now() + " ===\n",
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
extractAssets();
log("Запуск JFX UI...");
log("Starting JFX UI...");
startServer();
WebView webView = new WebView();
@@ -215,7 +299,7 @@ public class JFXLauncher extends Application {
engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
log("[UI] Load state: " + oldState + " -> " + newState);
if (newState == Worker.State.SUCCEEDED) {
log("Страница загружена");
log("Page loaded");
} else if (newState == Worker.State.FAILED) {
log("[UI] Load FAILED: " + engine.getLoadWorker().getException());
}
@@ -227,20 +311,24 @@ public class JFXLauncher extends Application {
engine.load(url);
stage.setTitle(APP_TITLE);
stage.setWidth(1200);
stage.setWidth(1280);
stage.setHeight(800);
stage.setMinWidth(800);
stage.setMinHeight(600);
stage.setScene(new Scene(webView));
stage.show();
log("Окно отображено");
log("Window displayed");
stage.setOnCloseRequest(e -> {
log("Закрытие...");
stopServer();
log("Closing...");
LaunchService.killAllProcesses();
if (server != null) server.stop(0);
Platform.exit();
});
} catch (Exception e) {
log("Ошибка: " + e.getMessage());
log("Error: " + e.getMessage());
e.printStackTrace();
throw new RuntimeException(e);
}
@@ -250,19 +338,31 @@ public class JFXLauncher extends Application {
server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0);
server.createContext("/api/login", this::handleLogin);
server.createContext("/api/auto-login", this::handleAutoLogin);
server.createContext("/api/account", this::handleAccount);
server.createContext("/api/instances", this::handleInstances);
server.createContext("/api/launch", this::handleLaunch);
server.createContext("/api/install", this::handleInstall);
server.createContext("/api/install/progress", this::handleInstallProgress);
server.createContext("/api/logs", this::handleLogs);
server.createContext("/api/logs/stream", this::handleLogsStream);
server.createContext("/api/game-logs", this::handleGameLogs);
server.createContext("/api/game-logs/stream", this::handleGameLogsStream);
server.createContext("/api/mc-versions", this::handleMCVersions);
server.createContext("/api/loader-versions", this::handleLoaderVersions);
server.createContext("/api/packs", this::handlePacks);
server.createContext("/api/settings", this::handleSettings);
server.createContext("/api/activate-pass", this::handleActivatePass);
server.createContext("/api/register", this::handleRegister);
server.createContext("/api/shutdown", this::handleShutdown);
server.createContext("/api/exit", this::handleExit);
server.createContext("/api/exit-parent", this::handleExitParent);
server.createContext("/assets/", this::handleStatic);
server.setExecutor(Executors.newCachedThreadPool());
server.start();
log("HTTP сервер на порту " + PORT);
log("HTTP server on port " + PORT);
}
private void stopServer() {
@@ -272,7 +372,7 @@ public class JFXLauncher extends Application {
private void handleLogin(HttpExchange exchange) {
try {
if (!"POST".equals(exchange.getRequestMethod())) {
sendJson(exchange, Map.of("success", false, "error", "Метод не поддерживается"));
sendJson(exchange, Map.of("success", false, "error", "Method not supported"));
return;
}
@@ -286,7 +386,7 @@ public class JFXLauncher extends Application {
data.put("username", result.getData().getUsername());
data.put("token", result.getData().getToken());
sendJson(exchange, Map.of("success", true, "data", data));
log("Вход: " + username);
log("Login: " + username);
} else {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
@@ -298,7 +398,7 @@ public class JFXLauncher extends Application {
private void handleAccount(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
Map<String, Object> data = new HashMap<>();
@@ -312,6 +412,24 @@ public class JFXLauncher extends Application {
}
}
private void handleAutoLogin(HttpExchange exchange) {
try {
if (AuthManager.loadSavedSession()) {
Map<String, Object> data = new HashMap<>();
data.put("username", AuthManager.getUsername());
data.put("passActive", AuthManager.hasActivePass());
data.put("role", AuthManager.getRole());
data.put("roleName", AuthManager.getRoleName());
sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true));
log("Auto-login performed: " + AuthManager.getUsername());
} else {
sendJson(exchange, Map.of("success", false, "autoLogin", false));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleInstances(HttpExchange exchange) {
try {
var result = api.getAllInstances();
@@ -331,20 +449,30 @@ public class JFXLauncher extends Application {
private void handleLaunch(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Pack not found"));
return;
}
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
sendJson(exchange, Map.of("success", false, "error", "Server pack requires an active pass"));
return;
}
var result = api.launch(name);
if (result.isSuccess()) {
Map<String, Object> data = new HashMap<>();
data.put("pid", result.getData().getPid());
data.put("status", result.getData().getStatus());
sendJson(exchange, Map.of("success", true, "data", data));
log("Запущено: " + name);
log("Launched: " + name);
} else {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
@@ -354,9 +482,14 @@ public class JFXLauncher extends Application {
}
private void handleInstall(HttpExchange exchange) {
if (installInProgress) {
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
return;
}
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
@@ -364,8 +497,9 @@ public class JFXLauncher extends Application {
String name = body.get("name");
String version = body.get("version");
String loader = body.get("loader");
String loaderVersion = body.get("loaderVersion");
log("Установка: " + name + " " + version + " " + loader);
log("Install: " + name + " " + version + " " + loader + (loaderVersion != null ? " " + loaderVersion : ""));
var createResult = api.instances().createInstance(name);
if (!createResult.isSuccess()) {
@@ -377,29 +511,313 @@ public class JFXLauncher extends Application {
if (instance != null) {
instance.setMinecraftVersion(version);
instance.setLoaderType(loader);
if (loaderVersion != null) {
instance.setLoaderVersion(loaderVersion);
}
sendJson(exchange, Map.of("success", true, "data", true));
log("Установлено: " + name);
sendJson(exchange, Map.of("success", true, "message", "Installation started"));
setInstallInProgress(true);
setInstallProgress("Preparing...", 0, 100);
Thread installThread = new Thread(() -> {
try {
MinecraftLib lib = new MinecraftLib(instance);
boolean success = false;
if ("vanilla".equalsIgnoreCase(loader)) {
success = lib.installMinecraft(version);
} else {
success = lib.installPack(name, version, loader, loaderVersion != null ? loaderVersion : "");
}
setInstallInProgress(false);
if (success) {
log("Installed: " + name);
} else {
log("Install error: " + name);
}
} catch (Exception e) {
log("Install error: " + e.getMessage());
setInstallInProgress(false);
}
});
installThread.setDaemon(true);
installThread.start();
} else {
sendJson(exchange, Map.of("success", false, "error", "Instance not found"));
}
} catch (Exception e) {
log("Install error: " + e.getMessage());
setInstallInProgress(false);
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleInstallProgress(HttpExchange exchange) {
try {
Map<String, Object> progress = new java.util.HashMap<>();
progress.put("label", installProgressLabel);
progress.put("current", installProgressCurrent);
progress.put("total", installProgressTotal);
progress.put("inProgress", installInProgress);
if (installInProgress && installProgressTotal > 0) {
progress.put("percent", (int) ((installProgressCurrent * 100.0) / installProgressTotal));
} else {
progress.put("percent", installInProgress ? 0 : 100);
}
sendJson(exchange, Map.of("success", true, "data", progress));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", logBuffer.toString()));
sendJson(exchange, Map.of("success", true, "data", getLauncherLogs()));
}
private void handleLogsStream(HttpExchange exchange) {
try {
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
exchange.getResponseHeaders().set("Connection", "keep-alive");
exchange.sendResponseHeaders(200, 0);
final OutputStream os = exchange.getResponseBody();
int[] lastLength = {getLauncherLogs().length()};
LogConsumer consumer = new LogConsumer() {
@Override
public synchronized void onLog(String line) {
try {
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
os.write(data.getBytes(StandardCharsets.UTF_8));
os.flush();
} catch (Exception e) {
removeLogConsumer(this);
}
}
};
addLogConsumer(consumer);
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(10000);
}
} catch (Exception ignored) {} finally {
removeLogConsumer(consumer);
try { exchange.getResponseBody().close(); } catch (Exception ignored) {}
}
}
private LogConsumer consumer = null;
private void handleGameLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
}
private void handleExit(HttpExchange exchange) {
log("Выход...");
if (mainStage != null) mainStage.close();
private void handleGameLogsStream(HttpExchange exchange) {
try {
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
exchange.getResponseHeaders().set("Connection", "keep-alive");
exchange.sendResponseHeaders(200, 0);
final OutputStream os = exchange.getResponseBody();
consumer = new LogConsumer() {
@Override
public synchronized void onLog(String line) {
try {
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
os.write(data.getBytes(StandardCharsets.UTF_8));
os.flush();
} catch (Exception e) {
removeGameLogConsumer(this);
}
}
};
addGameLogConsumer(consumer);
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(10000);
}
} catch (Exception ignored) {} finally {
if (consumer != null) {
removeGameLogConsumer(consumer);
}
consumer = null;
try { exchange.getResponseBody().close(); } catch (Exception ignored) {}
}
}
private void handleMCVersions(HttpExchange exchange) {
try {
var versions = api.getMCVersions();
if (versions.isSuccess()) {
sendJson(exchange, Map.of("success", true, "data", versions.getData()));
} else {
sendJson(exchange, Map.of("success", false, "error", versions.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleLoaderVersions(HttpExchange exchange) {
try {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
String mcVersion = params.get("mc");
String loader = params.get("loader");
var versions = api.getLoaderVersions(mcVersion, loader);
if (versions.isSuccess()) {
sendJson(exchange, Map.of("success", true, "data", versions.getData()));
} else {
sendJson(exchange, Map.of("success", false, "error", versions.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handlePacks(HttpExchange exchange) {
try {
var result = api.getZernMCPacks();
if (result.isSuccess()) {
sendJson(exchange, Map.of("success", true, "data", result.getData()));
} else {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleActivatePass(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String code = body.get("code");
if (code == null || code.isEmpty()) {
sendJson(exchange, Map.of("success", false, "error", "Enter pass code"));
return;
}
var result = api.activatePass(code);
if (result.isSuccess()) {
sendJson(exchange, Map.of("success", true, "message", "Pass activated!"));
log("Pass activated");
} else {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleRegister(HttpExchange exchange) {
try {
Map<String, String> body = parseJson(exchange.getRequestBody());
String username = body.get("username");
String password = body.get("password");
if (username == null || password == null || username.isEmpty() || password.isEmpty()) {
sendJson(exchange, Map.of("success", false, "error", "Fill all fields"));
return;
}
var result = api.register(username, password);
if (result.isSuccess()) {
Map<String, Object> data = new HashMap<>();
data.put("username", result.getData().getUsername());
data.put("token", result.getData().getToken());
sendJson(exchange, Map.of("success", true, "data", data));
log("Registered: " + username);
} else {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleSettings(HttpExchange exchange) {
try {
if ("POST".equals(exchange.getRequestMethod())) {
Map<String, String> body = parseJson(exchange.getRequestBody());
if (body.containsKey("maxMemory")) {
Config.setMaxMemory(Integer.parseInt(body.get("maxMemory")));
}
if (body.containsKey("windowWidth")) {
Config.setWindowWidth(Integer.parseInt(body.get("windowWidth")));
}
if (body.containsKey("windowHeight")) {
Config.setWindowHeight(Integer.parseInt(body.get("windowHeight")));
}
if (body.containsKey("extraJvmArgs")) {
Config.setExtraJvmArgs(body.get("extraJvmArgs"));
}
if (body.containsKey("javaPath")) {
Config.setJavaPath(body.get("javaPath"));
}
sendJson(exchange, Map.of("success", true));
return;
}
Map<String, Object> data = new HashMap<>();
data.put("maxMemory", Config.getMaxMemory());
data.put("serverUrl", Config.getServerUrl());
data.put("instancesDir", Config.getInstancesDir().toString());
data.put("windowWidth", Config.getWindowWidth());
data.put("windowHeight", Config.getWindowHeight());
data.put("extraJvmArgs", Config.getExtraJvmArgs());
data.put("javaPath", Config.getJavaPath());
sendJson(exchange, Map.of("success", true, "data", data));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private Map<String, String> parseQuery(String query) {
Map<String, String> params = new HashMap<>();
if (query != null && !query.isEmpty()) {
for (String pair : query.split("&")) {
String[] kv = pair.split("=");
if (kv.length == 2) {
params.put(kv[0], kv[1]);
}
}
}
return params;
}
private void handleShutdown(HttpExchange exchange) {
log("Shutdown request received...");
LaunchService.killAllProcesses();
if (server != null) server.stop(0);
Platform.exit();
System.exit(0);
}
private void handleExit(HttpExchange exchange) {
log("Exiting...");
LaunchService.killAllProcesses();
if (mainStage != null) mainStage.close();
Platform.exit();
System.exit(0);
}
private void handleExitParent(HttpExchange exchange) {
log("Terminating parent process...");
LaunchService.killAllProcesses();
if (mainStage != null) mainStage.close();
Platform.exit();
System.exit(240);
}
private void handleStatic(HttpExchange exchange) {
try {
String path = exchange.getRequestURI().getPath();
@@ -457,7 +875,18 @@ public class JFXLauncher extends Application {
private void log(String msg) {
String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n";
logBuffer.append(entry);
synchronized (launcherLogBuffer) {
launcherLogBuffer.append(entry);
}
System.out.println("[JFX] " + msg);
if (launcherLogFile != null) {
try {
Files.writeString(launcherLogFile, entry,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
}
for (LogConsumer consumer : logConsumers) {
try { consumer.onLog(entry); } catch (Exception ignored) {}
}
}
}
@@ -14,10 +14,13 @@ public class Config {
private static final Properties props = new Properties();
// Настройки
private static int maxMemory = 4096; // будет перезаписано умной логикой
private static int maxMemory = 4096;
private static String serverUrl = "http://87.120.187.36:1582";
private static String lastUsername = "Player";
private static int windowWidth = 1280;
private static int windowHeight = 720;
private static String extraJvmArgs = "";
private static String javaPath = "java";
static {
load();
@@ -36,9 +39,13 @@ public class Config {
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
serverUrl = props.getProperty("serverUrl", serverUrl);
lastUsername = props.getProperty("lastUsername", lastUsername);
windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280"));
windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720"));
extraJvmArgs = props.getProperty("extraJvmArgs", "");
javaPath = props.getProperty("javaPath", "java");
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Не удалось загрузить конфиг: ") + e.getMessage());
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage());
}
}
@@ -47,40 +54,34 @@ public class Config {
props.setProperty("maxMemory", String.valueOf(maxMemory));
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);
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 = Runtime.getRuntime().maxMemory() / (1024 * 1024);
// Рекомендуемое значение = total - 30%
long recommended = (long) (totalRamMB * 0.70); // 70% от доступной
long recommended = (long) (totalRamMB * 0.70);
// Ограничения
recommended = Math.max(1536, recommended); // минимум 1.5 GB
recommended = Math.min(recommended, totalRamMB - 1024); // оставляем минимум 1 GB системе
recommended = Math.max(1536, recommended);
recommended = Math.min(recommended, totalRamMB - 1024);
// Если текущее значение сильно отличается от рекомендуемого — корректируем
if (Math.abs(maxMemory - recommended) > 1024) { // разница больше 1 GB
if (Math.abs(maxMemory - recommended) > 1024) {
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 int getMaxMemory() {
return maxMemory;
}
@@ -94,7 +95,6 @@ public class Config {
}
public static void setMaxMemory(int memory) {
// Защита от слишком маленьких/больших значений
if (memory < 1024) memory = 1536;
if (memory > 32768) memory = 32768;
@@ -127,11 +127,44 @@ 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 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();
}
@@ -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) {
// Игнорируем
}
}
}
@@ -7,10 +7,19 @@ 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();
}
@@ -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,13 +84,12 @@ public class ZHttpClient {
}
}
/**
* Вызывать один раз при запуске лаунчера
*/
public static void checkAllServicesOnStartup() {
if (proxyTested.get()) return;
checkAllServicesOnStartup(false);
}
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
public static void checkAllServicesOnStartup(boolean verbose) {
if (proxyTested.get()) return;
List<ServiceType> servicesToCheck = List.of(
ServiceType.ZERN_SERVER,
@@ -116,14 +107,20 @@ public class ZHttpClient {
serviceHealthy.put(service, isHealthy);
if (service.isAlwaysDirect()) {
if (verbose) {
System.out.println(isHealthy ?
ZAnsi.green(" " + service.name() + " - OK") :
ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)"));
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);
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,8 +372,6 @@ public class ZHttpClient {
proxySuccessCount++;
}
// ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ======================
public static String get(String endpoint) throws IOException, InterruptedException {
checkAllServicesOnStartup();
@@ -403,7 +386,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 +412,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 +427,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 +485,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 +502,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,16 +510,16 @@ 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);
}
+332 -64
View File
@@ -1,98 +1,366 @@
<!DOCTYPE html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title>
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<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">1.0.9</span></p>
</div>
<form id="login-form" class="login-form">
<div class="field">
<input type="text" id="username" placeholder="Username" autocomplete="username" required>
<label for="username">Username</label>
</div>
<div class="field">
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
<label for="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">Sign In</span>
<div class="spinner hidden"></div>
</button>
<p class="login-hint">New account will be created automatically on first login</p>
</form>
<div id="login-error" class="error hidden"></div>
</div>
</div>
<!-- Главное меню -->
<!-- Loading Overlay -->
<div id="loading-overlay" class="overlay hidden">
<div class="loader-ring"></div>
<p class="loader-text">Loading...</p>
</div>
<!-- Main Screen -->
<div id="main-screen" class="screen hidden">
<!-- Хедер -->
<header class="header">
<h1 class="logo">ZernMC Launcher</h1>
<div class="account-info">
<span id="account-name">-</span>
<span id="account-status" class="badge">-</span>
<span id="account-role" class="badge role-badge">-</span>
</div>
</header>
<!-- Основной контент -->
<main class="main-content">
<!-- Слева: выбор сборки -->
<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>
Packs
</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>
News
</button>
<button class="nav-btn" data-view="settings" title="Settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
Settings
</button>
</nav>
<div class="sidebar-section">
<div class="section-header">
<span class="section-title">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">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="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 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>
Update
</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>
Delete
</button>
</div>
</div>
<div class="pack-detail" id="pack-detail">
<div class="pack-empty" id="pack-empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<h3>No pack selected</h3>
<p>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">Loader Ver</span></div>
<div class="stat"><span class="stat-value" id="detail-files">0</span><span class="stat-label">Files</span></div>
<div class="stat"><span class="stat-value" id="detail-size">-</span><span class="stat-label">Size</span></div>
</div>
<div id="pack-description" class="pack-description">
<p id="pack-description-text" class="pack-description-text">Loading description...</p>
<div id="pack-gallery" class="pack-gallery">
</div>
</div>
</div>
</div>
<div class="play-bar" id="play-bar">
<div class="play-bar-info">
<span id="play-bar-name">Select a pack</span>
</div>
<button id="play-btn" class="btn-play" disabled>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Play
</button>
</div>
</div>
<!-- News View -->
<div id="view-news" class="view">
<div class="view-header">
<h2 class="view-title">News</h2>
</div>
<div class="news-grid">
<article class="news-card news-placeholder">
<div class="news-card-badge">Coming Soon</div>
<h3>ZernMC Server Updates</h3>
<p>News and announcements will appear here. Stay tuned for the latest updates about the server and launcher.</p>
<time>Soon</time>
</article>
<article class="news-card news-placeholder">
<div class="news-card-badge">Info</div>
<h3>Launcher v1.0.9</h3>
<p>English UI, JavaFX redesign, improved pack management, and more. Check the GitHub for the full changelog.</p>
<time>v1.0.9</time>
</article>
<article class="news-card news-placeholder">
<div class="news-card-badge">Guide</div>
<h3>Getting Started</h3>
<p>Install a pack, activate your pass via the website, and start playing. Need help? Contact a moderator.</p>
<time>Guide</time>
</article>
</div>
</div>
<!-- Settings View -->
<div id="view-settings" class="view">
<div class="view-header">
<h2 class="view-title">Settings</h2>
</div>
<div class="settings-grid">
<div class="setting-card">
<div class="setting-info">
<h4>Activate Pass</h4>
<p>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" class="pass-input">
<button id="activate-pass-btn" class="btn-primary btn-sm">Activate</button>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4>Allocated RAM</h4>
<p id="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>Game Resolution</h4>
<p>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>Extra JVM Arguments</h4>
<p>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>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>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>
</div>
</main>
<!-- Низ: управление -->
<footer class="footer">
<div class="instance-info">
<span id="selected-name">-</span>
<span id="selected-version">-</span>
<span id="selected-loader">-</span>
</div>
<button id="play-btn" class="btn-play" disabled>Выберите сборку</button>
</footer>
</div>
<!-- Модальное окно установки -->
<div id="install-modal" class="modal hidden">
<div class="modal-content">
<h2>Установка сборки</h2>
<form id="install-form">
<label>Версия Minecraft
<select id="install-mc-version">
<option value="1.20.4">1.20.4</option>
<option value="1.20.2">1.20.2</option>
<option value="1.20.1">1.20.1</option>
<option value="1.19.2">1.19.2</option>
<!-- Install Modal -->
<div id="install-modal" class="modal-backdrop hidden">
<div class="modal">
<div class="modal-head">
<h3>Install Pack</h3>
<button class="modal-close" id="close-modal-btn">&times;</button>
</div>
<div class="modal-body">
<div class="modal-tabs">
<button class="modal-tab active" data-tab="zernmc">Server Pack</button>
<button class="modal-tab" data-tab="custom">Custom</button>
</div>
<div id="tab-zernmc" class="modal-tab-content active">
<div class="field">
<label>Server Pack</label>
<select id="zernmc-pack-select">
<option value="">Loading...</option>
</select>
</label>
<label>Загрузчик
<select id="install-loader">
<option value="vanilla">Vanilla</option>
</div>
<div class="field">
<label>Local Name</label>
<input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
</div>
<button id="install-zernmc-btn" class="btn-primary">Download & Install</button>
</div>
<div id="tab-custom" class="modal-tab-content">
<div class="field">
<label>Minecraft Version</label>
<div class="select-wrap">
<select id="mc-version-select"><option>Loading...</option></select>
</div>
</div>
<div class="field">
<label>Mod Loader</label>
<div class="select-wrap">
<select id="loader-select">
<option value="vanilla">Vanilla (no loader)</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</label>
<label>Имя сборки
<input type="text" id="install-name" placeholder="MyServer" required>
</label>
<div class="modal-buttons">
<button type="button" class="btn-secondary" onclick="closeInstallModal()">Отмена</button>
<button type="submit" class="btn-primary">Установить</button>
</div>
</form>
</div>
</div>
<div class="field hidden" id="loader-ver-field">
<label>Loader Version</label>
<div class="select-wrap">
<select id="loader-ver-select"><option>Select loader version</option></select>
</div>
</div>
<div class="field">
<label>Local Name</label>
<input type="text" id="custom-instance-name" placeholder="my-minecraft">
</div>
<button id="install-custom-btn" class="btn-primary">Download & Install</button>
</div>
<div 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">Installing...</p>
</div>
</div>
</div>
</div>
<!-- Notification Toast -->
<div id="toast" class="toast hidden"></div>
</div>
<script src="launcher.js"></script>
+525 -268
View File
@@ -1,311 +1,568 @@
const API_BASE = 'http://localhost:8080/api';
const API = '/api';
let state = {
loggedIn: false,
account: null,
instances: [],
selectedInstance: null
class ZernMCLauncher {
constructor() {
this.state = { account: null, instances: [], selectedPack: null, installing: false, serverPacks: [] };
this.toastTimer = null;
this.progressPoller = null;
this.init();
}
async init() {
this.bindEvents();
this.initBg();
await this.checkAuth();
}
// ==================== BACKGROUND ====================
initBg() {
const c = document.getElementById('bg-canvas');
const ctx = c.getContext('2d');
let mx = 0, my = 0, ox = 0, oy = 0;
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; draw(); };
const draw = () => {
ctx.clearRect(0, 0, c.width, c.height);
const gs = 48, r = 1.2;
ctx.fillStyle = '#e94560';
for (let x = 0; x <= c.width; x += gs)
for (let y = 0; y <= c.height; y += gs)
ctx.beginPath(), ctx.arc(x + ox * 8, y + oy * 8, r, 0, Math.PI * 2), ctx.fill();
};
// ============ API ============
async function apiCall(endpoint, options = {}) {
const url = `${API_BASE}${endpoint}`;
const config = {
headers: { 'Content-Type': 'application/json' },
...options
window.addEventListener('resize', resize);
window.addEventListener('mousemove', e => {
mx = (e.clientX / innerWidth - 0.5) * 2;
my = (e.clientY / innerHeight - 0.5) * 2;
});
const anim = () => {
ox += (mx * 0.3 - ox) * 0.04;
oy += (my * 0.3 - oy) * 0.04;
draw();
requestAnimationFrame(anim);
};
resize();
anim();
}
// ==================== API ====================
async req(endpoint, opts = {}) {
try {
const response = await fetch(url, config);
const data = await response.json();
return data;
const r = await fetch(`${API}${endpoint}`, {
...opts,
headers: { 'Content-Type': 'application/json', ...opts.headers }
});
return await r.json();
} catch (e) {
log('Ошибка соединения с сервером: ' + e.message, 'error');
return { success: false, error: e.message };
}
}
// ============ Login ============
async function login(username, password) {
log('Выполняется вход...', 'info');
const result = await apiCall('/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
if (result.success) {
state.loggedIn = true;
state.account = result.data;
log('Вход выполнен: ' + result.data.username, 'success');
showMainScreen();
await loadInstances();
// ==================== AUTH ====================
async checkAuth() {
this.showLoading(true);
const auto = await this.req('/auto-login');
if (auto.success && auto.autoLogin) {
this.state.account = auto.data;
this.enterMain();
this.toast(`Welcome back, ${auto.data.username}`, 'success');
} else {
log('Ошибка входа: ' + result.error, 'error');
showError(result.error);
const acct = await this.req('/account');
if (acct.success) {
this.state.account = acct.data;
this.enterMain();
} else {
this.showLogin();
}
return result;
}
this.showLoading(false);
}
function showError(message) {
async handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errEl = document.getElementById('login-error');
const btn = document.getElementById('login-btn');
const label = btn.querySelector('.btn-label');
const spinner = btn.querySelector('.spinner');
if (!username || !password) { this.showLoginError('Enter username and password'); return; }
btn.disabled = true;
label.textContent = 'Signing in...';
spinner.classList.remove('hidden');
const r = await this.req('/login', { method: 'POST', body: JSON.stringify({ username, password }) });
btn.disabled = false;
label.textContent = 'Sign In';
spinner.classList.add('hidden');
if (r.success) {
this.state.account = r.data;
this.enterMain();
this.toast(`Welcome, ${r.data.username}!`, 'success');
} else {
// If login fails, try register (auto-create account)
if (r.error && (r.error.includes('not found') || r.error.includes('Invalid'))) {
const reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
if (reg.success) {
this.state.account = reg.data;
this.enterMain();
this.toast(`Account created! Welcome, ${reg.data.username}!`, 'success');
return;
}
}
this.showLoginError(r.error || 'Login failed');
}
}
showLoginError(msg) {
const el = document.getElementById('login-error');
el.textContent = message;
el.textContent = msg;
el.classList.remove('hidden');
}
function hideError() {
document.getElementById('login-error').classList.add('hidden');
}
// ============ Account ============
async function loadAccountInfo() {
const result = await apiCall('/account');
if (result.success) {
state.account = result.data;
state.loggedIn = true;
document.getElementById('account-name').textContent = result.data.username;
const statusEl = document.getElementById('account-status');
statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE';
statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive');
const roleEl = document.getElementById('account-role');
if (roleEl && result.data.roleName) {
roleEl.textContent = result.data.roleName;
}
} else {
showLoginScreen();
}
}
// ============ Instances ============
async function loadInstances() {
log('Загрузка списка сборок...', 'info');
const result = await apiCall('/instances');
if (result.success) {
state.instances = result.data;
renderInstances();
log('Загружено ' + result.data.length + ' сборок', 'success');
} else {
log('Ошибка загрузки: ' + result.error, 'error');
}
}
function renderInstances() {
const container = document.getElementById('instances-list');
container.innerHTML = '';
state.instances.forEach(inst => {
const card = document.createElement('div');
card.className = 'instance-card';
card.dataset.name = inst.name;
card.onclick = () => selectInstance(inst.name);
let details = `
<span class="instance-version">${inst.version || '?'}</span>
<span class="instance-loader">${inst.loaderType || 'vanilla'}</span>
`;
if (inst.isServerPack) {
details += `<span class="instance-server-version">v${inst.serverVersion}</span>`;
}
card.innerHTML = `
<div class="instance-name">${inst.name}</div>
<div class="instance-details">${details}</div>
`;
container.appendChild(card);
});
}
function selectInstance(name) {
state.selectedInstance = state.instances.find(i => i.name === name);
document.querySelectorAll('.instance-card').forEach(c => {
c.classList.toggle('selected', c.dataset.name === name);
});
const btn = document.getElementById('play-btn');
const inst = state.selectedInstance;
if (inst) {
document.getElementById('selected-name').textContent = inst.name;
document.getElementById('selected-version').textContent = inst.version || '-';
document.getElementById('selected-loader').textContent = inst.loaderType || 'vanilla';
btn.disabled = false;
btn.textContent = 'Играть';
btn.classList.remove('update');
} else {
btn.disabled = true;
btn.textContent = 'Выберите сборку';
}
}
// ============ Launch ============
async function launchInstance() {
if (!state.selectedInstance) return;
const name = state.selectedInstance.name;
log('Запуск сборки: ' + name, 'info');
const result = await apiCall('/launch', {
method: 'POST',
body: JSON.stringify({ name })
});
if (result.success) {
log('Сборка запущена! PID: ' + result.data.pid, 'success');
} else {
log('Ошибка запуска: ' + result.error, 'error');
}
}
// ============ Install ============
function openInstallModal() {
document.getElementById('install-modal').classList.remove('hidden');
}
function closeInstallModal() {
document.getElementById('install-modal').classList.add('hidden');
}
async function installInstance(formData) {
log('Установка сборки...', 'info');
const result = await apiCall('/install', {
method: 'POST',
body: JSON.stringify(formData)
});
if (result.success) {
log('Сборка установлена!', 'success');
closeInstallModal();
await loadInstances();
} else {
log('Ошибка установки: ' + result.error, 'error');
}
return result;
}
// ============ Logs ============
function log(message, type = 'info') {
const container = document.getElementById('logs-container');
if (!container) return;
const line = document.createElement('div');
line.className = 'log-line ' + type;
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
container.appendChild(line);
container.scrollTop = container.scrollHeight;
}
function clearLogs() {
document.getElementById('logs-container').innerHTML = '';
}
// ============ Screens ============
function showLoginScreen() {
showLogin() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
clearError();
}
function showMainScreen() {
async logout() {
this.state.selectedPack = null;
this.state.instances = [];
this.state.account = null;
this.toast('Logged out');
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
enterMain() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('hidden');
if (state.account) {
document.getElementById('account-name').textContent = state.account.username;
const statusEl = document.getElementById('account-status');
statusEl.textContent = state.account.passActive ? 'PRO' : 'FREE';
statusEl.className = 'badge ' + (state.account.passActive ? 'active' : 'inactive');
}
}
// ============ Init ============
document.addEventListener('DOMContentLoaded', async () => {
log('Запуск лаунчера...', 'info');
await loadAccountInfo();
if (!state.loggedIn) {
showLoginScreen();
const a = this.state.account;
const avatar = document.getElementById('user-avatar');
avatar.textContent = (a.username || 'Z')[0].toUpperCase();
document.getElementById('username-display').textContent = a.username;
const status = document.getElementById('account-status');
if (a.passActive) {
status.textContent = 'PRO';
status.className = 'badge badge-pro';
} else {
showMainScreen();
await loadInstances();
status.textContent = 'FREE';
status.className = 'badge badge-free';
}
const role = document.getElementById('account-role');
if (a.roleName) {
role.textContent = a.roleName;
role.classList.remove('hidden');
} else {
role.classList.add('hidden');
}
document.getElementById('header-version').textContent = document.getElementById('version').textContent;
this.switchView('packs');
this.loadInstances();
this.loadSettings();
this.loadServerPacksList();
}
// Start polling for server logs
startLogPolling();
});
let lastLogLength = 0;
let lastGameLogLength = 0;
function startLogPolling() {
setInterval(async () => {
// Launcher logs
const result = await apiCall('/logs');
if (result.success && result.data && result.data.length > lastLogLength) {
const newLogs = result.data.substring(lastLogLength);
const lines = newLogs.split('\n').filter(l => l.trim());
lines.forEach(line => {
if (line.includes('[JFX]')) {
log(line.replace('[JFX] ', ''), 'info');
async loadServerPacksList() {
const r = await this.req('/packs');
if (r.success && r.data) {
this.state.serverPacks = r.data;
}
});
lastLogLength = result.data.length;
}
// Game logs
const gameResult = await apiCall('/game-logs');
if (gameResult.success && gameResult.data && gameResult.data.length > lastGameLogLength) {
const newLogs = gameResult.data.substring(lastGameLogLength);
const lines = newLogs.split('\n').filter(l => l.trim());
lines.forEach(line => {
log('[GAME] ' + line, 'info');
// ==================== NAV ====================
bindEvents() {
document.getElementById('login-form').addEventListener('submit', e => this.handleLogin(e));
document.getElementById('logout-btn').addEventListener('click', () => this.logout());
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => this.switchView(btn.dataset.view));
});
document.getElementById('add-pack-btn').addEventListener('click', () => this.showInstallModal());
document.getElementById('close-modal-btn').addEventListener('click', () => this.hideInstallModal());
document.querySelectorAll('.modal-tab').forEach(t => {
t.addEventListener('click', () => this.switchInstallTab(t.dataset.tab));
});
document.getElementById('loader-select').addEventListener('change', e => this.onLoaderChange(e.target.value));
document.getElementById('install-zernmc-btn').addEventListener('click', () => this.installZernMCPack());
document.getElementById('install-custom-btn').addEventListener('click', () => this.installCustom());
document.getElementById('play-btn').addEventListener('click', () => this.launchPack());
document.getElementById('ram-slider').addEventListener('input', e => {
document.getElementById('ram-value').textContent = (e.target.value / 1024).toFixed(1) + ' GB';
});
document.getElementById('ram-slider').addEventListener('change', e => this.saveSettings());
document.getElementById('activate-pass-btn').addEventListener('click', () => this.activatePass());
let saveTimer;
const debouncedSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(() => this.saveSettings(), 500); };
document.getElementById('win-width').addEventListener('change', debouncedSave);
document.getElementById('win-height').addEventListener('change', debouncedSave);
document.getElementById('jvm-args').addEventListener('change', debouncedSave);
document.getElementById('java-path-input').addEventListener('change', debouncedSave);
document.querySelectorAll('.modal-backdrop').forEach(m => {
m.addEventListener('click', e => { if (e.target === m) this.hideInstallModal(); });
});
lastGameLogLength = gameResult.data.length;
}
}, 2000);
}
// ============ Form Handlers ============
switchView(view) {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + view));
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
// ==================== INSTANCES ====================
async loadInstances() {
const r = await this.req('/instances');
if (r.success && r.data) {
this.state.instances = r.data;
this.renderSidebar();
}
}
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
renderSidebar() {
const serverList = document.getElementById('server-packs-list');
serverList.innerHTML = '';
await login(username, password);
if (!this.state.instances || this.state.instances.length === 0) return;
this.state.instances.forEach(inst => {
const isZern = inst.isServerPack || inst.category === 'zernmc';
if (!isZern) return;
const el = document.createElement('div');
el.className = 'pack-entry' + (this.state.selectedPack && this.state.selectedPack.name === inst.name ? ' selected' : '');
el.innerHTML = `
<div class="pack-entry-icon server">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
</div>
<div class="pack-entry-info">
<div class="pack-entry-name">${this.esc(inst.name)}</div>
<div class="pack-entry-meta">${inst.version || '?'}${inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : ''}</div>
</div>
`;
el.addEventListener('click', () => this.selectPack(inst));
serverList.appendChild(el);
});
}
document.getElementById('play-btn').addEventListener('click', async () => {
await launchInstance();
selectPack(inst) {
this.state.selectedPack = inst;
this.renderSidebar();
this.showPackDetail(inst);
}
showPackDetail(inst) {
document.getElementById('pack-empty-state').classList.add('hidden');
const detail = document.getElementById('pack-detail-content');
detail.classList.remove('hidden');
document.getElementById('detail-name').textContent = inst.name;
document.getElementById('detail-mc').textContent = inst.version || '?';
const loader = document.getElementById('detail-loader');
if (inst.loaderType && inst.loaderType !== 'vanilla') {
loader.textContent = inst.loaderType;
loader.classList.remove('hidden');
} else {
loader.classList.add('hidden');
}
const serverTag = document.getElementById('detail-server');
if (inst.isServerPack && inst.serverVersion) {
serverTag.textContent = 'v' + inst.serverVersion;
serverTag.classList.remove('hidden');
} else {
serverTag.classList.add('hidden');
}
document.getElementById('detail-loader-ver').textContent = inst.loaderVersion || '-';
document.getElementById('detail-files').textContent = inst.filesCount || '0';
document.getElementById('selected-pack-title').textContent = inst.name;
document.getElementById('selected-pack-meta').textContent =
(inst.version || '?') + (inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : '');
const playBar = document.getElementById('play-bar-name');
playBar.textContent = inst.name;
const playBtn = document.getElementById('play-btn');
if (inst.isServerPack && !this.state.account.passActive) {
playBtn.disabled = true;
playBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Pass Required';
} else {
playBtn.disabled = false;
playBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Play';
}
// Load pack description from API
const descEl = document.getElementById('pack-description-text');
const galleryEl = document.getElementById('pack-gallery');
galleryEl.innerHTML = '';
if (inst.isServerPack && inst.serverPackName) {
descEl.textContent = 'Loading description...';
this.loadPackDescription(inst.serverPackName, descEl, galleryEl);
} else {
descEl.textContent = '';
}
}
async loadPackDescription(packName, descEl, galleryEl) {
const packs = await this.req('/packs');
if (packs.success && packs.data) {
const pack = packs.data.find(p => p.name === packName);
if (pack && pack.description) {
descEl.textContent = pack.description;
} else {
descEl.textContent = 'No description available';
}
} else {
descEl.textContent = 'Failed to load description';
}
}
// ==================== LAUNCH ====================
async launchPack() {
const inst = this.state.selectedPack;
if (!inst) return;
this.toast(`Launching ${inst.name}...`, 'info');
const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }) });
if (r.success) {
this.toast(`Launched! PID: ${r.data?.pid || ''}`, 'success');
} else {
this.toast(r.error || 'Launch failed', 'error');
}
}
// ==================== INSTALL ====================
async showInstallModal() {
document.getElementById('install-modal').classList.remove('hidden');
document.getElementById('zernmc-pack-select').innerHTML = '<option>Loading...</option>';
document.getElementById('mc-version-select').innerHTML = '<option>Loading...</option>';
const packs = await this.req('/packs');
const zernmcSel = document.getElementById('zernmc-pack-select');
if (packs.success && packs.data && packs.data.length > 0) {
if (this.state.account && this.state.account.passActive) {
zernmcSel.innerHTML = '<option value="">Select a pack</option>';
zernmcSel.disabled = false;
packs.data.forEach(p => {
const o = document.createElement('option');
o.value = p.name;
o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')';
zernmcSel.appendChild(o);
});
} else {
zernmcSel.innerHTML = '<option value="">Pass required</option>';
zernmcSel.disabled = true;
}
} else {
zernmcSel.innerHTML = '<option value="">No packs available</option>';
zernmcSel.disabled = true;
}
document.getElementById('install-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (this.state.account && !this.state.account.passActive) {
document.querySelector('[data-tab="zernmc"]').style.opacity = '0.5';
} else {
document.querySelector('[data-tab="zernmc"]').style.opacity = '1';
}
const formData = {
name: document.getElementById('install-name').value,
version: document.getElementById('install-mc-version').value,
loader: document.getElementById('install-loader').value
};
await installInstance(formData);
const mc = await this.req('/mc-versions');
const mcSel = document.getElementById('mc-version-select');
if (mc.success && mc.data) {
mcSel.innerHTML = '<option value="">Select version</option>';
mc.data.forEach(v => {
const o = document.createElement('option');
o.value = v; o.textContent = v;
mcSel.appendChild(o);
});
} else {
mcSel.innerHTML = '<option value="">Failed to load</option>';
}
}
// Expose functions globally for inline handlers
window.closeInstallModal = closeInstallModal;
hideInstallModal() {
document.getElementById('install-modal').classList.add('hidden');
document.getElementById('install-progress').classList.add('hidden');
this.stopProgressPoll();
}
switchInstallTab(tab) {
document.querySelectorAll('.modal-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.modal-tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab));
}
async onLoaderChange(loader) {
const f = document.getElementById('loader-ver-field');
if (loader === 'vanilla') { f.classList.add('hidden'); return; }
f.classList.remove('hidden');
const sel = document.getElementById('loader-ver-select');
sel.innerHTML = '<option>Loading...</option>';
const mc = document.getElementById('mc-version-select').value;
if (!mc) { sel.innerHTML = '<option>Select MC version first</option>'; return; }
const r = await this.req(`/loader-versions?mc=${mc}&loader=${loader}`);
if (r.success && r.data) {
sel.innerHTML = '<option value="">Select version</option>';
r.data.forEach(v => {
const o = document.createElement('option');
o.value = v; o.textContent = v;
sel.appendChild(o);
});
} else {
sel.innerHTML = '<option>Failed to load</option>';
}
}
async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value;
const instanceName = document.getElementById('zernmc-instance-name').value.trim();
if (!packName) { this.toast('Select a pack', 'error'); return; }
if (!instanceName) { this.toast('Enter a name', 'error'); return; }
const r = await this.req('/install', {
method: 'POST',
body: JSON.stringify({ name: instanceName, version: 'latest', loader: 'zernmc' })
});
if (r.success) {
this.toast('Installing...', 'info');
this.startProgressPoll();
} else {
this.toast(r.error || 'Install failed', 'error');
}
}
async installCustom() {
const mc = document.getElementById('mc-version-select').value;
const loader = document.getElementById('loader-select').value;
const loaderVer = document.getElementById('loader-ver-select').value;
const name = document.getElementById('custom-instance-name').value.trim();
if (!mc) { this.toast('Select MC version', 'error'); return; }
if (!name) { this.toast('Enter a name', 'error'); return; }
const r = await this.req('/install', {
method: 'POST',
body: JSON.stringify({ name, version: mc, loader, loaderVersion: loaderVer })
});
if (r.success) {
this.toast('Installing...', 'info');
this.startProgressPoll();
} else {
this.toast(r.error || 'Install failed', 'error');
}
}
startProgressPoll() {
this.progressPoller = setInterval(async () => {
const r = await this.req('/install/progress');
if (r.success && r.data) {
const p = document.getElementById('install-progress');
p.classList.remove('hidden');
document.getElementById('progress-fill').style.width = (r.data.percent || 0) + '%';
document.getElementById('progress-label').textContent = r.data.label || 'Installing...';
if (!r.data.inProgress) {
this.stopProgressPoll();
this.hideInstallModal();
this.toast('Installation complete!', 'success');
await this.loadInstances();
}
}
}, 500);
}
stopProgressPoll() {
if (this.progressPoller) {
clearInterval(this.progressPoller);
this.progressPoller = null;
}
}
// ==================== SETTINGS ====================
async loadSettings() {
const r = await this.req('/settings');
if (r.success && r.data) {
const ram = r.data.maxMemory || 4096;
document.getElementById('ram-slider').value = ram;
document.getElementById('ram-value').textContent = (ram / 1024).toFixed(1) + ' GB';
document.getElementById('ram-info').textContent = ram + ' MB allocated';
document.getElementById('server-url').textContent = r.data.serverUrl || 'http://87.120.187.36:1582';
if (r.data.windowWidth) {
document.getElementById('win-width').value = r.data.windowWidth;
}
if (r.data.windowHeight) {
document.getElementById('win-height').value = r.data.windowHeight;
}
if (r.data.extraJvmArgs !== undefined) {
document.getElementById('jvm-args').value = r.data.extraJvmArgs || '';
}
if (r.data.javaPath) {
document.getElementById('java-path-input').value = r.data.javaPath;
}
} else {
document.getElementById('ram-value').textContent = '4 GB';
document.getElementById('ram-slider').value = '4096';
}
const sr = await this.req('/instances');
if (sr.success) {
document.getElementById('server-status').textContent = 'Connected';
document.getElementById('server-status').style.color = 'var(--success)';
} else {
document.getElementById('server-status').textContent = 'Disconnected';
document.getElementById('server-status').style.color = 'var(--error)';
}
}
async saveSettings() {
const ram = document.getElementById('ram-slider').value;
const w = parseInt(document.getElementById('win-width').value) || 1280;
const h = parseInt(document.getElementById('win-height').value) || 720;
const jvm = document.getElementById('jvm-args').value.trim();
const jp = document.getElementById('java-path-input').value.trim();
await this.req('/settings', { method: 'POST', body: JSON.stringify({ maxMemory: ram, windowWidth: w, windowHeight: h, extraJvmArgs: jvm, javaPath: jp }) });
}
async activatePass() {
const code = document.getElementById('pass-code').value.trim();
if (!code) { this.toast('Enter a pass code', 'error'); return; }
const r = await this.req('/activate-pass', { method: 'POST', body: JSON.stringify({ code }) });
if (r.success) {
this.toast('Pass activated!', 'success');
document.getElementById('pass-code').value = '';
// Refresh account info
const acct = await this.req('/account');
if (acct.success) {
this.state.account = acct.data;
const status = document.getElementById('account-status');
status.textContent = acct.data.passActive ? 'PRO' : 'FREE';
status.className = 'badge ' + (acct.data.passActive ? 'badge-pro' : 'badge-free');
}
} else {
this.toast(r.error || 'Activation failed', 'error');
}
}
// ==================== TOAST ====================
toast(msg, type = 'info') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast ' + type;
el.classList.remove('hidden');
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => el.classList.add('hidden'), 3000);
}
// ==================== LOADING ====================
showLoading(show) {
document.getElementById('loading-overlay').classList.toggle('hidden', !show);
}
esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
}
const app = new ZernMCLauncher();
+459 -366
View File
@@ -1,438 +1,531 @@
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--bg-deep: #07070a;
--bg-surface: #0c0c12;
--bg-elevated: #111118;
--bg-card: #16161f;
--bg-card-hover: #1c1c28;
--bg-inset: #0a0a0f;
--accent: #e94560;
--accent-hover: #ff6b6b;
--text-primary: #eaeaea;
--text-secondary: #a0a0a0;
--accent-glow: rgba(233, 69, 96, 0.25);
--accent-soft: rgba(233, 69, 96, 0.1);
--text: #eeeef0;
--text-secondary: #88889a;
--text-muted: #555566;
--border: #1e1e2a;
--border-light: #2a2a3a;
--success: #4ade80;
--error: #f87171;
--warning: #fbbf24;
--error: #ef4444;
--border: #2d2d4a;
--shadow: rgba(0, 0, 0, 0.3);
--info: #60a5fa;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow: 0 4px 24px rgba(0,0,0,0.5);
--shadow-glow: 0 0 40px var(--accent-glow);
--transition: 200ms ease;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', 'Consolas', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
font-family: var(--font);
background: var(--bg-deep);
color: var(--text);
min-height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
#app {
height: 100vh;
display: flex;
flex-direction: column;
#bg-canvas {
position: fixed; inset: 0; width: 100%; height: 100%;
z-index: 0; opacity: 0.08; pointer-events: none;
}
/* Screens */
#app { position: relative; z-index: 1; height: 100vh; display: flex; }
.screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
transition: opacity 0.4s ease, transform 0.4s ease;
}
.hidden {
display: none !important;
}
.screen.hidden { opacity: 0; transform: scale(0.97); pointer-events: none; }
/* Login Screen */
#login-screen {
justify-content: center;
align-items: center;
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
}
.hidden { display: none !important; }
/* ========== LOGIN ========== */
.login-container {
background: var(--bg-secondary);
padding: 3rem;
border-radius: 16px;
box-shadow: 0 25px 50px var(--shadow);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 48px 40px 40px;
width: 100%;
max-width: 400px;
border: 1px solid var(--border);
max-width: 380px;
box-shadow: var(--shadow);
animation: floatIn 0.5s ease forwards;
}
.logo {
font-size: 2.5rem;
text-align: center;
color: var(--accent);
margin-bottom: 0.5rem;
@keyframes floatIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.subtitle {
text-align: center;
color: var(--text-secondary);
margin-bottom: 2rem;
.login-brand { text-align: center; margin-bottom: 36px; }
.brand-icon { margin-bottom: 16px; }
.brand-title {
font-size: 28px; font-weight: 800;
background: linear-gradient(135deg, #fff 60%, var(--accent));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.brand-sub { color: var(--text-muted); font-size: 13px; margin-top: 4px; }
#login-form {
display: flex;
flex-direction: column;
gap: 1rem;
.login-form { display: flex; flex-direction: column; gap: 20px; }
.field { position: relative; }
.field label {
position: absolute; top: 50%; left: 14px; transform: translateY(-50%);
font-size: 13px; color: var(--text-muted);
transition: var(--transition); pointer-events: none;
background: var(--bg-elevated); padding: 0 4px;
}
input, select {
background: var(--bg-primary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.875rem 1rem;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
.field input:focus + label,
.field input:not(:placeholder-shown) + label {
top: 0; font-size: 11px; color: var(--accent);
}
input:focus, select:focus {
.field input {
width: 100%; padding: 14px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); transition: var(--transition);
outline: none;
}
.field input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input::placeholder {
color: var(--text-secondary);
.field select {
width: 100%; padding: 12px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); cursor: pointer; outline: none;
}
.field select:focus { border-color: var(--accent); }
.btn-primary {
background: var(--accent);
color: white;
border: none;
padding: 0.875rem 1rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
width: 100%; padding: 14px; border: none; border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), #ff6b6b);
color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
font-family: var(--font); transition: var(--transition);
display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 48px; position: relative;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: var(--shadow-glow); }
.btn-primary:active { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.error-msg {
color: var(--error); font-size: 13px; text-align: center;
padding: 10px; background: rgba(248,113,113,0.1);
border-radius: var(--radius-sm); animation: shake 0.4s ease;
}
@keyframes shake {
0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}
}
.btn-primary:hover {
background: var(--accent-hover);
.login-hint { text-align: center; font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.spinner {
position: absolute; width: 20px; height: 20px;
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
border-radius: 50%; animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ========== OVERLAY ========== */
.overlay {
position: fixed; inset: 0; background: rgba(7,7,10,0.92);
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 100; animation: fadeIn 0.3s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.loader-ring {
width: 48px; height: 48px;
border: 3px solid var(--border-light); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px;
}
.loader-text { color: var(--text-secondary); font-size: 14px; }
/* ========== MAIN SHELL ========== */
.shell {
display: flex; width: 100%; height: 100vh;
background: var(--bg-surface);
}
.btn-primary:disabled {
background: var(--text-secondary);
cursor: not-allowed;
/* ========== SIDEBAR ========== */
.sidebar {
width: 260px; min-width: 260px;
background: var(--bg-deep);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
padding: 16px 12px;
}
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 20px; overflow: hidden; }
.sidebar-brand {
display: flex; align-items: center; gap: 10px;
padding: 4px 8px 16px; border-bottom: 1px solid var(--border);
}
.sidebar-brand-text { display: flex; flex-direction: column; }
.sidebar-brand-name { font-size: 16px; font-weight: 700; }
.sidebar-brand-ver { font-size: 11px; color: var(--text-muted); }
.sidebar-nav {
display: flex; gap: 4px;
padding-bottom: 16px; border-bottom: 1px solid var(--border);
}
.nav-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px; background: transparent; border: 1px solid transparent;
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 11px;
font-weight: 500; cursor: pointer; font-family: var(--font);
transition: var(--transition);
}
.nav-btn:hover { color: var(--text-secondary); background: var(--bg-card); }
.nav-btn.active { color: var(--accent); background: var(--accent-soft); border-color: rgba(233,69,96,0.2); }
.section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px; padding: 0 4px;
}
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
.pack-list {
display: flex; flex-direction: column; gap: 3px;
overflow-y: auto; max-height: calc((100vh - 460px) / 2);
min-height: 40px;
}
.pack-list:empty::after {
content: 'No packs'; display: block; padding: 12px 8px;
font-size: 12px; color: var(--text-muted); text-align: center;
}
.pack-entry {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: var(--radius-sm);
cursor: pointer; transition: var(--transition);
border: 1px solid transparent;
}
.pack-entry:hover { background: var(--bg-card); }
.pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); }
.pack-entry-icon {
width: 32px; height: 32px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.pack-entry-icon.server { background: rgba(251,191,36,0.15); color: var(--warning); }
.pack-entry-icon.local { background: var(--accent-soft); color: var(--accent); }
.pack-entry-info { flex: 1; min-width: 0; }
.pack-entry-name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pack-entry-meta { font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.btn-icon {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid transparent; border-radius: var(--radius-sm);
color: var(--text-muted); cursor: pointer; transition: var(--transition); flex-shrink: 0;
}
.btn-icon:hover { color: var(--text); background: var(--bg-card); border-color: var(--border-light); }
/* Sidebar bottom */
.sidebar-bottom { padding-top: 12px; border-top: 1px solid var(--border); }
.user-card {
display: flex; align-items: center; gap: 10px;
padding: 8px; border-radius: var(--radius-sm);
}
.user-avatar {
width: 32px; height: 32px; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #ff6b6b);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 14px; color: #fff; flex-shrink: 0;
}
.user-info { flex: 1; min-width: 0; }
.user-name { font-size: 13px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-badges { display: flex; gap: 4px; margin-top: 2px; }
.badge {
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.badge-pro { background: rgba(74,222,128,0.15); color: var(--success); }
.badge-free { background: rgba(248,113,113,0.12); color: var(--error); }
.badge-role { background: rgba(96,165,250,0.15); color: var(--info); }
/* ========== CONTENT ========== */
.content {
flex: 1; display: flex; flex-direction: column;
padding: 24px 32px; min-width: 0;
position: relative;
}
.view { display: none; flex-direction: column; height: 100%; }
.view.active { display: flex; }
.view-header {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 24px; gap: 16px;
}
.view-title { font-size: 22px; font-weight: 700; }
.view-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 4px; }
.view-actions { display: flex; gap: 8px; flex-shrink: 0; }
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
display: flex; align-items: center; gap: 6px;
padding: 8px 16px; background: var(--bg-card); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 13px;
font-weight: 500; cursor: pointer; font-family: var(--font);
transition: var(--transition);
}
.btn-secondary:hover { background: var(--bg-card-hover); color: var(--text); border-color: var(--border); }
.btn-secondary.btn-danger:hover { color: var(--error); border-color: rgba(248,113,113,0.3); background: rgba(248,113,113,0.08); }
/* ========== PACK DETAIL ========== */
.pack-detail { flex: 1; display: flex; }
.pack-empty {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; color: var(--text-muted);
}
.pack-empty h3 { font-size: 18px; font-weight: 600; color: var(--text-secondary); }
.pack-empty p { font-size: 13px; }
.pack-detail-content { flex: 1; display: flex; flex-direction: column; gap: 24px; }
.pack-hero { display: flex; align-items: center; gap: 16px; }
.pack-icon {
width: 56px; height: 56px; border-radius: var(--radius-md);
background: var(--bg-card); border: 1px solid var(--border-light);
display: flex; align-items: center; justify-content: center; color: var(--accent);
}
.detail-name { font-size: 20px; font-weight: 700; }
.detail-tags { display: flex; gap: 6px; margin-top: 6px; }
.tag {
font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 4px;
}
.tag-mc { background: var(--bg-card); color: var(--text-secondary); }
.tag-loader { background: rgba(99,102,241,0.15); color: #818cf8; }
.tag-server { background: rgba(251,191,36,0.15); color: var(--warning); }
.pack-stats {
display: flex; gap: 24px; padding: 16px;
background: var(--bg-card); border-radius: var(--radius-md);
border: 1px solid var(--border);
padding: 0.875rem 1rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-value { font-size: 18px; font-weight: 700; color: var(--text); }
.stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.btn-secondary:hover {
background: var(--bg-secondary);
/* ========== PLAY BAR ========== */
.play-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; margin-top: auto;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.play-bar-info { font-size: 14px; font-weight: 500; color: var(--text-secondary); }
.error {
color: var(--error);
text-align: center;
margin-top: 1rem;
padding: 0.75rem;
border-radius: 8px;
background: rgba(239, 68, 68, 0.1);
/* ========== PACK DESCRIPTION ========== */
.pack-description {
padding: 16px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: var(--radius-md);
}
/* Main Screen */
#main-screen {
display: flex;
flex-direction: column;
height: 100vh;
.pack-description-text {
font-size: 13px; color: var(--text-secondary); line-height: 1.6;
}
/* Header */
.header {
background: var(--bg-secondary);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
.pack-gallery {
display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap;
}
.header .logo {
font-size: 1.5rem;
margin: 0;
}
.account-info {
display: flex;
align-items: center;
gap: 1rem;
}
#account-name {
font-weight: 600;
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
.badge.active {
background: rgba(74, 222, 128, 0.2);
color: var(--success);
}
.badge.inactive {
background: rgba(239, 68, 68, 0.2);
color: var(--error);
}
.role-badge {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
}
/* Main Content */
.main-content {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr;
gap: 1px;
background: var(--border);
.pack-gallery-item {
width: 120px; height: 80px; border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border-light);
display: flex; align-items: center; justify-content: center;
color: var(--text-muted); font-size: 11px;
overflow: hidden;
}
/* Sidebar */
.sidebar {
background: var(--bg-secondary);
padding: 1rem;
overflow-y: auto;
}
.sidebar h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.instances-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.instance-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.instance-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.instance-card.selected {
border-color: var(--accent);
background: rgba(233, 69, 96, 0.1);
}
.instance-name {
font-weight: 600;
margin-bottom: 0.5rem;
}
.instance-details {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.instance-version, .instance-loader {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: var(--bg-tertiary);
}
.instance-server-version {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: rgba(251, 191, 36, 0.2);
color: var(--warning);
}
/* Logs Panel */
.logs-panel {
background: var(--bg-primary);
padding: 1rem;
display: flex;
flex-direction: column;
overflow: hidden;
}
.logs-panel h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 1rem;
}
#logs-container {
flex: 1;
background: #0d0d1a;
border-radius: 8px;
padding: 1rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.875rem;
overflow-y: auto;
line-height: 1.6;
}
.log-line {
margin-bottom: 0.25rem;
white-space: pre-wrap;
word-break: break-all;
}
.log-line.info { color: var(--text-primary); }
.log-line.success { color: var(--success); }
.log-line.warning { color: var(--warning); }
.log-line.error { color: var(--error); }
/* Footer */
.footer {
background: var(--bg-secondary);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border);
}
.instance-info {
display: flex;
gap: 1rem;
align-items: center;
}
.instance-info span {
padding: 0.5rem 1rem;
background: var(--bg-primary);
border-radius: 6px;
font-size: 0.875rem;
}
#selected-name {
font-weight: 600;
color: var(--accent);
.pack-gallery-item img {
width: 100%; height: 100%; object-fit: cover;
}
.btn-play {
background: var(--success);
color: #0a0a0a;
border: none;
padding: 0.875rem 2rem;
border-radius: 8px;
font-size: 1.125rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex; align-items: center; gap: 8px;
padding: 12px 28px; border: none; border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--success), #22c55e);
color: #07070a; font-size: 15px; font-weight: 700; cursor: pointer;
font-family: var(--font); transition: var(--transition);
box-shadow: 0 4px 20px rgba(74,222,128,0.35);
}
.btn-play:hover:not(:disabled) { transform: translateY(-2px) scale(1.02); box-shadow: 0 8px 32px rgba(74,222,128,0.45); }
.btn-play:active:not(:disabled) { transform: translateY(0); }
.btn-play:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-play:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 0 20px rgba(74, 222, 128, 0.4);
/* ========== NEWS ========== */
.news-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px; overflow-y: auto; padding-bottom: 24px;
}
.btn-play:disabled {
background: var(--text-secondary);
cursor: not-allowed;
.news-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 24px; display: flex;
flex-direction: column; gap: 12px; transition: var(--transition);
}
.btn-play.update {
background: var(--warning);
.news-card:hover { border-color: var(--border-light); }
.news-card-badge {
align-self: flex-start; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 1px; padding: 4px 10px; border-radius: 4px;
}
.news-placeholder .news-card-badge { background: var(--accent-soft); color: var(--accent); }
.news-card h3 { font-size: 16px; font-weight: 600; }
.news-card p { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
.news-card time { font-size: 11px; color: var(--text-muted); margin-top: auto; }
/* Modal */
/* ========== SETTINGS ========== */
.settings-grid { display: flex; flex-direction: column; gap: 12px; }
.setting-card {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); gap: 24px;
}
.setting-info h4 { font-size: 14px; font-weight: 600; }
.setting-info p { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.setting-control { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.setting-control input[type="range"] {
width: 160px; height: 4px; -webkit-appearance: none; appearance: none;
background: var(--border); border-radius: 2px; outline: none;
}
.setting-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); cursor: pointer; border: 2px solid var(--bg-deep);
}
.setting-value { font-size: 14px; font-weight: 600; color: var(--text); min-width: 48px; text-align: right; }
.setting-badge {
font-size: 12px; padding: 4px 10px; border-radius: 4px;
background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-light);
}
.setting-pass { display: flex; align-items: center; gap: 8px; }
.pass-input {
width: 160px; padding: 6px 12px; border-radius: var(--radius-sm);
background: var(--bg-inset); border: 1px solid var(--border-light);
color: var(--text); font-size: 13px; outline: none;
}
.pass-input:focus { border-color: var(--accent); }
.setting-input {
padding: 6px 10px; border-radius: var(--radius-sm);
background: var(--bg-inset); border: 1px solid var(--border-light);
color: var(--text); font-size: 13px; outline: none; font-family: var(--mono);
}
.setting-input:focus { border-color: var(--accent); }
.btn-sm { padding: 6px 14px !important; font-size: 12px !important; }
/* ========== MODAL ========== */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(7,7,10,0.85);
display: flex; align-items: center; justify-content: center; z-index: 50;
animation: fadeIn 0.2s ease;
}
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); width: 90%; max-width: 480px;
max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);
animation: floatIn 0.3s ease;
}
.modal-head {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 24px; border-bottom: 1px solid var(--border);
}
.modal-head h3 { font-size: 17px; font-weight: 600; }
.modal-close {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
background: transparent; border: none; color: var(--text-muted);
font-size: 22px; cursor: pointer; border-radius: var(--radius-sm); transition: var(--transition);
}
.modal-close:hover { color: var(--text); background: var(--bg-card); }
.modal-body { padding: 20px 24px 24px; }
.modal-tabs { display: flex; gap: 8px; margin-bottom: 20px; }
.modal-tab {
flex: 1; padding: 10px; background: transparent; border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 13px;
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
}
.modal-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); }
.modal-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); }
.modal-tab-content { display: none; flex-direction: column; gap: 16px; }
.modal-tab-content.active { display: flex; }
.modal-tab-content .field label {
display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary);
margin-bottom: 6px; position: static; transform: none;
background: none; padding: 0;
}
.modal-content {
background: var(--bg-secondary);
padding: 2rem;
border-radius: 16px;
width: 100%;
max-width: 450px;
border: 1px solid var(--border);
.select-wrap select {
width: 100%; padding: 10px 12px; font-size: 13px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); cursor: pointer; outline: none;
}
.select-wrap select:focus { border-color: var(--accent); }
.modal-content h2 {
margin-bottom: 1.5rem;
.install-progress { padding-top: 16px; border-top: 1px solid var(--border); }
.progress-track {
height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden;
}
.progress-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, var(--accent), #ff6b6b);
border-radius: 3px; transition: width 0.3s ease;
}
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
#install-form {
display: flex;
flex-direction: column;
gap: 1rem;
/* ========== TOAST ========== */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 12px 24px; border-radius: var(--radius-sm);
font-size: 13px; font-weight: 500; z-index: 200;
background: var(--bg-elevated); border: 1px solid var(--border);
color: var(--text); box-shadow: var(--shadow);
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
}
.toast.error { border-color: rgba(248,113,113,0.3); color: var(--error); }
.toast.success { border-color: rgba(74,222,128,0.3); color: var(--success); }
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
#install-form label {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* ========== SCROLLBAR ========== */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
#install-form select, #install-form input {
width: 100%;
/* ========== RESPONSIVE ========== */
@media (max-width: 900px) {
.sidebar { width: 200px; min-width: 200px; }
.content { padding: 16px; }
}
.modal-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1rem;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border);
@media (max-width: 700px) {
.sidebar { width: 56px; min-width: 56px; }
.sidebar-brand-text, .sidebar-nav .nav-btn span,
.section-header, .pack-entry-info, .user-info,
.sidebar-bottom .user-card .btn-icon:first-child { display: none; }
.sidebar-brand { justify-content: center; padding: 8px; }
.sidebar-nav { flex-direction: column; }
.nav-btn { padding: 8px; }
.pack-entry { justify-content: center; padding: 8px; }
.content { padding: 12px; }
.play-bar { flex-direction: column; gap: 12px; }
.view-header { flex-direction: column; }
}
+38
View File
@@ -15,6 +15,7 @@ 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)")
@@ -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}")
+343 -20
View File
@@ -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,13 +22,18 @@ 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
import asyncio
import hashlib
import aiofiles
import mimetypes
logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes
@@ -37,6 +42,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
@@ -148,6 +165,50 @@ async def lifespan(app: FastAPI):
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 +233,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,6 +621,136 @@ 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
# Add caching headers for static files
async def send_wrapper(status, headers, *args, **kwargs):
# Add cache headers for static files
cache_headers = [
(b"cache-control", b"public, max-age=86400"), # 24 hours
(b"etag", b'"file-etag"'),
]
headers = list(headers) + cache_headers
await send(status, headers, *args, **kwargs)
# Use original send
await self.app(scope, receive, send)
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)
@@ -583,15 +822,13 @@ 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 = []
@@ -776,7 +1013,7 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
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 +1114,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 +1368,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 +1386,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,7 +1404,7 @@ 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"):
@@ -1172,6 +1415,9 @@ async def download_launcher_zip(filename: str):
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,
@@ -1239,6 +1485,76 @@ async def get_launcher_meta_list():
}
@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"""
@@ -1306,17 +1622,21 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
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",
@@ -1780,6 +2100,9 @@ if __name__ == "__main__":
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:
+229
View File
@@ -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())