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.io.*;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.*; import java.nio.file.*;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
@@ -21,10 +23,15 @@ import java.util.jar.Manifest;
public class Bootstrap { public class Bootstrap {
private static final String JAR_NAME = "zernmclauncher.jar"; private static final String JAR_NAME = "zernmclauncher.jar";
private static final String BASE_URL = "http://87.120.187.36:1582"; 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 baseDir;
private static Path binDir; private static Path binDir;
private static Path logDir; private static Path logDir;
private static Path javafxPath;
private static boolean isCliMode;
private static boolean isJfxMode;
private static Path getLauncherJar() { private static Path getLauncherJar() {
return binDir.resolve(JAR_NAME); return binDir.resolve(JAR_NAME);
@@ -36,36 +43,136 @@ public class Bootstrap {
Files.createDirectories(binDir); Files.createDirectories(binDir);
logDir = baseDir.resolve("logs"); logDir = baseDir.resolve("logs");
Files.createDirectories(logDir); Files.createDirectories(logDir);
javafxPath = baseDir.resolve("lib").resolve("javafx");
log("=== ZernMC Launcher ==="); log("=== ZernMC Launcher ===");
// Определяем режим запуска
List<String> argList = Arrays.asList(args); List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli"); isCliMode = argList.contains("--cli");
boolean jfxMode = !cliMode; // по умолчанию JFX isJfxMode = !isCliMode;
log("Mode: " + (isCliMode ? "CLI" : "JFX"));
// Проверка и обновление лаунчера
String currentVersion = readCurrentVersion(); String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion(); String serverVersion = getServerVersion();
log("Локальная версия: " + currentVersion); log("Local version: " + currentVersion);
log("Версия на сервере: " + serverVersion); log("Server version: " + serverVersion);
loadMirrors();
log("Primary server: " + BASE_URL);
log("Mirrors available: " + (MIRRORS.size() + 1));
if (isNewer(serverVersion, currentVersion)) { if (isNewer(serverVersion, currentVersion)) {
log("Доступно обновление!"); log("Update available!");
downloadUpdate(serverVersion); downloadUpdate(serverVersion);
} else { } else {
log("Версия актуальна"); log("Version is up to date");
} }
// Запуск в выбранном режиме Runtime.getRuntime().addShutdownHook(new Thread(() -> {
if (jfxMode) { log("Shutdown signal received...");
launchJFX(); }));
launchMain(args);
}
private static void launchMain(String[] args) throws Exception {
log("Loading launcher: " + getLauncherJar());
if (isCliMode) {
launchInProcess(args);
} else { } else {
launchCLI(); 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) { private static void log(String msg) {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg; String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
System.out.println(entry); System.out.println(entry);
@@ -85,7 +192,7 @@ public class Bootstrap {
if (v != null && !v.isBlank()) return v; if (v != null && !v.isBlank()) return v;
} }
} catch (Exception e) { } catch (Exception e) {
log("Ошибка чтения манифеста: " + e.getMessage()); log("Error reading manifest: " + e.getMessage());
} }
} }
return "0.0.0"; return "0.0.0";
@@ -118,7 +225,7 @@ public class Bootstrap {
} }
} }
} catch (Exception e) { } catch (Exception e) {
log("Ошибка получения версии: " + e.getMessage()); log("Error fetching version: " + e.getMessage());
} }
return "unknown"; return "unknown";
} }
@@ -139,21 +246,18 @@ public class Bootstrap {
} }
private static void downloadUpdate(String newVersion) throws Exception { private static void downloadUpdate(String newVersion) throws Exception {
log("Проверка обновлений..."); log("Checking for updates...");
// Получаем мета с сервера
Map<String, FileMeta> serverFiles = fetchServerMeta(newVersion); Map<String, FileMeta> serverFiles = fetchServerMeta(newVersion);
if (serverFiles.isEmpty()) { if (serverFiles.isEmpty()) {
log("Не удалось получить мета с сервера"); log("Failed to get server meta");
return; return;
} }
// Сканируем локальные файлы
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
log("Локальных файлов: " + localFiles.size()); log("Local files: " + localFiles.size());
log("Файлов на сервере: " + serverFiles.size()); log("Server files: " + serverFiles.size());
// Сравниваем и скачиваем
int downloaded = 0; int downloaded = 0;
int skipped = 0; int skipped = 0;
@@ -170,17 +274,17 @@ public class Bootstrap {
} }
if (localHash != null) { if (localHash != null) {
log("Обновление: " + filePath); log("Updating: " + filePath);
} else { } else {
log("Скачивание: " + filePath); log("Downloading: " + filePath);
} }
downloadFile(newVersion, filePath, serverMeta.size); downloadFile(newVersion, filePath, serverMeta.size);
downloaded++; downloaded++;
} }
log("Обновлено файлов: " + downloaded + ", пропущено: " + skipped); log("Updated files: " + downloaded + ", skipped: " + skipped);
log("Обновлено до v" + newVersion); log("Updated to v" + newVersion);
} }
private static Map<String, FileMeta> fetchServerMeta(String version) { private static Map<String, FileMeta> fetchServerMeta(String version) {
@@ -210,7 +314,7 @@ public class Bootstrap {
} }
} }
} catch (Exception e) { } catch (Exception e) {
log("Ошибка получения мета: " + e.getMessage()); log("Error fetching meta: " + e.getMessage());
} }
return files; return files;
} }
@@ -248,38 +352,90 @@ public class Bootstrap {
} }
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception { 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(); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000); conn.setConnectTimeout(10000);
conn.setReadTimeout(60000); conn.setReadTimeout(60000);
if (conn.getResponseCode() != 200) { 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()); Files.createDirectories(outPath.getParent());
long downloaded = 0; long downloaded = 0;
long lastUpdate = 0;
long startTime = System.currentTimeMillis();
try (InputStream in = conn.getInputStream(); try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(outPath.toFile())) { OutputStream out = new FileOutputStream(outPath.toFile())) {
byte[] buf = new byte[8192]; byte[] buf = new byte[65536];
int len; int len;
while ((len = in.read(buf)) > 0) { while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len); out.write(buf, 0, len);
downloaded += 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;
}
} }
} }
// Проверяем хеш long elapsed = System.currentTimeMillis() - startTime;
String actualHash = calculateFileHash(outPath); double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
String expectedHash = expectedSize > 0 ? "" : ""; System.out.println(String.format("\r[%s] %s - %.1f MB (%.1f MB/s) - Done!",
if (downloaded != expectedSize) { getProgressBar(downloaded, expectedSize),
log("Предупреждение: размер " + filePath + " не совпадает"); fileName,
downloaded / 1024.0 / 1024.0,
speed
));
}
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();
System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB");
} }
private static class FileMeta { private static class FileMeta {
@@ -291,120 +447,45 @@ public class Bootstrap {
} }
} }
private static void launchJFX() throws Exception { private static void loadMirrors() {
Path javaBin = findJava(); try {
Path jarPath = getLauncherJar(); URL url = new URL(BASE_URL + "/launcher/mirrors");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
log("Запуск JFX режима..."); conn.setConnectTimeout(5000);
log("Java: " + javaBin); conn.setReadTimeout(5000);
log("JAR: " + jarPath);
if (conn.getResponseCode() == 200) {
// JVM аргументы для UTF-8 и JavaFX try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
List<String> jvmArgs = List.of( StringBuilder sb = new StringBuilder();
"-Dfile.encoding=UTF-8", String line;
"-Dsun.stdout.encoding=UTF-8", while ((line = br.readLine()) != null) sb.append(line);
"-Dsun.stderr.encoding=UTF-8"
); com.google.gson.JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject();
com.google.gson.JsonArray mirrorsArray = json.getAsJsonArray("mirrors");
// Путь к JavaFX модулям
Path javafxPath = baseDir.resolve("lib").resolve("javafx"); for (com.google.gson.JsonElement elem : mirrorsArray) {
if (Files.exists(javafxPath)) { com.google.gson.JsonObject mirror = elem.getAsJsonObject();
jvmArgs = List.of( String mirrorUrl = mirror.get("url").getAsString();
"-Dfile.encoding=UTF-8", if (!MIRRORS.contains(mirrorUrl)) {
"-Dsun.stdout.encoding=UTF-8", MIRRORS.add(mirrorUrl);
"-Dsun.stderr.encoding=UTF-8",
"-Dlauncher.server=" + BASE_URL,
"--module-path", javafxPath.toAbsolutePath().toString(),
"--add-modules", "javafx.controls,javafx.web"
);
} else {
jvmArgs = List.of(
"-Dfile.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
"-Dlauncher.server=" + BASE_URL
);
}
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.addAll(jvmArgs);
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--jfx");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.inheritIO();
Process p = pb.start();
int code = p.waitFor();
log("Завершено с кодом: " + code);
System.exit(code);
}
private static void launchCLI() throws Exception {
Path javaBin = findJava();
Path jarPath = getLauncherJar();
log("Запуск CLI режима...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
// JVM аргументы для UTF-8
List<String> jvmArgs = List.of(
"-Dfile.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
"-Dlauncher.server=" + BASE_URL
);
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.addAll(jvmArgs);
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--cli");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.inheritIO();
Process p = pb.start();
int code = p.waitFor();
log("Завершено с кодом: " + code);
System.exit(code);
}
private static Path findJava() {
String os = System.getProperty("os.name").toLowerCase();
String javaExe = os.contains("windows") ? "java.exe" : "java";
// Сначала ищем jre21/bin/java рядом с лаунчером
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
// Если нет, пробуем системную Java
if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
}
// Если и это не найдено - ищем java в PATH
if (!Files.exists(javaBin)) {
try {
Process p = new ProcessBuilder("which", javaExe).start();
if (p.waitFor() == 0) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String path = br.readLine();
if (path != null) {
javaBin = Paths.get(path.trim());
} }
} }
} }
} catch (Exception ignored) {} }
} 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.io.*;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.*; import java.nio.file.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@@ -24,26 +26,23 @@ public class Bootstrap {
log("=== ZernMC Launcher ==="); log("=== ZernMC Launcher ===");
// Определяем режим запуска
List<String> argList = Arrays.asList(args); List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli"); boolean cliMode = argList.contains("--cli");
boolean jfxMode = !cliMode; // по умолчанию JFX boolean jfxMode = !cliMode;
// Проверка и обновление лаунчера
String currentVersion = readCurrentVersion(); String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion(); String serverVersion = getServerVersion();
log("Локальная версия: " + currentVersion); log("Local version: " + currentVersion);
log("Версия на сервере: " + serverVersion); log("Server version: " + serverVersion);
if (isNewer(serverVersion, currentVersion)) { if (isNewer(serverVersion, currentVersion)) {
log("Доступно обновление!"); log("Update available!");
downloadUpdate(serverVersion); downloadUpdate(serverVersion);
} else { } else {
log("Версия актуальна"); log("Version is up to date");
} }
// Запуск в выбранном режиме
if (jfxMode) { if (jfxMode) {
launchJFX(); launchJFX();
} else { } else {
@@ -117,10 +116,10 @@ public class Bootstrap {
while ((len = in.read(buf)) > 0) { while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len); out.write(buf, 0, len);
total += 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"); Path backup = jarFile.resolveSibling(JAR_NAME + ".old");
@@ -129,9 +128,9 @@ public class Bootstrap {
if (Files.exists(backup)) Files.delete(backup); if (Files.exists(backup)) Files.delete(backup);
Files.writeString(baseDir.resolve(VERSION_FILE), newVersion); Files.writeString(baseDir.resolve(VERSION_FILE), newVersion);
log("Обновлено до v" + newVersion); log("Updated to v" + newVersion);
} else { } 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 javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME); Path jarPath = baseDir.resolve(JAR_NAME);
log("Запуск JFX режима..."); log("Starting JFX mode...");
log("Java: " + javaBin); log("Java: " + javaBin);
log("JAR: " + jarPath); log("JAR: " + jarPath);
ProcessBuilder pb = new ProcessBuilder( List<String> cmd = new ArrayList<>();
javaBin.toAbsolutePath().toString(), cmd.add(javaBin.toAbsolutePath().toString());
"-jar", cmd.add("-Dfile.encoding=UTF-8");
jarPath.toAbsolutePath().toString(), cmd.add("-Dsun.stdout.encoding=UTF-8");
"--jfx" 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.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(); 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(); int code = p.waitFor();
log("Завершено с кодом: " + code); try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code);
System.exit(code); System.exit(code);
} }
@@ -161,21 +182,43 @@ public class Bootstrap {
Path javaBin = findJava(); Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME); Path jarPath = baseDir.resolve(JAR_NAME);
log("Запуск CLI режима..."); log("Starting CLI mode...");
log("Java: " + javaBin); log("Java: " + javaBin);
log("JAR: " + jarPath); log("JAR: " + jarPath);
ProcessBuilder pb = new ProcessBuilder( List<String> cmd = new ArrayList<>();
javaBin.toAbsolutePath().toString(), cmd.add(javaBin.toAbsolutePath().toString());
"-jar", cmd.add("-Dfile.encoding=UTF-8");
jarPath.toAbsolutePath().toString(), cmd.add("-Dsun.stdout.encoding=UTF-8");
"--cli" 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.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(); 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(); int code = p.waitFor();
log("Завершено с кодом: " + code); try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code);
System.exit(code); System.exit(code);
} }
@@ -183,15 +226,12 @@ public class Bootstrap {
String os = System.getProperty("os.name").toLowerCase(); String os = System.getProperty("os.name").toLowerCase();
String javaExe = os.contains("windows") ? "java.exe" : "java"; String javaExe = os.contains("windows") ? "java.exe" : "java";
// Сначала ищем jre21/bin/java рядом с лаунчером
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe); Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
// Если нет, пробуем системную Java
if (!Files.exists(javaBin)) { if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe); javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
} }
// Если и это не найдено - ищем java в PATH
if (!Files.exists(javaBin)) { if (!Files.exists(javaBin)) {
try { try {
Process p = new ProcessBuilder("which", javaExe).start(); Process p = new ProcessBuilder("which", javaExe).start();
@@ -207,9 +247,9 @@ public class Bootstrap {
} }
if (!Files.exists(javaBin)) { 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; return javaBin;
} }
} }
@@ -15,24 +15,23 @@ public class Main {
private static final LauncherAPI api = new LauncherAPI(); private static final LauncherAPI api = new LauncherAPI();
public static void main(String[] args) throws IOException { 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("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("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 режим if (System.getProperty("os.name").toLowerCase().contains("windows")) {
try { try {
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor(); new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
} } catch (Exception ignored) {}
} catch (Exception ignored) {} }
ZAnsi.install(); ZAnsi.install();
System.out.print("\033[H\033[2J"); System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION)); System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
// Определяем режим запуска
List<String> argList = List.of(args); List<String> argList = List.of(args);
boolean jfxMode = argList.contains("--jfx"); boolean jfxMode = argList.contains("--jfx");
boolean cliMode = argList.contains("--cli"); boolean cliMode = argList.contains("--cli");
@@ -42,23 +41,22 @@ public class Main {
return; return;
} }
// CLI режим (по умолчанию или с --cli) System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
startCLI(); startCLI();
} }
private static void launchJFX() { private static void launchJFX() {
System.out.println(ZAnsi.cyan("Запуск JFX интерфейса..."));
try { try {
// Устанавливаем параметры для JavaFX (важно для Windows)
System.setProperty("javafx.runtime.version", "21"); System.setProperty("javafx.runtime.version", "21");
JFXLauncher.main(new String[]{}); JFXLauncher.main(new String[]{});
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.brightRed("Ошибка запуска JFX: " + e.getMessage())); System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage()));
// Проверяем, связано ли это с отсутствием JavaFX
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) { if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
System.err.println(ZAnsi.yellow("JavaFX недоступен. Возможно, отсутствуют нативные библиотеки.")); System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing."));
System.err.println(ZAnsi.yellow("Попробуйте использовать CLI режим: --cli")); System.err.println(ZAnsi.yellow("Try CLI mode: --cli"));
} }
e.printStackTrace(); e.printStackTrace();
System.exit(1); System.exit(1);
@@ -66,38 +64,36 @@ public class Main {
} }
private static void startCLI() throws IOException { private static void startCLI() throws IOException {
// Проверка всех сервисов при старте ZHttpClient.checkAllServicesOnStartup(true);
ZHttpClient.checkAllServicesOnStartup();
// === АВТОРИЗАЦИЯ (используем новый API) === System.out.println(ZAnsi.cyan("Checking authorization..."));
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
var sessionResponse = api.checkSession(); var sessionResponse = api.checkSession();
if (!sessionResponse.isSuccess()) { if (!sessionResponse.isSuccess()) {
LoginMenu loginMenu = new LoginMenu(); LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show(); boolean loggedIn = loginMenu.show();
if (!loggedIn) { if (!loggedIn) {
System.out.println(ZAnsi.yellow("До свидания!")); System.out.println(ZAnsi.yellow("Goodbye!"));
ZAnsi.uninstall(); ZAnsi.uninstall();
System.exit(0); System.exit(0);
} }
} else { } else {
var sessionInfo = sessionResponse.getData(); 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 { try {
mainLoop(); mainLoop();
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage())); System.err.println(ZAnsi.brightRed("Critical error: " + e.getMessage()));
e.printStackTrace(); e.printStackTrace();
} finally { } finally {
ZAnsi.uninstall(); ZAnsi.uninstall();
} }
} }
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
private static void mainLoop() throws Exception { private static void mainLoop() throws Exception {
if (Config.isZernMCBuild()) { if (Config.isZernMCBuild()) {
zernMCFlow(); zernMCFlow();
@@ -106,24 +102,21 @@ public class Main {
} }
} }
// ====================== ZERNMC FLOW ======================
private static void zernMCFlow() throws Exception { private static void zernMCFlow() throws Exception {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ===")); System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
// 1. Проверка подключения к серверу System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
try { try {
String response = ZHttpClient.get("/health"); String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("Сервер доступен")); System.out.println(ZAnsi.brightGreen("Server is available"));
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Не удалось подключиться к ZernMC серверу")); System.out.println(ZAnsi.brightRed("Could not connect to ZernMC server"));
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage())); System.out.println(ZAnsi.white("Error: " + e.getMessage()));
ConsoleUtils.pause(); ConsoleUtils.pause();
System.exit(1); System.exit(1);
} }
// 2. Авторизация
boolean sessionRestored = AuthManager.loadSavedSession(); boolean sessionRestored = AuthManager.loadSavedSession();
if (!sessionRestored) { if (!sessionRestored) {
LoginMenu loginMenu = new LoginMenu(); LoginMenu loginMenu = new LoginMenu();
@@ -132,42 +125,40 @@ public class Main {
System.exit(0); System.exit(0);
} }
} else { } 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 launchMenu = new LaunchMenu();
launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри launchMenu.show();
} }
// ====================== GLOBAL FLOW ======================
private static void globalFlow() throws Exception { private static void globalFlow() throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Launcher ===")); System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
List<String> options = List.of( 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(); int choice = menu.show();
if (choice == -1 || choice == 4) { if (choice == -1 || choice == 4) {
System.out.println(ZAnsi.yellow("До свидания!")); System.out.println(ZAnsi.yellow("Goodbye!"));
break; break;
} }
switch (choice) { switch (choice) {
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu case 0 -> new LaunchMenu().show();
case 1 -> new UpdateMenu().show(); case 1 -> new UpdateMenu().show();
case 2 -> new SettingsMenu().show(); case 2 -> new SettingsMenu().show();
case 3 -> new ServerCheckMenu().show(); case 3 -> new ServerCheckMenu().show();
} }
} }
} }
} }
@@ -3,13 +3,12 @@ package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.auth.AuthService; import me.sashegdev.zernmc.launcher.api.auth.AuthService;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService; import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService; 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.List;
import java.util.Map;
/**
* Центральный фасад для внутреннего API лаунчера.
* Используется как единая точка входа для UI и других компонентов.
*/
public class LauncherAPI { public class LauncherAPI {
private final AuthService authService; private final AuthService authService;
@@ -34,8 +33,6 @@ public class LauncherAPI {
return launchService; return launchService;
} }
// ====================== Удобные методы ======================
public boolean isLoggedIn() { public boolean isLoggedIn() {
return authService.isLoggedIn(); return authService.isLoggedIn();
} }
@@ -56,6 +53,14 @@ public class LauncherAPI {
return authService.logout(); return authService.logout();
} }
public ApiResponse<Boolean> activatePass(String passCode) {
return authService.activatePass(passCode);
}
public ApiResponse<AuthService.LoginResult> register(String username, String password) {
return authService.register(username, password);
}
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() { public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
return instanceService.getAllInstances(); return instanceService.getAllInstances();
} }
@@ -71,4 +76,104 @@ public class LauncherAPI {
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) { public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
return launchService.launch(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 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) { public ApiResponse<LoginResult> login(String username, String password) {
try { try {
AuthManager.AuthResult result = AuthManager.login(username, password); AuthManager.AuthResult result = AuthManager.login(username, password);
@@ -15,9 +36,9 @@ public class AuthService {
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken()); LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
return ApiResponse.success(loginResult); 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) { } catch (Exception e) {
return ApiResponse.error("Ошибка авторизации: " + e.getMessage()); return ApiResponse.error("Auth error: " + e.getMessage());
} }
} }
@@ -26,7 +47,7 @@ public class AuthService {
AuthManager.logout(); AuthManager.logout();
return ApiResponse.success(true); return ApiResponse.success(true);
} catch (Exception e) { } 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.success(info);
} }
return ApiResponse.error("Сессия не найдена"); return ApiResponse.error("Session not found");
} catch (Exception e) { } 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 + "\"}"); "{\"code\":\"" + passCode + "\"}");
return ApiResponse.success(true); return ApiResponse.success(true);
} catch (Exception e) { } 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(); return AuthManager.getUsername();
} }
public String getCurrentToken() {
return AuthManager.getAccessToken();
}
public static class LoginResult { public static class LoginResult {
private String username; private String username;
private String token; private String token;
@@ -18,7 +18,7 @@ public class InstanceService {
.collect(Collectors.toList()); .collect(Collectors.toList());
return ApiResponse.success(infoList); return ApiResponse.success(infoList);
} catch (IOException e) { } 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 { try {
Instance instance = InstanceManager.getInstance(name); Instance instance = InstanceManager.getInstance(name);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + name); return ApiResponse.error("Pack not found: " + name);
} }
return ApiResponse.success(toInstanceInfo(instance)); return ApiResponse.success(toInstanceInfo(instance));
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Ошибка получения сборки: " + e.getMessage()); return ApiResponse.error("Error getting pack: " + e.getMessage());
} }
} }
@@ -38,12 +38,12 @@ public class InstanceService {
try { try {
boolean created = InstanceManager.createInstanceFolder(name); boolean created = InstanceManager.createInstanceFolder(name);
if (!created) { if (!created) {
return ApiResponse.error("Сборка с таким именем уже существует: " + name); return ApiResponse.error("A pack with this name already exists: " + name);
} }
Instance instance = InstanceManager.getInstance(name); Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(toInstanceInfo(instance)); return ApiResponse.success(toInstanceInfo(instance));
} catch (IOException e) { } catch (IOException e) {
return ApiResponse.error("Ошибка создания сборки: " + e.getMessage()); return ApiResponse.error("Error creating pack: " + e.getMessage());
} }
} }
@@ -51,11 +51,11 @@ public class InstanceService {
try { try {
boolean deleted = InstanceManager.deleteInstance(name); boolean deleted = InstanceManager.deleteInstance(name);
if (!deleted) { if (!deleted) {
return ApiResponse.error("Не удалось удалить сборку: " + name); return ApiResponse.error("Failed to delete pack: " + name);
} }
return ApiResponse.success(true); return ApiResponse.success(true);
} catch (Exception e) { } 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); Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(instance != null); return ApiResponse.success(instance != null);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Ошибка проверки сборки: " + e.getMessage()); return ApiResponse.error("Error checking pack: " + e.getMessage());
} }
} }
private InstanceInfo toInstanceInfo(Instance instance) { private InstanceInfo toInstanceInfo(Instance instance) {
String name = instance.getName().toLowerCase();
String category = instance.isServerPack() ? "zernmc" : "local";
return new InstanceInfo( return new InstanceInfo(
instance.getName(), instance.getName(),
instance.getPath().toString(), instance.getPath().toString(),
instance.getMinecraftVersion(), 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 path;
private String version; private String version;
private String loaderType; 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.name = name;
this.path = path; this.path = path;
this.version = version; this.version = version;
this.loaderType = loaderType; 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 getName() { return name; }
public String getPath() { return path; } public String getPath() { return path; }
public String getVersion() { return version; } public String getVersion() { return version; }
public String getLoaderType() { return loaderType; } public String getLoaderType() { return loaderType; }
public 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.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher; import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.Config;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
public class LaunchService { 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) { public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) {
try { try {
Instance instance = InstanceManager.getInstance(instanceName); Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName); return ApiResponse.error("Pack not found: " + instanceName);
} }
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions(); LaunchOptions options = createOptions();
List<String> command = builder.build(options); List<String> command = builder.build(options);
@@ -35,7 +51,7 @@ public class LaunchService {
); );
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Ошибка подготовки запуска: " + e.getMessage()); return ApiResponse.error("Error preparing launch: " + e.getMessage());
} }
} }
@@ -43,15 +59,11 @@ public class LaunchService {
try { try {
Instance instance = InstanceManager.getInstance(instanceName); Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName); return ApiResponse.error("Pack not found: " + instanceName);
} }
JFXLauncher.initGameLog(instance.getPath());
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions(); LaunchOptions options = createOptions();
// Set auth info
options.setUsername(AuthManager.getUsername()); options.setUsername(AuthManager.getUsername());
options.setAccessToken(AuthManager.getAccessToken()); options.setAccessToken(AuthManager.getAccessToken());
options.setUuid(AuthManager.getUuid()); options.setUuid(AuthManager.getUuid());
@@ -64,57 +76,64 @@ public class LaunchService {
processBuilder.directory(instance.getPath().toFile()); processBuilder.directory(instance.getPath().toFile());
processBuilder.redirectErrorStream(true); processBuilder.redirectErrorStream(true);
Path logsDir = instance.getPath().resolve("logs");
java.nio.file.Files.createDirectories(logsDir);
Path gameLog = logsDir.resolve("game.log");
Process process = processBuilder.start(); Process process = processBuilder.start();
System.out.println("[LAUNCH] Process started, pid=" + process.pid()); long pid = process.pid();
runningProcesses.put(pid, process);
System.out.println("[LAUNCH] Process started, pid=" + pid);
// Capture output (stdout) java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true);
Thread outThread = new Thread(() -> {
Thread logReader = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line; String line;
while ((line = reader.readLine()) != null) { 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); JFXLauncher.appendGameLog(line);
try {
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
logFileOut.flush();
} catch (Exception ignored) {}
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println("[STDOUT ERROR] " + e.getMessage()); JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage()); } finally {
try { logFileOut.close(); } catch (Exception ignored) {}
} }
}); }, "GameLogReader-" + instanceName);
outThread.setDaemon(true); logReader.setDaemon(true);
outThread.start(); logReader.start();
// Capture errors (stderr) process.onExit().thenRun(() -> {
Thread errThread = new Thread(() -> { runningProcesses.remove(pid);
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
String line;
while ((line = reader.readLine()) != null) {
System.out.println("[STDERR] " + line);
JFXLauncher.appendGameLog("[ERR] " + line);
}
} catch (Exception e) {
System.out.println("[STDERR ERROR] " + e.getMessage());
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage());
}
}); });
errThread.setDaemon(true);
errThread.start();
ProcessInfo info = new ProcessInfo( ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
instanceName,
process.pid(),
"RUNNING"
);
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } 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) { public ApiResponse<Boolean> isReady(String instanceName) {
try { try {
Instance instance = InstanceManager.getInstance(instanceName); Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName); return ApiResponse.error("Pack not found: " + instanceName);
} }
Path versionJson = instance.getPath().resolve("version.json"); Path versionJson = instance.getPath().resolve("version.json");
@@ -122,7 +141,7 @@ public class LaunchService {
return ApiResponse.success(hasVersionJson); return ApiResponse.success(hasVersionJson);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Ошибка проверки готовности: " + e.getMessage()); return ApiResponse.error("Readiness check error: " + e.getMessage());
} }
} }
@@ -130,7 +149,7 @@ public class LaunchService {
try { try {
Instance instance = InstanceManager.getInstance(instanceName); Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName); return ApiResponse.error("Pack not found: " + instanceName);
} }
InstanceInfo info = new InstanceInfo( InstanceInfo info = new InstanceInfo(
@@ -142,10 +161,28 @@ public class LaunchService {
); );
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } 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 { public static class LaunchInfo {
private String instanceName; private String instanceName;
private List<String> command; private List<String> command;
@@ -26,14 +26,12 @@ public class AuthManager {
private static volatile AuthSession session = null; private static volatile AuthSession session = null;
private static volatile UserInfo userInfo = null; private static volatile UserInfo userInfo = null;
// === Роли ===
public static final int ROLE_USER = 0; public static final int ROLE_USER = 0;
public static final int ROLE_PASS_HOLDER = 1; public static final int ROLE_PASS_HOLDER = 1;
public static final int ROLE_MODERATOR = 2; public static final int ROLE_MODERATOR = 2;
public static final int ROLE_ELDER = 3; public static final int ROLE_ELDER = 3;
public static final int ROLE_CREATOR = 4; public static final int ROLE_CREATOR = 4;
// === Права доступа ===
public static final String PERM_VIEW_PACKS = "view_packs"; public static final String PERM_VIEW_PACKS = "view_packs";
public static final String PERM_DOWNLOAD_PACK = "download_pack"; public static final String PERM_DOWNLOAD_PACK = "download_pack";
@@ -56,7 +54,6 @@ public class AuthManager {
} }
} }
// ====================== АВТОРИЗАЦИЯ ======================
public static AuthResult login(String username, String password) { public static AuthResult login(String username, String password) {
return authRequest("/auth/login", username, password); return authRequest("/auth/login", username, password);
} }
@@ -77,13 +74,13 @@ public class AuthManager {
userInfo = fetchUserInfo(); userInfo = fetchUserInfo();
return AuthResult.ok(); return AuthResult.ok();
} else if (resp.statusCode() == 422) { } else if (resp.statusCode() == 422) {
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body())); return AuthResult.fail("Validation error: " + extractError(resp.body()));
} else { } else {
return AuthResult.fail(extractError(resp.body())); return AuthResult.fail(extractError(resp.body()));
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); 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.createDirectories(AUTH_FILE.getParent());
Files.writeString(AUTH_FILE, GSON.toJson(session)); Files.writeString(AUTH_FILE, GSON.toJson(session));
} catch (IOException e) { } 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() { private static UserInfo fetchUserInfo() {
if (!isLoggedIn() || session.accessToken == null) return null; if (!isLoggedIn() || session.accessToken == null) return null;
try { try {
// Используем существующий метод ZHttpClient.get() + вручную добавляем токен
java.net.HttpURLConnection conn = null; java.net.HttpURLConnection conn = null;
try { try {
URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me"); URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me");
@@ -185,12 +180,11 @@ public class AuthManager {
if (conn != null) conn.disconnect(); if (conn != null) conn.disconnect();
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("Не удалось получить UserInfo: " + e.getMessage()); System.err.println("Failed to get UserInfo: " + e.getMessage());
return null; return null;
} }
} }
// ==================== ПРОВЕРКИ ПРАВ ====================
public static boolean hasPass() { public static boolean hasPass() {
if (userInfo != null) return userInfo.has_pass; if (userInfo != null) return userInfo.has_pass;
return getRole() >= ROLE_PASS_HOLDER; return getRole() >= ROLE_PASS_HOLDER;
@@ -200,14 +194,14 @@ public class AuthManager {
if (userInfo != null && userInfo.permissions != null) { if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_VIEW_PACKS); return userInfo.permissions.contains(PERM_VIEW_PACKS);
} }
return hasPass(); // fallback для старых аккаунтов return hasPass();
} }
public static boolean canDownloadPacks() { public static boolean canDownloadPacks() {
if (userInfo != null && userInfo.permissions != null) { if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK); return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
} }
return hasPass(); // fallback return hasPass();
} }
public static int getRole() { public static int getRole() {
@@ -221,7 +215,6 @@ public class AuthManager {
return "USER"; return "USER";
} }
// ====================== POST ======================
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception { private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint; String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
HttpURLConnection conn = null; HttpURLConnection conn = null;
@@ -291,24 +284,23 @@ public class AuthManager {
JsonObject json = JsonParser.parseString(response).getAsJsonObject(); JsonObject json = JsonParser.parseString(response).getAsJsonObject();
return json.has("has_active") && json.get("has_active").getAsBoolean(); return json.has("has_active") && json.get("has_active").getAsBoolean();
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.red("Не удалось проверить проходки: ") + e.getMessage()); System.err.println(ZAnsi.red("Failed to check pass: ") + e.getMessage());
return false; return false;
} }
} }
public static String getPassStatus() { public static String getPassStatus() {
if (!isLoggedIn()) return "Не авторизован"; if (!isLoggedIn()) return "Not logged in";
try { try {
String response = ZHttpClient.get("/auth/pass/my"); String response = ZHttpClient.get("/auth/pass/my");
JsonObject json = JsonParser.parseString(response).getAsJsonObject(); JsonObject json = JsonParser.parseString(response).getAsJsonObject();
boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean(); boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean();
return hasActive ? "Есть активная проходка" : "Проходка отсутствует"; return hasActive ? "Active pass" : "No pass";
} catch (Exception e) { } catch (Exception e) {
return "Ошибка проверки"; return "Check error";
} }
} }
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
public static class AuthSession { public static class AuthSession {
@SerializedName("access_token") public String accessToken; @SerializedName("access_token") public String accessToken;
@SerializedName("refresh_token") public String refreshToken; @SerializedName("refresh_token") public String refreshToken;
@@ -351,7 +343,6 @@ public class AuthManager {
} }
} }
// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ======================
class SimpleHttpResponse { class SimpleHttpResponse {
final int statusCode; final int statusCode;
final String body; final String body;
@@ -363,4 +354,4 @@ class SimpleHttpResponse {
int statusCode() { return statusCode; } int statusCode() { return statusCode; }
String body() { return body; } String body() { return body; }
} }
@@ -33,12 +33,11 @@ public class LaunchMenu {
} }
} }
// ====================== ZERNMC BUILD ======================
private void showZernMCOnly() throws Exception { private void showZernMCOnly() throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ===")); System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
System.out.println(ZAnsi.cyan("Доступны только серверные сборки")); System.out.println(ZAnsi.cyan("Server packs only"));
if (!awaitActivePass()) { if (!awaitActivePass()) {
return; return;
@@ -48,13 +47,13 @@ public class LaunchMenu {
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks(); List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) { if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("На данный момент нет доступных сборок на сервере.")); System.out.println(ZAnsi.yellow("No packs available on the server."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
List<String> options = availablePacks.stream() 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.getName(),
p.getMinecraftVersion(), p.getMinecraftVersion(),
p.getLoaderType(), p.getLoaderType(),
@@ -62,9 +61,9 @@ public class LaunchMenu {
p.getFilesCount())) p.getFilesCount()))
.collect(Collectors.toList()); .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(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return; if (choice == -1 || choice == options.size() - 1) return;
@@ -76,25 +75,25 @@ public class LaunchMenu {
private boolean awaitActivePass() throws Exception { private boolean awaitActivePass() throws Exception {
if (AuthManager.hasActivePass()) { if (AuthManager.hasActivePass()) {
System.out.println(ZAnsi.brightGreen("Активная проходка подтверждена")); System.out.println(ZAnsi.brightGreen("Active pass confirmed"));
return true; return true;
} }
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightRed("У вас нет активной проходки!")); System.out.println(ZAnsi.brightRed("You don't have an active pass!"));
System.out.println(ZAnsi.white("Для доступа к сборкам ZernMC требуется активная проходка.")); System.out.println(ZAnsi.white("Access to ZernMC packs requires an active pass."));
System.out.println(); System.out.println();
openActivationWebsite(); openActivationWebsite();
System.out.println(ZAnsi.cyan("Ожидаем активацию проходки... (проверка каждые 10 секунд)")); System.out.println(ZAnsi.cyan("Waiting for pass activation... (checking every 10 seconds)"));
System.out.println(ZAnsi.white("Нажмите Enter для отмены")); System.out.println(ZAnsi.white("Press Enter to cancel"));
for (int i = 0; i < 60; i++) { for (int i = 0; i < 60; i++) {
try { try {
if (System.in.available() > 0) { if (System.in.available() > 0) {
Input.readLine(); Input.readLine();
System.out.println(ZAnsi.yellow("\nОжидание отменено.")); System.out.println(ZAnsi.yellow("\nWaiting cancelled."));
return false; return false;
} }
} catch (Exception ignored) {} } catch (Exception ignored) {}
@@ -102,7 +101,7 @@ public class LaunchMenu {
Thread.sleep(10000); Thread.sleep(10000);
if (AuthManager.hasActivePass()) { if (AuthManager.hasActivePass()) {
System.out.println(ZAnsi.brightGreen("\n✓ Проходка успешно активирована!")); System.out.println(ZAnsi.brightGreen("\n✓ Pass activated successfully!"));
return true; return true;
} }
@@ -110,43 +109,42 @@ public class LaunchMenu {
if ((i + 1) % 6 == 0) System.out.println(); 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; return false;
} }
private void openActivationWebsite() { private void openActivationWebsite() {
//String url = "https://launcher.ru.zernmc.ru/activate-pass";
String url = ZHttpClient.getBaseUrl() + "/activate-pass"; String url = ZHttpClient.getBaseUrl() + "/activate-pass";
try { try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(new URI(url)); Desktop.getDesktop().browse(new URI(url));
System.out.println(ZAnsi.cyan("Браузер открыт: " + url)); System.out.println(ZAnsi.cyan("Browser opened: " + url));
} else { } else {
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически.")); System.out.println(ZAnsi.yellow("Could not open browser automatically."));
System.out.println(ZAnsi.white("Откройте вручную: " + url)); System.out.println(ZAnsi.white("Open manually: " + url));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Ошибка открытия браузера: " + e.getMessage())); System.out.println(ZAnsi.brightRed("Error opening browser: " + e.getMessage()));
System.out.println(ZAnsi.white("Ссылка: " + url)); System.out.println(ZAnsi.white("Link: " + url));
} }
} }
private void installAndRunServerPack(ServerPack selected) throws Exception { private void installAndRunServerPack(ServerPack selected) throws Exception {
ConsoleUtils.clearScreen(); 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(" Minecraft: ") + selected.getMinecraftVersion());
System.out.println(ZAnsi.white(" Лоадер: ") + selected.getLoaderType() + System.out.println(ZAnsi.white(" Loader: ") + selected.getLoaderType() +
(selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : "")); (selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : ""));
System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion()); System.out.println(ZAnsi.white(" Version: v") + selected.getVersion());
System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount()); System.out.println(ZAnsi.white(" Files: ") + selected.getFilesCount());
String localName = askPackName(); String localName = askPackName();
if (localName == null) return; if (localName == null) return;
if (InstanceManager.getInstance(localName) != null) { if (InstanceManager.getInstance(localName) != null) {
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
@@ -158,18 +156,17 @@ public class LaunchMenu {
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected); boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
if (!success) { if (!success) {
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку.")); System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!")); System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
launchExistingInstance(newInstance); launchExistingInstance(newInstance);
} }
// ====================== GLOBAL BUILD ======================
private void showGlobal() throws Exception { private void showGlobal() throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
@@ -179,10 +176,10 @@ public class LaunchMenu {
.map(Instance::toString) .map(Instance::toString)
.collect(Collectors.toList()); .collect(Collectors.toList());
options.add("Установить новую сборку"); options.add("Install new pack");
options.add("Назад в главное меню"); options.add("Back to main menu");
ArrowMenu menu = new ArrowMenu("Управление сборками", options); ArrowMenu menu = new ArrowMenu("Manage packs", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) break; if (choice == -1 || choice == options.size() - 1) break;
@@ -201,13 +198,13 @@ public class LaunchMenu {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
List<String> options = List.of( List<String> options = List.of(
"Установить сборку с сервера ZernMC", "Install pack from ZernMC server",
"Установить Vanilla Minecraft", "Install Vanilla Minecraft",
"Создать сборку вручную (Fabric/Forge)", "Create custom pack (Fabric/Forge)",
"Назад" "Back"
); );
ArrowMenu menu = new ArrowMenu("Установка новой сборки", options); ArrowMenu menu = new ArrowMenu("Install new pack", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == 3) return; if (choice == -1 || choice == 3) return;
@@ -223,28 +220,28 @@ public class LaunchMenu {
if (!awaitActivePass()) return; if (!awaitActivePass()) return;
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Получение списка доступных сборок...")); System.out.println(ZAnsi.cyan("Fetching available packs..."));
PackDownloader tempDownloader = new PackDownloader(null); PackDownloader tempDownloader = new PackDownloader(null);
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks(); List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) { if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("Нет доступных сборок на сервере.")); System.out.println(ZAnsi.yellow("No packs available on the server."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
List<String> options = availablePacks.stream() 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.getName(),
p.getMinecraftVersion(), p.getMinecraftVersion(),
p.getLoaderType(), p.getLoaderType(),
p.getVersion(), p.getVersion(),
p.getFilesCount())) p.getFilesCount()))
.collect(Collectors.toList()); .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(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return; if (choice == -1 || choice == options.size() - 1) return;
@@ -252,14 +249,14 @@ public class LaunchMenu {
ServerPack selected = availablePacks.get(choice); ServerPack selected = availablePacks.get(choice);
ConsoleUtils.clearScreen(); 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(); String localName = Input.readLine().trim();
if (localName.isEmpty()) localName = selected.getName(); if (localName.isEmpty()) localName = selected.getName();
if (InstanceManager.getInstance(localName) != null) { if (InstanceManager.getInstance(localName) != null) {
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
@@ -271,37 +268,36 @@ public class LaunchMenu {
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected); boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!")); System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!"));
} else { } else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку.")); System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
// ====================== manageInstance — полностью восстановлен ======================
private void manageInstance(Instance instance) throws Exception { private void manageInstance(Instance instance) throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName())); System.out.println(ZAnsi.header("Managing pack: " + instance.getName()));
System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion())); System.out.println(ZAnsi.white("Version: " + instance.getMinecraftVersion()));
System.out.println(ZAnsi.white("Лоадер: " + instance.getLoaderType() + System.out.println(ZAnsi.white("Loader: " + instance.getLoaderType() +
(instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : ""))); (instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : "")));
if (instance.isServerPack()) { 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<>(); List<String> options = new ArrayList<>();
options.add("Запустить сборку"); options.add("Launch pack");
if (instance.isServerPack()) { if (instance.isServerPack()) {
options.add("Проверить обновления"); options.add("Check for updates");
} }
options.add("Изменить версию лоадера"); options.add("Change loader version");
options.add("Удалить сборку"); options.add("Delete pack");
options.add("Назад"); options.add("Back");
ArrowMenu menu = new ArrowMenu("Действия", options); ArrowMenu menu = new ArrowMenu("Actions", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return; if (choice == -1 || choice == options.size() - 1) return;
@@ -329,40 +325,40 @@ public class LaunchMenu {
private void checkAndUpdateServerPack(Instance instance) throws Exception { private void checkAndUpdateServerPack(Instance instance) throws Exception {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName())); System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName()));
PackDownloader downloader = new PackDownloader(instance); PackDownloader downloader = new PackDownloader(instance);
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
if (!hasUpdate) { 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(); ConsoleUtils.pause();
return; return;
} }
System.out.println(ZAnsi.brightYellow("Доступно обновление!")); System.out.println(ZAnsi.brightYellow("Update available!"));
if (Input.confirm("Обновить сборку")) { if (Input.confirm("Update pack")) {
boolean success = downloader.updatePack(instance.getServerPackName()); boolean success = downloader.updatePack(instance.getServerPackName());
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("Сборка успешно обновлена!")); System.out.println(ZAnsi.brightGreen("Pack updated successfully!"));
} else { } else {
System.out.println(ZAnsi.brightRed("Не удалось обновить сборку.")); System.out.println(ZAnsi.brightRed("Failed to update pack."));
} }
} else { } else {
System.out.println(ZAnsi.yellow("Обновление отменено.")); System.out.println(ZAnsi.yellow("Update cancelled."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
private void changeLoaderVersion(Instance instance) throws Exception { private void changeLoaderVersion(Instance instance) throws Exception {
ConsoleUtils.clearScreen(); 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 currentLoader = instance.getLoaderType();
String mcVersion = instance.getMinecraftVersion(); String mcVersion = instance.getMinecraftVersion();
if ("vanilla".equalsIgnoreCase(currentLoader)) { 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(); ConsoleUtils.pause();
return; return;
} }
@@ -378,7 +374,7 @@ public class LaunchMenu {
if (newLoaderVersion == null) return; 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); MinecraftLib lib = new MinecraftLib(instance);
boolean success; boolean success;
@@ -393,12 +389,12 @@ public class LaunchMenu {
} }
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!")); System.out.println(ZAnsi.brightGreen("Loader version changed successfully!"));
} else { } else {
System.out.println(ZAnsi.brightRed("Не удалось изменить версию лоадера.")); System.out.println(ZAnsi.brightRed("Failed to change loader version."));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage())); System.out.println(ZAnsi.brightRed("Error changing loader: " + e.getMessage()));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -408,12 +404,12 @@ public class LaunchMenu {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
List<String> confirmOptions = List.of( List<String> confirmOptions = List.of(
"Да, удалить сборку", "Yes, delete pack",
"Нет, отменить" "No, cancel"
); );
ArrowMenu confirmMenu = new ArrowMenu( ArrowMenu confirmMenu = new ArrowMenu(
"Вы действительно хотите удалить сборку '" + instance.getName() + "'?", "Are you sure you want to delete '" + instance.getName() + "'?",
confirmOptions confirmOptions
); );
@@ -422,12 +418,12 @@ public class LaunchMenu {
if (choice == 0) { if (choice == 0) {
boolean deleted = InstanceManager.deleteInstance(instance.getName()); boolean deleted = InstanceManager.deleteInstance(instance.getName());
if (deleted) { if (deleted) {
System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена.")); System.out.println(ZAnsi.brightGreen("Pack '" + instance.getName() + "' deleted successfully."));
} else { } else {
System.out.println(ZAnsi.brightRed("Не удалось удалить сборку.")); System.out.println(ZAnsi.brightRed("Failed to delete pack."));
} }
} else { } else {
System.out.println(ZAnsi.yellow("Удаление отменено.")); System.out.println(ZAnsi.yellow("Deletion cancelled."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -436,16 +432,20 @@ public class LaunchMenu {
private void launchExistingInstance(Instance instance) { private void launchExistingInstance(Instance instance) {
if (instance.isServerPack() && !AuthManager.hasActivePass()) { if (instance.isServerPack() && !AuthManager.hasActivePass()) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!")); System.out.println(ZAnsi.brightRed("Launching a server pack requires an active pass!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName())); System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
MinecraftLib lib = new MinecraftLib(instance); MinecraftLib lib = new MinecraftLib(instance);
LaunchOptions options = new LaunchOptions(); 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.setUsername(AuthManager.getUsername());
options.setUuid(AuthManager.getUuid()); options.setUuid(AuthManager.getUuid());
@@ -454,20 +454,18 @@ public class LaunchMenu {
try { try {
lib.launch(options); lib.launch(options);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage())); System.out.println(ZAnsi.brightRed("Error launching: " + e.getMessage()));
e.printStackTrace(); e.printStackTrace();
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
// ====================== Остальные вспомогательные методы ======================
private String askPackName() { private String askPackName() {
System.out.print(ZAnsi.white("\nВведите название новой сборки: ")); System.out.print(ZAnsi.white("\nEnter new pack name: "));
String name = Input.readLine().trim(); String name = Input.readLine().trim();
if (name.isEmpty()) { if (name.isEmpty()) {
System.out.println(ZAnsi.yellow("Отменено.")); System.out.println(ZAnsi.yellow("Cancelled."));
return null; return null;
} }
return name; return name;
@@ -475,7 +473,7 @@ public class LaunchMenu {
private void createVanillaInstance() throws Exception { private void createVanillaInstance() throws Exception {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft...")); System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
VersionInstaller versionInstaller = new VersionInstaller(null); VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions(); List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
@@ -483,9 +481,9 @@ public class LaunchMenu {
List<String> versionOptions = allVersions.stream() List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")") .map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList()); .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(); int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
@@ -497,7 +495,7 @@ public class LaunchMenu {
if (packName == null) return; if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) { if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
@@ -509,9 +507,9 @@ public class LaunchMenu {
boolean success = lib.installMinecraft(mcVersion); boolean success = lib.installMinecraft(mcVersion);
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!")); System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla pack '" + packName + "' created successfully!"));
} else { } else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку.")); System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to create pack."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -519,7 +517,7 @@ public class LaunchMenu {
private void createCustomInstance() throws Exception { private void createCustomInstance() throws Exception {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft...")); System.out.println(ZAnsi.cyan("Fetching Minecraft versions..."));
VersionInstaller versionInstaller = new VersionInstaller(null); VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions(); List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
@@ -527,9 +525,9 @@ public class LaunchMenu {
List<String> versionOptions = allVersions.stream() List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")") .map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList()); .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(); int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
@@ -538,7 +536,7 @@ public class LaunchMenu {
String mcVersion = selectedMc.getId(); String mcVersion = selectedMc.getId();
List<String> loaderOptions = buildLoaderOptions(mcVersion); 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(); int loaderChoice = loaderMenu.show();
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return; if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
@@ -574,7 +572,7 @@ public class LaunchMenu {
if (packName == null) return; if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) { if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); System.out.println(ZAnsi.brightRed("A pack with this name already exists!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
@@ -594,9 +592,9 @@ public class LaunchMenu {
} }
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!")); System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + packName + "' installed successfully!"));
} else { } else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку.")); System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to install pack."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -609,7 +607,7 @@ public class LaunchMenu {
if (isNeoForgeSupported(mcVersion)) options.add("NeoForge"); if (isNeoForgeSupported(mcVersion)) options.add("NeoForge");
if (isForgeSupported(mcVersion)) options.add("Forge"); if (isForgeSupported(mcVersion)) options.add("Forge");
options.add("Vanilla"); options.add("Vanilla");
options.add("Назад"); options.add("Back");
return options; return options;
} }
@@ -631,16 +629,16 @@ public class LaunchMenu {
} }
private String askFabricLoaderVersion() throws Exception { 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> versions = ZHttpClient.getFabricLoaderVersions();
List<String> options = versions.stream() List<String> options = versions.stream()
.limit(30) .limit(30)
.map(v -> "Fabric Loader " + v) .map(v -> "Fabric Loader " + v)
.collect(Collectors.toList()); .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(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null; if (choice == -1 || choice == options.size() - 1) return null;
@@ -648,7 +646,7 @@ public class LaunchMenu {
} }
private String askForgeVersion(String mcVersion) throws Exception { 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(); List<String> allForgeVersions = getAllForgeVersions();
@@ -658,7 +656,7 @@ public class LaunchMenu {
.collect(Collectors.toList()); .collect(Collectors.toList());
if (compatibleVersions.isEmpty()) { if (compatibleVersions.isEmpty()) {
System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion)); System.out.println(ZAnsi.yellow("No compatible Forge versions found for " + mcVersion));
ConsoleUtils.pause(); ConsoleUtils.pause();
return null; return null;
} }
@@ -667,9 +665,9 @@ public class LaunchMenu {
.limit(30) .limit(30)
.map(v -> "Forge " + v) .map(v -> "Forge " + v)
.collect(Collectors.toList()); .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(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null; if (choice == -1 || choice == options.size() - 1) return null;
@@ -698,7 +696,7 @@ public class LaunchMenu {
} }
private String askNeoForgeVersion(String mcVersion) throws Exception { 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(); List<String> allNeoForgeVersions = getAllNeoForgeVersions();
@@ -707,7 +705,7 @@ public class LaunchMenu {
.collect(Collectors.toList()); .collect(Collectors.toList());
if (compatibleVersions.isEmpty()) { if (compatibleVersions.isEmpty()) {
System.out.println(ZAnsi.yellow("Не найдено совместимых версий NeoForge для " + mcVersion)); System.out.println(ZAnsi.yellow("No compatible NeoForge versions found for " + mcVersion));
ConsoleUtils.pause(); ConsoleUtils.pause();
return null; return null;
} }
@@ -716,9 +714,9 @@ public class LaunchMenu {
.limit(30) .limit(30)
.map(v -> "NeoForge " + v) .map(v -> "NeoForge " + v)
.collect(Collectors.toList()); .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(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null; if (choice == -1 || choice == options.size() - 1) return null;
@@ -760,11 +758,10 @@ public class LaunchMenu {
index = end; index = end;
} }
} catch (Exception e) { } catch (Exception e) {
// Skip if one maven doesn't have the artifact
} }
} }
versions.sort((a, b) -> b.compareTo(a)); versions.sort((a, b) -> b.compareTo(a));
return versions; return versions;
} }
} }
@@ -10,30 +10,20 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
/**
* Экран входа/регистрации.
* Показывается при старте лаунчера, если нет сохранённой сессии.
*
* show() возвращает true — пользователь вошёл/зарегистрировался
* false — пользователь выбрал выход из лаунчера
*/
public class LoginMenu { public class LoginMenu {
/**
* Главный экран выбора действия.
*/
public boolean show() throws IOException { public boolean show() throws IOException {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
printBanner(); printBanner();
List<String> options = List.of( 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(); int choice = menu.show();
if (choice == -1 || choice == 2) return false; if (choice == -1 || choice == 2) return false;
@@ -45,62 +35,56 @@ public class LoginMenu {
}; };
if (success) return true; if (success) return true;
// Если не успех — покажем меню снова (ошибка уже напечатана внутри методов)
} }
} }
/**
* Показывается когда пользователь уже вошёл — предлагает выйти из аккаунта.
*/
public void showAccountMenu() throws IOException { public void showAccountMenu() throws IOException {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== Аккаунт ===")); System.out.println(ZAnsi.header("=== Account ==="));
System.out.println(); 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(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid()));
System.out.println(); System.out.println();
List<String> options = List.of( List<String> options = List.of(
"Выйти из аккаунта", "Log Out",
"Назад" "Back"
); );
ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options); ArrowMenu menu = new ArrowMenu("Account Management", options);
int choice = menu.show(); int choice = menu.show();
if (choice == 0) { if (choice == 0) {
AuthManager.logout(); AuthManager.logout();
System.out.println(ZAnsi.yellow("Вы вышли из аккаунта.")); System.out.println(ZAnsi.yellow("Logged out."));
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
} }
// ====================== ПРИВАТНЫЕ МЕТОДЫ ======================
private boolean doLogin() throws IOException { private boolean doLogin() throws IOException {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
printBanner(); printBanner();
System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]")); System.out.println(ZAnsi.cyan(" [ Sign In ]"));
System.out.println(); System.out.println();
String username = Input.readLine(ZAnsi.white(" Имя пользователя: ")); String username = Input.readLine(ZAnsi.white(" Username: "));
if (username.isEmpty()) return false; if (username.isEmpty()) return false;
String password = readPassword(" Пароль: "); String password = readPassword(" Password: ");
if (password.isEmpty()) return false; if (password.isEmpty()) return false;
System.out.println(); System.out.println();
System.out.print(ZAnsi.cyan(" Выполняем вход...")); System.out.print(ZAnsi.cyan(" Signing in..."));
AuthResult result = AuthManager.login(username, password); AuthResult result = AuthManager.login(username, password);
if (result.success) { if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! ")); System.out.println("\r" + ZAnsi.brightGreen(" Welcome, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause(); ConsoleUtils.pause();
return true; return true;
} else { } else {
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " ")); System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
ConsoleUtils.pause(); ConsoleUtils.pause();
return false; return false;
} }
@@ -109,45 +93,41 @@ public class LoginMenu {
private boolean doRegister() throws IOException { private boolean doRegister() throws IOException {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
printBanner(); printBanner();
System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]")); System.out.println(ZAnsi.cyan(" [ Create Account ]"));
System.out.println(); System.out.println();
System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _")); System.out.println(ZAnsi.yellow(" Allowed characters: a-z, A-Z, 0-9, _"));
System.out.println(ZAnsi.yellow(" Длина имени: 3-16 символов | Длина пароля: от 6 символов")); System.out.println(ZAnsi.yellow(" Name length: 3-16 chars | Password length: 6+ chars"));
System.out.println(); System.out.println();
String username = Input.readLine(ZAnsi.white(" Имя пользователя: ")); String username = Input.readLine(ZAnsi.white(" Username: "));
if (username.isEmpty()) return false; if (username.isEmpty()) return false;
String password = readPassword(" Пароль: "); String password = readPassword(" Password: ");
if (password.isEmpty()) return false; if (password.isEmpty()) return false;
String confirm = readPassword(" Повторите пароль: "); String confirm = readPassword(" Confirm password: ");
if (!password.equals(confirm)) { if (!password.equals(confirm)) {
System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!")); System.out.println(ZAnsi.brightRed("\n Passwords do not match!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return false; return false;
} }
System.out.println(); System.out.println();
System.out.print(ZAnsi.cyan(" Создаём аккаунт...")); System.out.print(ZAnsi.cyan(" Creating account..."));
AuthResult result = AuthManager.register(username, password); AuthResult result = AuthManager.register(username, password);
if (result.success) { if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! ")); System.out.println("\r" + ZAnsi.brightGreen(" Account created! Welcome, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause(); ConsoleUtils.pause();
return true; return true;
} else { } else {
System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " ")); System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " "));
ConsoleUtils.pause(); ConsoleUtils.pause();
return false; return false;
} }
} }
/**
* Читаем пароль — стараемся скрыть вывод через Console,
* если недоступно (IDE/терминал без TTY) — читаем обычным способом.
*/
private String readPassword(String prompt) throws IOException { private String readPassword(String prompt) throws IOException {
org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder() org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder()
.system(true) .system(true)
@@ -165,27 +145,26 @@ public class LoginMenu {
int key = passTerminal.reader().read(); int key = passTerminal.reader().read();
if (key == 27) { if (key == 27) {
// Escape sequence — consume remaining bytes (arrow keys, etc.)
int next = passTerminal.reader().read(); int next = passTerminal.reader().read();
if (next == 91) { // '[' — arrow key sequence if (next == 91) {
passTerminal.reader().read(); // consume 'A'/'B'/'C'/'D' passTerminal.reader().read();
} }
continue; continue;
} }
if (key == 13 || key == 10) { // Enter if (key == 13 || key == 10) {
passTerminal.writer().println(); passTerminal.writer().println();
break; break;
} else if (key == 127 || key == 8) { // Backspace } else if (key == 127 || key == 8) {
if (password.length() > 0) { if (password.length() > 0) {
password.setLength(password.length() - 1); password.setLength(password.length() - 1);
passTerminal.writer().print("\b \b"); passTerminal.writer().print("\b \b");
passTerminal.writer().flush(); passTerminal.writer().flush();
} }
} else if (key == 3) { // Ctrl+C } else if (key == 3) {
passTerminal.writer().println(); passTerminal.writer().println();
System.exit(0); System.exit(0);
} else if (key >= 32 && key < 127) { // Printable characters } else if (key >= 32 && key < 127) {
password.append((char) key); password.append((char) key);
passTerminal.writer().print('*'); passTerminal.writer().print('*');
passTerminal.writer().flush(); passTerminal.writer().flush();
@@ -18,17 +18,17 @@ public class ServerCheckMenu {
public void show() throws IOException { public void show() throws IOException {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Диагностика подключения")); System.out.println(ZAnsi.header("Connection Diagnostics"));
List<String> options = List.of( List<String> options = List.of(
"Проверить подключение к ZernMC серверу", "Check ZernMC server connection",
"Проверить доступ к Mojang (Minecraft)", "Check Mojang (Minecraft) access",
"Проверить доступ к Fabric Meta", "Check Fabric Meta access",
"Проверить доступ к Forge Maven", "Check Forge Maven access",
"Назад в главное меню" "Back to main menu"
); );
ArrowMenu menu = new ArrowMenu("Выберите проверку", options); ArrowMenu menu = new ArrowMenu("Select check", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == 4) { if (choice == -1 || choice == 4) {
@@ -49,20 +49,20 @@ public class ServerCheckMenu {
} }
private void checkZernServer() { private void checkZernServer() {
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу...")); System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
try { try {
String response = ZHttpClient.get("/health"); String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!")); System.out.println(ZAnsi.brightGreen("[OK] ZernMC server connected successfully!"));
System.out.println(ZAnsi.white("Ответ сервера: ") + response); System.out.println(ZAnsi.white("Server response: ") + response);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу")); System.out.println(ZAnsi.brightRed("[FAIL] Could not connect to ZernMC server"));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); System.out.println(ZAnsi.white("Error: ") + e.getMessage());
} }
} }
private void checkMojang() { private void checkMojang() {
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang...")); System.out.println(ZAnsi.cyan("Checking Mojang access..."));
try { try {
HttpClient client = HttpClient.newBuilder() HttpClient client = HttpClient.newBuilder()
@@ -77,18 +77,18 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Mojang доступен")); System.out.println(ZAnsi.brightGreen("[OK] Mojang is accessible"));
} else { } else {
System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode())); System.out.println(ZAnsi.brightRed("[FAIL] Mojang returned code " + response.statusCode()));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang")); System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Mojang"));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); System.out.println(ZAnsi.white("Error: ") + e.getMessage());
} }
} }
private void checkFabric() { private void checkFabric() {
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta...")); System.out.println(ZAnsi.cyan("Checking Fabric Meta access..."));
try { try {
HttpClient client = HttpClient.newBuilder() HttpClient client = HttpClient.newBuilder()
@@ -103,18 +103,18 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta доступен")); System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta is accessible"));
} else { } 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) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta")); System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Fabric Meta"));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); System.out.println(ZAnsi.white("Error: ") + e.getMessage());
} }
} }
private void checkForge() { private void checkForge() {
System.out.println(ZAnsi.cyan("Проверка доступа к Forge Maven...")); System.out.println(ZAnsi.cyan("Checking Forge Maven access..."));
try { try {
HttpClient client = HttpClient.newBuilder() HttpClient client = HttpClient.newBuilder()
@@ -129,13 +129,13 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven доступен")); System.out.println(ZAnsi.brightGreen("[OK] Forge Maven is accessible"));
} else { } 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) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Forge Maven")); System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Forge Maven"));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); System.out.println(ZAnsi.white("Error: ") + e.getMessage());
} }
} }
} }
@@ -13,13 +13,13 @@ public class SettingsMenu {
public void show() throws IOException { public void show() throws IOException {
List<String> options = List.of( List<String> options = List.of(
"Настроить путь к Java", "Configure Java path",
"Настроить выделенную память (RAM)", "Configure allocated RAM",
"Дополнительные JVM параметры", "Additional JVM parameters",
"Назад в главное меню" "Back to main menu"
); );
ArrowMenu menu = new ArrowMenu("Настройки лаунчера", options); ArrowMenu menu = new ArrowMenu("Launcher Settings", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == 3) return; if (choice == -1 || choice == 3) return;
@@ -36,33 +36,33 @@ public class SettingsMenu {
} }
private void configureJava() { 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(" " + Config.getJreDir().toAbsolutePath());
System.out.println(ZAnsi.white("\nJava будет искаться автоматически в папке ~/.zernmc/jre/")); System.out.println(ZAnsi.white("\nJava will be searched automatically in ~/.zernmc/jre/"));
System.out.println("Если нужно — положите туда свою версию Java."); System.out.println("If needed, place your own Java version there.");
} }
private void configureRam() { private void configureRam() {
System.out.println(ZAnsi.cyan("Настройка выделенной памяти")); System.out.println(ZAnsi.cyan("RAM Allocation"));
System.out.println(Config.getRamInfo()); System.out.println(Config.getRamInfo());
int newRam = Input.readInt( int newRam = Input.readInt(
ZAnsi.white("\nВведите новое значение RAM в MB (или 0 для отмены): "), ZAnsi.white("\nEnter new RAM value in MB (or 0 to cancel): "),
0, 32768 0, 32768
); );
if (newRam == 0) { if (newRam == 0) {
System.out.println(ZAnsi.yellow("Настройка отменена.")); System.out.println(ZAnsi.yellow("Setting cancelled."));
return; return;
} }
Config.setMaxMemory(newRam); Config.setMaxMemory(newRam);
System.out.println(ZAnsi.brightGreen("Выделенная память изменена на " + newRam + " MB")); System.out.println(ZAnsi.brightGreen("Allocated RAM changed to " + newRam + " MB"));
} }
private void configureJvmArgs() { private void configureJvmArgs() {
System.out.println(ZAnsi.yellow("Дополнительные JVM параметры")); System.out.println(ZAnsi.yellow("Additional JVM parameters"));
System.out.println("Пока в разработке."); System.out.println("Currently in development.");
System.out.println("В будущем здесь будет список предустановленных оптимизаций."); 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 { public void show() throws IOException {
List<String> options = List.of( 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(); int choice = menu.show();
if (choice == -1 || choice == 2) return; if (choice == -1 || choice == 2) return;
@@ -34,7 +34,7 @@ public class UpdateMenu {
try { try {
checkPackUpdates(); checkPackUpdates();
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage())); System.out.println(ZAnsi.brightRed("Error: " + e.getMessage()));
e.printStackTrace(); e.printStackTrace();
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
@@ -44,7 +44,7 @@ public class UpdateMenu {
} }
private void checkPackUpdates() throws Exception { 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> instances = InstanceManager.getAllInstances();
List<Instance> serverInstances = instances.stream() List<Instance> serverInstances = instances.stream()
@@ -52,12 +52,12 @@ public class UpdateMenu {
.collect(Collectors.toList()); .collect(Collectors.toList());
if (serverInstances.isEmpty()) { if (serverInstances.isEmpty()) {
System.out.println(ZAnsi.yellow("Нет сборок, установленных с сервера.")); System.out.println(ZAnsi.yellow("No server-installed packs found."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
System.out.println(ZAnsi.cyan("\nПроверка обновлений для серверных сборок:\n")); System.out.println(ZAnsi.cyan("\nChecking updates for server packs:\n"));
boolean hasUpdates = false; boolean hasUpdates = false;
List<Instance> updatableInstances = new ArrayList<>(); List<Instance> updatableInstances = new ArrayList<>();
@@ -68,42 +68,41 @@ public class UpdateMenu {
try { try {
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
if (hasUpdate) { if (hasUpdate) {
System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!")); System.out.println(ZAnsi.yellow(instance.getName() + " - Update available!"));
updatableInstances.add(instance); updatableInstances.add(instance);
hasUpdates = true; hasUpdates = true;
} else { } else {
System.out.println(ZAnsi.green(instance.getName() + " - Актуальна")); System.out.println(ZAnsi.green(instance.getName() + " - Up to date"));
} }
} catch (Exception e) { } 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) { if (!hasUpdates) {
System.out.println(ZAnsi.green("\nВсе сборки актуальны!")); System.out.println(ZAnsi.green("\nAll packs are up to date!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
// Предлагаем обновить каждую сборку отдельно
for (Instance instance : updatableInstances) { for (Instance instance : updatableInstances) {
System.out.println(ZAnsi.brightYellow("\nОбновить сборку '" + instance.getName() + "'?")); System.out.println(ZAnsi.brightYellow("\nUpdate pack '" + instance.getName() + "'?"));
if (Input.confirm("Обновить")) { if (Input.confirm("Update")) {
System.out.println(ZAnsi.cyan("Обновление " + instance.getName() + "...")); System.out.println(ZAnsi.cyan("Updating " + instance.getName() + "..."));
PackDownloader downloader = new PackDownloader(instance); PackDownloader downloader = new PackDownloader(instance);
try { try {
boolean success = downloader.updatePack(instance.getServerPackName()); boolean success = downloader.updatePack(instance.getServerPackName());
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen(instance.getName() + " обновлен")); System.out.println(ZAnsi.brightGreen(instance.getName() + " updated"));
} else { } else {
System.out.println(ZAnsi.brightRed(instance.getName() + " не удалось обновить")); System.out.println(ZAnsi.brightRed(instance.getName() + " update failed"));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage())); System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage()));
} }
} else { } 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() { private void checkLauncherUpdates() {
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера...")); System.out.println(ZAnsi.cyan("Checking launcher updates..."));
try { try {
String json = ZHttpClient.getLauncherVersionInfo(); String json = ZHttpClient.getLauncherVersionInfo();
String serverVersion = extractVersion(json); String serverVersion = extractVersion(json);
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion(); String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
System.out.println(ZAnsi.white("Текущая версия: ") + currentVersion); System.out.println(ZAnsi.white("Current version: ") + currentVersion);
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion); System.out.println(ZAnsi.white("Server version: ") + serverVersion);
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) { if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия!")); System.out.println(ZAnsi.brightYellow("\nNew version available!"));
if (Input.confirm("Обновить лаунчер?")) { if (Input.confirm("Update launcher?")) {
// Обновление будет при следующем запуске System.out.println(ZAnsi.green("Launcher will be updated on next restart."));
System.out.println(ZAnsi.green("Лаунчер будет обновлен при следующем запуске."));
} }
} else { } else {
System.out.println(ZAnsi.brightGreen("Лаунчер актуален.")); System.out.println(ZAnsi.brightGreen("Launcher is up to date."));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера.")); System.out.println(ZAnsi.yellow("Could not check launcher updates."));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); System.out.println(ZAnsi.white("Error: ") + e.getMessage());
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -149,4 +147,4 @@ public class UpdateMenu {
return "unknown"; return "unknown";
} }
} }
} }
@@ -56,7 +56,7 @@ public class MinecraftLib {
boolean success = installer.install(minecraftVersion, loaderVersion); boolean success = installer.install(minecraftVersion, loaderVersion);
if (success) { if (success) {
// Сохраняем информацию в Instance // Save info to Instance
instance.setMinecraftVersion(minecraftVersion); instance.setMinecraftVersion(minecraftVersion);
instance.setLoaderType("fabric"); instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVersion); 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 { 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); boolean mcInstalled = installMinecraft(minecraftVersion);
if (!mcInstalled) { if (!mcInstalled) {
System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion)); System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion));
return false; return false;
} }
// 2. Устанавливаем лоадер // 2. Install loader
if ("fabric".equalsIgnoreCase(loaderType)) { if ("fabric".equalsIgnoreCase(loaderType)) {
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion); boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
if (!fabricInstalled) { if (!fabricInstalled) {
System.out.println(ZAnsi.brightRed("Не удалось установить Fabric")); System.out.println(ZAnsi.brightRed("Failed to install Fabric"));
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(loaderType)) { } else if ("forge".equalsIgnoreCase(loaderType)) {
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion); boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
if (!forgeInstalled) { if (!forgeInstalled) {
System.out.println(ZAnsi.brightRed("Не удалось установить Forge")); System.out.println(ZAnsi.brightRed("Failed to install Forge"));
return false; return false;
} }
} else if ("neoforge".equalsIgnoreCase(loaderType)) { } else if ("neoforge".equalsIgnoreCase(loaderType)) {
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion); boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
if (!neoforgeInstalled) { if (!neoforgeInstalled) {
System.out.println(ZAnsi.brightRed("Не удалось установить NeoForge")); System.out.println(ZAnsi.brightRed("Failed to install NeoForge"));
return false; 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; return true;
} }
//Запуск //Launch
public void launch(LaunchOptions options) throws Exception { public void launch(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName())); System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
cleanupOldLoaders(); cleanupOldLoaders();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options); 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)); command.forEach(arg -> System.out.println(" " + arg));
ProcessBuilder pb = new ProcessBuilder(command); ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(instance.getPath().toFile()); pb.directory(instance.getPath().toFile());
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n")); System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
Process process = pb.start(); Process process = pb.start();
@@ -132,7 +132,7 @@ public class MinecraftLib {
JFXLauncher.appendGameLog(line); JFXLauncher.appendGameLog(line);
} }
} catch (Exception e) { } catch (Exception e) {
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage() + "]"); JFXLauncher.appendGameLog("[Error reading output: " + e.getMessage() + "]");
} }
}); });
outThread.setDaemon(true); outThread.setDaemon(true);
@@ -146,7 +146,7 @@ public class MinecraftLib {
JFXLauncher.appendGameLog("[ERR] " + line); JFXLauncher.appendGameLog("[ERR] " + line);
} }
} catch (Exception e) { } catch (Exception e) {
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage() + "]"); JFXLauncher.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
} }
}); });
errThread.setDaemon(true); errThread.setDaemon(true);
@@ -156,7 +156,7 @@ public class MinecraftLib {
outThread.join(1000); outThread.join(1000);
errThread.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) { private void safeDeleteDirectory(Path dir) {
@@ -202,9 +202,9 @@ public class MinecraftLib {
if (currentLoaderVer == null) return; 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"); Path libraries = instance.getPath().resolve("libraries");
if ("fabric".equals(loaderType)) { if ("fabric".equals(loaderType)) {
@@ -36,18 +36,18 @@ public class PackDownloader {
} }
/** /**
* Получить список доступных паков с сервера * Get list of available packs from server
*/ */
public List<ServerPack> getAvailablePacks() throws Exception { public List<ServerPack> getAvailablePacks() throws Exception {
String accessToken = AuthManager.getAccessToken(); String accessToken = AuthManager.getAccessToken();
if (accessToken == null) { if (accessToken == null) {
throw new IOException("Не авторизован. Требуется проходка для просмотра сборок."); throw new IOException("Not authenticated. Active pass required to view packs.");
} }
if (!AuthManager.canViewPacks()) { 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; java.net.HttpURLConnection connection = null;
try { try {
java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs"); java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs");
@@ -61,7 +61,7 @@ public class PackDownloader {
int responseCode = connection.getResponseCode(); int responseCode = connection.getResponseCode();
if (responseCode == 403) { if (responseCode == 403) {
throw new IOException("Для просмотра сборок требуется активная проходка"); throw new IOException("Active pass required to view packs");
} }
StringBuilder response = new StringBuilder(); StringBuilder response = new StringBuilder();
@@ -118,7 +118,7 @@ public class PackDownloader {
result.add(new ServerPack(name, version, minecraftVersion, loaderType, result.add(new ServerPack(name, version, minecraftVersion, loaderType,
loaderVersion, updatedAt, filesCount)); loaderVersion, updatedAt, filesCount));
} catch (Exception e) { } 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 { public PackManifest getPackManifest(String packName) throws Exception {
String response = ZHttpClient.get("/pack/" + packName); 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 { 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); PackManifest manifest = getPackManifest(packName);
// 2. Сначала устанавливаем Minecraft + Loader через MinecraftLib // 2. First install Minecraft + Loader via MinecraftLib
MinecraftLib lib = new MinecraftLib(instance); 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 || boolean needsMinecraftInstall = instance.getMinecraftVersion() == null ||
!instance.getMinecraftVersion().equals(manifest.getMinecraftVersion()); !instance.getMinecraftVersion().equals(manifest.getMinecraftVersion());
@@ -154,40 +154,40 @@ public class PackDownloader {
if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) { if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Не удалось установить Fabric")); System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
return false; return false;
} }
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) { } else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Не удалось установить NeoForge")); System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Не удалось установить Forge")); System.err.println(ZAnsi.brightRed("Failed to install Forge"));
return false; return false;
} }
} else { } else {
boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Не удалось установить Vanilla Minecraft")); System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
return false; return false;
} }
} }
} else { } 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(); 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()) { 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.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
@@ -196,19 +196,19 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(manifest.getAssetIndex()); instance.setAssetIndex(manifest.getAssetIndex());
System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!")); System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
return true; return true;
} }
// 4. Отправляем diff запрос // 4. Send diff request
System.out.println(ZAnsi.cyan("Проверка файлов сборки...")); System.out.println(ZAnsi.cyan("Checking pack files..."));
DiffResponse diff = getDiff(packName, localFiles); DiffResponse diff = getDiff(packName, localFiles);
// 5. Применяем изменения // 5. Apply changes
boolean success = applyDiff(diff, packName); boolean success = applyDiff(diff, packName);
if (success) { if (success) {
// 6. Обновляем метаданные инстанса // 6. Update instance metadata
instance.setServerPack(true); instance.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
@@ -217,14 +217,14 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(manifest.getAssetIndex()); instance.setAssetIndex(manifest.getAssetIndex());
System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!")); System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
} }
return success; return success;
} }
/** /**
* Проверить наличие обновлений для серверной сборки * Check for server pack updates
*/ */
public boolean checkForUpdates(String packName) throws Exception { public boolean checkForUpdates(String packName) throws Exception {
if (!instance.isServerPack()) return false; if (!instance.isServerPack()) return false;
@@ -237,40 +237,40 @@ public class PackDownloader {
} }
/** /**
* Обновить существующую серверную сборку * Update an existing server pack
*/ */
public boolean updatePack(String packName) throws Exception { 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); PackManifest manifest = getPackManifest(packName);
int serverVersion = manifest.getVersion(); int serverVersion = manifest.getVersion();
if (serverVersion <= instance.getServerVersion()) { 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; 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(); Map<String, String> localFiles = scanLocalFiles();
// Получаем diff // Get diff
DiffResponse diff = getDiff(packName, localFiles); DiffResponse diff = getDiff(packName, localFiles);
// Применяем изменения // Apply changes
boolean success = applyDiff(diff, packName); boolean success = applyDiff(diff, packName);
if (success) { if (success) {
instance.setServerVersion(serverVersion); instance.setServerVersion(serverVersion);
System.out.println(ZAnsi.brightGreen("Сборка обновлена до v" + serverVersion)); System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
} }
return success; return success;
} }
/** /**
* Сканирование локальных файлов и вычисление хешей * Scan local files and compute hashes
*/ */
private Map<String, String> scanLocalFiles() throws IOException { private Map<String, String> scanLocalFiles() throws IOException {
Map<String, String> files = new HashMap<>(); Map<String, String> files = new HashMap<>();
@@ -312,23 +312,23 @@ public class PackDownloader {
} }
/** /**
* Отправить diff запрос на сервер * Send diff request to server
*/ */
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception { private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
String json = gson.toJson(localFiles); String json = gson.toJson(localFiles);
// Получаем токен авторизации // Get auth token
String accessToken = AuthManager.getAccessToken(); String accessToken = AuthManager.getAccessToken();
if (accessToken == null) { if (accessToken == null) {
throw new IOException("Не авторизован. Требуется проходка для скачивания сборок."); throw new IOException("Not authenticated. Active pass required to download packs.");
} }
if (!AuthManager.canDownloadPacks()) { if (!AuthManager.canDownloadPacks()) {
throw new IOException("Для скачивания сборок требуется активная проходка"); throw new IOException("Active pass required to download packs");
} }
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff"; String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
// Используем HttpURLConnection для полного контроля // Use HttpURLConnection for full control
java.net.HttpURLConnection connection = null; java.net.HttpURLConnection connection = null;
try { try {
java.net.URL urlObj = new java.net.URL(url); java.net.URL urlObj = new java.net.URL(url);
@@ -342,7 +342,7 @@ public class PackDownloader {
connection.setConnectTimeout(30000); connection.setConnectTimeout(30000);
connection.setReadTimeout(30000); connection.setReadTimeout(30000);
// Отправляем JSON // Send JSON
try (java.io.OutputStream os = connection.getOutputStream()) { try (java.io.OutputStream os = connection.getOutputStream()) {
byte[] input = json.getBytes("UTF-8"); byte[] input = json.getBytes("UTF-8");
os.write(input, 0, input.length); os.write(input, 0, input.length);
@@ -351,7 +351,7 @@ public class PackDownloader {
int responseCode = connection.getResponseCode(); int responseCode = connection.getResponseCode();
// Читаем ответ // Read response
StringBuilder response = new StringBuilder(); StringBuilder response = new StringBuilder();
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) { 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(); String responseBody = response.toString();
if (responseCode == 403) { if (responseCode == 403) {
throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору."); throw new IOException("Active pass required to download packs. Contact the administrator.");
} }
if (responseCode != 200) { 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) { private boolean applyDiff(DiffResponse diff, String packName) {
System.out.println(ZAnsi.cyan("\nПрименение изменений:")); System.out.println(ZAnsi.cyan("\nApplying changes:"));
System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов"); System.out.println(" Download: " + diff.getToDownload().size() + " files");
System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов"); System.out.println(" Delete: " + diff.getToDelete().size() + " files");
// Создаем директории если нужно // Create directories if needed
try { try {
Files.createDirectories(instance.getPath()); Files.createDirectories(instance.getPath());
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage())); System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage()));
return false; return false;
} }
// Удаляем файлы // Delete files
for (String filePath : diff.getToDelete()) { for (String filePath : diff.getToDelete()) {
Path fullPath = instance.getPath().resolve(filePath); Path fullPath = instance.getPath().resolve(filePath);
try { try {
if (Files.deleteIfExists(fullPath)) { if (Files.deleteIfExists(fullPath)) {
System.out.println(ZAnsi.yellow(" Удален: " + filePath)); System.out.println(ZAnsi.yellow(" Deleted: " + filePath));
} }
} catch (IOException e) { } 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); AtomicInteger downloaded = new AtomicInteger(0);
int total = diff.getToDownload().size(); int total = diff.getToDownload().size();
@@ -427,32 +427,32 @@ public class PackDownloader {
Path fullPath = instance.getPath().resolve(path); Path fullPath = instance.getPath().resolve(path);
try { try {
// Создаем директории // Create directories
Files.createDirectories(fullPath.getParent()); Files.createDirectories(fullPath.getParent());
// Скачиваем файл // Download file
downloadFile(file, fullPath); downloadFile(file, fullPath);
// Проверяем хеш // Verify hash
String actualHash = calculateHash(fullPath); String actualHash = calculateHash(fullPath);
if (!actualHash.equals(file.getHash())) { if (!actualHash.equals(file.getHash())) {
throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() + throw new IOException("Hash mismatch! Expected: " + file.getHash() +
", получен: " + actualHash); ", got: " + actualHash);
} }
downloaded.incrementAndGet(); downloaded.incrementAndGet();
if (total > 0) { if (total > 0) {
ProgressBar.show("Скачивание", downloaded.get(), total, "файлов"); ProgressBar.show("Download", downloaded.get(), total, "files");
} }
} catch (Exception e) { } 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; return false;
} }
} }
if (total > 0) { if (total > 0) {
ProgressBar.finish("Скачивание"); ProgressBar.finish("Download");
} }
return true; return true;
@@ -26,7 +26,7 @@ public class FabricInstaller {
} }
public boolean install(String minecraftVersion, String loaderVersion) throws Exception { 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(); Path instancePath = instance.getPath();
cleanOldFabricLoaders(); cleanOldFabricLoaders();
@@ -34,7 +34,7 @@ public class FabricInstaller {
VersionInstaller versionInstaller = new VersionInstaller(instancePath); VersionInstaller versionInstaller = new VersionInstaller(instancePath);
String assetIndex = versionInstaller.install(minecraftVersion); 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.setAssetIndex(assetIndex);
instance.setMinecraftVersion(minecraftVersion); instance.setMinecraftVersion(minecraftVersion);
@@ -46,12 +46,12 @@ public class FabricInstaller {
Path installerJar = instancePath.resolve("fabric-installer.jar"); Path installerJar = instancePath.resolve("fabric-installer.jar");
if (!Files.exists(installerJar)) { if (!Files.exists(installerJar)) {
ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%"); ProgressBar.show("Downloading Fabric Installer", 0, 100, "%");
downloadFileWithFallback(installerUrl, installerJar); 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; String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
@@ -71,24 +71,24 @@ public class FabricInstaller {
int exitCode = process.waitFor(); int exitCode = process.waitFor();
if (exitCode != 0) { if (exitCode != 0) {
System.out.println(ZAnsi.brightRed("Fabric Installer завершился с ошибкой (код " + exitCode + ")")); System.out.println(ZAnsi.brightRed("Fabric Installer failed (code " + exitCode + ")"));
return false; return false;
} }
Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId); Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId);
if (Files.exists(fabricVersionDir)) { if (Files.exists(fabricVersionDir)) {
System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!")); System.out.println(ZAnsi.brightGreen("Fabric installed successfully!"));
instance.setLoaderType("fabric"); instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVersion); instance.setLoaderVersion(loaderVersion);
instance.setFabricVersionId(fabricVersionId); // ← СОХРАНЯЕМ instance.setFabricVersionId(fabricVersionId);
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex); ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
return true; return true;
} else { } else {
System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена.")); System.out.println(ZAnsi.brightRed("Fabric Installer ran, but version not found."));
return false; return false;
} }
} }
@@ -97,7 +97,7 @@ public class FabricInstaller {
try { try {
ZHttpClient.downloadFile(url, target); ZHttpClient.downloadFile(url, target);
} catch (Exception e) { } 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; throw e;
} }
} }
@@ -106,28 +106,28 @@ public class FabricInstaller {
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json"); Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
if (!Files.exists(versionJson)) { if (!Files.exists(versionJson)) {
System.out.println(ZAnsi.yellow("JSON файл версии не найден: " + versionJson)); System.out.println(ZAnsi.yellow("Version JSON file not found: " + versionJson));
return; return;
} }
String content = Files.readString(versionJson); String content = Files.readString(versionJson);
// Проверяем и исправляем asset index // Check and fix asset index
if (!content.contains("\"assets\":\"" + assetIndex + "\"")) { 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 + "\""); content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\"");
// Также проверяем assetIndex // Also check assetIndex
if (content.contains("\"assetIndex\"")) { if (content.contains("\"assetIndex\"")) {
content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\""); content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\"");
} }
Files.writeString(versionJson, content); Files.writeString(versionJson, content);
System.out.println(ZAnsi.green("Asset index исправлен на: " + assetIndex)); System.out.println(ZAnsi.green("Asset index fixed to: " + assetIndex));
} else { } 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"); Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader");
if (!Files.exists(librariesDir)) return; 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)) { try (var stream = Files.walk(librariesDir)) {
stream.filter(Files::isDirectory) stream.filter(Files::isDirectory)
@@ -155,18 +155,18 @@ public class FabricInstaller {
private String getLatestInstallerVersion() throws Exception { private String getLatestInstallerVersion() throws Exception {
try { try {
// Используем ZHttpClient с умным прокси // Use ZHttpClient with smart proxy
String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml"); String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml");
int start = xml.indexOf("<latest>") + 8; int start = xml.indexOf("<latest>") + 8;
int end = xml.indexOf("</latest>", start); int end = xml.indexOf("</latest>", start);
return xml.substring(start, end).trim(); return xml.substring(start, end).trim();
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Ошибка получения версии Fabric Installer: " + e.getMessage())); System.out.println(ZAnsi.yellow("Error getting Fabric Installer version: " + e.getMessage()));
throw new Exception("Не удалось получить версию Fabric Installer", e); throw new Exception("Failed to get Fabric Installer version", e);
} }
} }
// под рефактор оставить // under refactor - keep
private String downloadString(String url) throws Exception { private String downloadString(String url) throws Exception {
Exception lastException = null; Exception lastException = null;
@@ -186,7 +186,7 @@ public class FabricInstaller {
throw new IOException("HTTP " + resp.statusCode()); throw new IOException("HTTP " + resp.statusCode());
} catch (Exception e) { } catch (Exception e) {
lastException = e; lastException = e;
System.out.println(ZAnsi.yellow("Попытка " + attempt + " не удалась: " + e.getMessage())); System.out.println(ZAnsi.yellow("Attempt " + attempt + " failed: " + e.getMessage()));
if (attempt < 3) { if (attempt < 3) {
Thread.sleep(1000 * attempt); Thread.sleep(1000 * attempt);
} }
@@ -207,7 +207,7 @@ public class FabricInstaller {
HttpResponse.BodyHandlers.ofFile(target)); HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) { 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 { 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 // Step 1: Install vanilla and get real assetIndex
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "...")); System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "..."));
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
String assetIndex = vanillaInstaller.install(mcVersion); String assetIndex = vanillaInstaller.install(mcVersion);
if (assetIndex == null || assetIndex.isEmpty()) { if (assetIndex == null || assetIndex.isEmpty()) {
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft")); System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
return false; return false;
} }
instance.setAssetIndex(assetIndex); instance.setAssetIndex(assetIndex);
// Шаг 2: Создаём launcher_profiles.json // Step 2: Create launcher_profiles.json
createLauncherProfile(); createLauncherProfile();
// Шаг 3: Скачиваем Forge Installer с прогресс-баром // Step 3: Download Forge Installer with progress bar
String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/" String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/"
+ mcVersion + "-" + forgeVersion + mcVersion + "-" + forgeVersion
+ "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar"; + "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar";
Path installerJar = instance.getPath().resolve("forge-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); downloadFileWithProgress(installerUrl, installerJar);
// Шаг 4: Запускаем Forge Installer и показываем его вывод // Step 4: Run Forge Installer and show its output
System.out.println(ZAnsi.cyan("Запуск Forge Installer...")); System.out.println(ZAnsi.cyan("Running Forge Installer..."));
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n")); System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
boolean success = runForgeInstaller(installerJar); boolean success = runForgeInstaller(installerJar);
// После успешной установки Forge, но перед сохранением метаданных // After successful Forge install, before saving metadata
if (success) { if (success) {
// Докачиваем пропущенные библиотеки // Download missing libraries
try { try {
downloadMissingLibraries(mcVersion, forgeVersion); downloadMissingLibraries(mcVersion, forgeVersion);
} catch (Exception e) { } 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.setMinecraftVersion(mcVersion);
instance.setLoaderType("forge"); instance.setLoaderType("forge");
instance.setLoaderVersion(forgeVersion); instance.setLoaderVersion(forgeVersion);
// Очищаем временный файл установщика // Clean up temporary installer file
Files.deleteIfExists(installerJar); Files.deleteIfExists(installerJar);
return true; return true;
} else { } else {
System.out.println(ZAnsi.brightRed("\nОшибка при установке Forge!")); System.out.println(ZAnsi.brightRed("\nError installing Forge!"));
return false; return false;
} }
} }
@@ -94,7 +94,7 @@ public class ForgeInstaller {
} }
"""; """;
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); 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 { private void downloadFileWithProgress(String url, Path target) throws Exception {
@@ -132,10 +132,10 @@ public class ForgeInstaller {
lastPercent = percent; lastPercent = percent;
} }
} else { } else {
// Если размер неизвестен, показываем анимацию // If size unknown, show animation
char[] spinner = {'|', '/', '-', '\\'}; char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4; 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 { private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException {
// Пробуем до 3 раз с разными опциями // Try up to 3 times with different options
int maxRetries = 3; int maxRetries = 3;
int attempt = 1; int attempt = 1;
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries)); System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
"java", "java",
@@ -158,7 +158,7 @@ public class ForgeInstaller {
"--installClient" "--installClient"
); );
// Добавляем JVM аргументы для увеличения таймаутов // Add JVM args for increased timeouts
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
pb.directory(instance.getPath().toFile()); pb.directory(instance.getPath().toFile());
@@ -166,7 +166,7 @@ public class ForgeInstaller {
Process process = pb.start(); Process process = pb.start();
// Читаем вывод в реальном времени // Read output in real time
StringBuilder output = new StringBuilder(); StringBuilder output = new StringBuilder();
boolean hasErrors = false; boolean hasErrors = false;
@@ -175,7 +175,7 @@ public class ForgeInstaller {
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
output.append(line).append("\n"); output.append(line).append("\n");
// Форматируем вывод Forge Installer // Format Forge Installer output
if (line.contains("Downloading") || line.contains("Extracting")) { if (line.contains("Downloading") || line.contains("Extracting")) {
System.out.println(ZAnsi.blue(" -> " + line)); System.out.println(ZAnsi.blue(" -> " + line));
} else if (line.contains("SUCCESS") || line.contains("successfully")) { } else if (line.contains("SUCCESS") || line.contains("successfully")) {
@@ -195,17 +195,17 @@ public class ForgeInstaller {
int exitCode = process.waitFor(); int exitCode = process.waitFor();
// Если успешно или нет ошибок скачивания // If successful or no download errors
if (exitCode == 0 && !hasErrors) { if (exitCode == 0 && !hasErrors) {
return true; return true;
} }
// Если ошибка и это не последняя попытка // If error and not last attempt
if (attempt < maxRetries) { if (attempt < maxRetries) {
System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд...")); System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
Thread.sleep(5000); Thread.sleep(5000);
// Очищаем временные файлы перед повтором // Clean temp files before retry
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) { if (Files.exists(librariesDir)) {
// Удаляем только частично скачанные библиотеки Forge // Удаляем только частично скачанные библиотеки Forge
@@ -218,15 +218,15 @@ public class ForgeInstaller {
} }
} }
} else { } 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")) { if (output.toString().contains("timed out")) {
System.out.println(ZAnsi.yellow("\nВозможные решения:")); System.out.println(ZAnsi.yellow("\nPossible solutions:"));
System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение")); System.out.println(ZAnsi.yellow("1. Check your internet connection"));
System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора")); System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр")); System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию Forge")); 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 { 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<>(); Map<String, String> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", 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"); "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()); Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) { if (!Files.exists(target)) {
Files.createDirectories(target.getParent()); 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++) { for (int attempt = 1; attempt <= 3; attempt++) {
try { try {
@@ -27,14 +27,14 @@ public class NeoForgeInstaller {
} }
public boolean install(String mcVersion, String neoForgeVersion) throws Exception { 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()); VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
String assetIndex = vanillaInstaller.install(mcVersion); String assetIndex = vanillaInstaller.install(mcVersion);
if (assetIndex == null || assetIndex.isEmpty()) { if (assetIndex == null || assetIndex.isEmpty()) {
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft")); System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
return false; return false;
} }
@@ -52,11 +52,11 @@ public class NeoForgeInstaller {
Path installerJar = instance.getPath().resolve("neoforge-installer.jar"); 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); downloadFileWithProgress(installerUrl, installerJar);
System.out.println(ZAnsi.cyan("Запуск NeoForge Installer...")); System.out.println(ZAnsi.cyan("Running NeoForge Installer..."));
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n")); System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
boolean success = runNeoForgeInstaller(installerJar); boolean success = runNeoForgeInstaller(installerJar);
@@ -64,10 +64,10 @@ public class NeoForgeInstaller {
try { try {
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact); downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
} catch (Exception e) { } 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.setMinecraftVersion(mcVersion);
instance.setLoaderType("neoforge"); instance.setLoaderType("neoforge");
instance.setLoaderVersion(neoForgeVersion); instance.setLoaderVersion(neoForgeVersion);
@@ -75,7 +75,7 @@ public class NeoForgeInstaller {
Files.deleteIfExists(installerJar); Files.deleteIfExists(installerJar);
return true; return true;
} else { } else {
System.out.println(ZAnsi.brightRed("\nОшибка при установке NeoForge!")); System.out.println(ZAnsi.brightRed("\nError installing NeoForge!"));
return false; return false;
} }
} }
@@ -105,7 +105,7 @@ public class NeoForgeInstaller {
} }
"""; """;
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); 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 { private void downloadFileWithProgress(String url, Path target) throws Exception {
@@ -145,7 +145,7 @@ public class NeoForgeInstaller {
} else { } else {
char[] spinner = {'|', '/', '-', '\\'}; char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4; 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; int attempt = 1;
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries)); System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
"java", "java",
@@ -205,7 +205,7 @@ public class NeoForgeInstaller {
} }
if (attempt < maxRetries) { if (attempt < maxRetries) {
System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд...")); System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
Thread.sleep(5000); Thread.sleep(5000);
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
@@ -219,14 +219,14 @@ public class NeoForgeInstaller {
} }
} }
} else { } 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")) { if (output.toString().contains("timed out")) {
System.out.println(ZAnsi.yellow("\nВозможные решения:")); System.out.println(ZAnsi.yellow("\nPossible solutions:"));
System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение")); System.out.println(ZAnsi.yellow("1. Check your internet connection"));
System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора")); System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр")); System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию NeoForge")); 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 { 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<>(); Map<String, String> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", 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()); Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) { if (!Files.exists(target)) {
Files.createDirectories(target.getParent()); 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++) { for (int attempt = 1; attempt <= 3; attempt++) {
try { try {
@@ -261,7 +261,7 @@ public class NeoForgeInstaller {
break; break;
} catch (Exception e) { } catch (Exception e) {
if (attempt == 3) throw e; if (attempt == 3) throw e;
System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3...")); System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
Thread.sleep(2000); Thread.sleep(2000);
} }
} }
@@ -57,12 +57,12 @@ public class VersionInstaller {
} }
public String install(String versionId) throws Exception { 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); Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
Files.createDirectories(versionDir); Files.createDirectories(versionDir);
String versionUrl = getVersionUrl(versionId); 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); String versionJson = downloadString(versionUrl);
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson); Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
@@ -73,8 +73,8 @@ public class VersionInstaller {
downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"), downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
versionDir.resolve(versionId + ".jar"), "client.jar"); versionDir.resolve(versionId + ".jar"), "client.jar");
// Библиотеки // Libraries
System.out.println(ZAnsi.cyan("Скачивание библиотек...")); System.out.println(ZAnsi.cyan("Downloading libraries..."));
downloadLibraries(versionData.getJSONArray("libraries")); downloadLibraries(versionData.getJSONArray("libraries"));
String assetIndex; String assetIndex;
@@ -86,12 +86,12 @@ public class VersionInstaller {
System.out.println(ZAnsi.cyan("Asset index: " + assetIndex)); System.out.println(ZAnsi.cyan("Asset index: " + assetIndex));
// Скачиваем ассеты используя правильный индекс // Download assets using correct index
System.out.println(ZAnsi.cyan("Скачивание ассетов...")); System.out.println(ZAnsi.cyan("Downloading assets..."));
downloadAssets(versionData, assetIndex); downloadAssets(versionData, assetIndex);
System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " полностью установлен!")); System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " fully installed!"));
return assetIndex; // ← возвращаем "5" а не "1.20.1" return assetIndex;
} }
private void downloadLibraries(JSONArray libraries) throws Exception { private void downloadLibraries(JSONArray libraries) throws Exception {
@@ -111,32 +111,32 @@ public class VersionInstaller {
try { try {
downloadFile(url, target, "library"); downloadFile(url, target, "library");
} catch (Exception e) { } catch (Exception e) {
// Пропускаем проблемные библиотеки // Skip problematic libraries
} }
} }
count++; 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 { private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception {
// Находим URL для asset index // Find URL for asset index
JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex"); JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex");
String indexUrl = assetIndexInfo.getString("url"); String indexUrl = assetIndexInfo.getString("url");
Path indexesDir = minecraftDir.resolve("assets/indexes"); Path indexesDir = minecraftDir.resolve("assets/indexes");
Files.createDirectories(indexesDir); 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"); downloadFile(indexUrl, indexPath, "asset index");
String jsonContent = Files.readString(indexPath); String jsonContent = Files.readString(indexPath);
JSONObject root = new JSONObject(jsonContent); JSONObject root = new JSONObject(jsonContent);
JSONObject objects = root.getJSONObject("objects"); 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 total = objects.length();
int[] success = {0}; int[] success = {0};
@@ -146,7 +146,7 @@ public class VersionInstaller {
for (String key : objects.keySet()) { for (String key : objects.keySet()) {
JSONObject asset = objects.getJSONObject(key); 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; String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash;
Path target = minecraftDir.resolve("assets/objects") Path target = minecraftDir.resolve("assets/objects")
@@ -160,19 +160,19 @@ public class VersionInstaller {
for (int attempt = 1; attempt <= 3; attempt++) { for (int attempt = 1; attempt <= 3; attempt++) {
try { try {
downloadFile(url, target, ""); downloadFile(url, target, "");
synchronized (this) {
success[0]++;
ProgressBar.show("Ассеты", success[0], total, "файлов");
}
downloaded = true;
break;
} catch (Exception e) {
if (attempt == 3) {
synchronized (this) { synchronized (this) {
failed[0]++; success[0]++;
ProgressBar.show("Assets", success[0], total, "files");
} }
System.err.println("Не удалось скачать " + hash); downloaded = true;
} else { break;
} catch (Exception e) {
if (attempt == 3) {
synchronized (this) {
failed[0]++;
}
System.err.println("Failed to download " + hash);
} else {
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {} try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
} }
} }
@@ -184,17 +184,17 @@ public class VersionInstaller {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); 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) { if (failed[0] > 0) {
System.out.println(ZAnsi.yellow("Предупреждение: " + failed[0] + " файлов ассетов не удалось скачать.")); System.out.println(ZAnsi.yellow("Warning: " + failed[0] + " asset files could not be downloaded."));
System.out.println(ZAnsi.yellow("Игра запустится, но некоторые текстуры/звуки могут отсутствовать.")); System.out.println(ZAnsi.yellow("The game will launch, but some textures/sounds may be missing."));
} }
} }
public String getAssetIndexId(String versionId) throws Exception { public String getAssetIndexId(String versionId) throws Exception {
String versionUrl = getVersionUrl(versionId); String versionUrl = getVersionUrl(versionId);
if (versionUrl == null) throw new Exception("Версия не найдена"); if (versionUrl == null) throw new Exception("Version not found");
String versionJson = downloadString(versionUrl); String versionJson = downloadString(versionUrl);
JSONObject versionData = new JSONObject(versionJson); JSONObject versionData = new JSONObject(versionJson);
@@ -202,7 +202,7 @@ public class VersionInstaller {
if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) { if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) {
return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1 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 { 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 { private void downloadFile(String url, Path target, String label) throws Exception {
if (!label.isEmpty()) { if (!label.isEmpty()) {
ProgressBar.clearLine(); ProgressBar.clearLine();
System.out.println(ZAnsi.cyan("Скачивание " + label + "...")); System.out.println(ZAnsi.cyan("Downloading " + label + "..."));
} }
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
@@ -233,8 +233,8 @@ public class VersionInstaller {
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target)); HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
if (label.isEmpty()) return; // для ассетов молча if (label.isEmpty()) return; // for assets silently
throw new IOException("HTTP " + response.statusCode() + " при скачивании " + label); throw new IOException("HTTP " + response.statusCode() + " while downloading " + label);
} }
if (!label.isEmpty()) { if (!label.isEmpty()) {
@@ -21,11 +21,12 @@ public class LaunchCommandBuilder {
} }
public List<String> build(LaunchOptions options) throws Exception { 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<>(); List<String> command = new ArrayList<>();
String javaPath = "java"; String javaPath = options.getJavaPath() != null && !options.getJavaPath().isEmpty()
? options.getJavaPath() : "java";
command.add(javaPath); command.add(javaPath);
command.addAll(getJvmArguments(options)); command.addAll(getJvmArguments(options));
@@ -53,7 +54,7 @@ public class LaunchCommandBuilder {
// Fallback if classpath is empty // Fallback if classpath is empty
if (classpath.isEmpty() || classpath.equals(instance.getPath().resolve("versions").resolve(getVersionId()).resolve(getVersionId() + ".jar").toAbsolutePath().toString())) { 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("-cp");
command.add(buildVanillaClasspath()); command.add(buildVanillaClasspath());
command.add(getVanillaMainClass()); command.add(getVanillaMainClass());
@@ -83,15 +84,15 @@ public class LaunchCommandBuilder {
if (versionJson != null && Files.exists(versionJson)) { if (versionJson != null && Files.exists(versionJson)) {
String content = Files.readString(versionJson); String content = Files.readString(versionJson);
JSONObject json = new JSONObject(content); 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); return new VersionManifest(json);
} else { } 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(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
System.out.println(ZAnsi.yellow(" path=" + instance.getPath())); System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage())); System.out.println(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
} }
return null; return null;
} }
@@ -251,9 +252,9 @@ public class LaunchCommandBuilder {
String assetIndex = instance.getAssetIndex(); String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) { if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion(); assetIndex = instance.getMinecraftVersion();
System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex)); System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
} else { } else {
System.out.println(ZAnsi.green("Использую asset index: " + assetIndex)); System.out.println(ZAnsi.green("Using asset index: " + assetIndex));
} }
args.add(assetIndex); args.add(assetIndex);
args.add("--username"); args.add("--username");
@@ -332,7 +333,7 @@ public class LaunchCommandBuilder {
if (Files.exists(fallbackPath)) { if (Files.exists(fallbackPath)) {
paths.add(fallbackPath.toAbsolutePath().toString()); paths.add(fallbackPath.toAbsolutePath().toString());
} else { } 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 void setExtraJvmArgs(List<String> extraJvmArgs) { this.extraJvmArgs = extraJvmArgs; }
public int getWidth() { return width; } public int getWidth() { return width; }
public void setWidth(int width) { this.width = width; }
public int getHeight() { return height; } 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 org.jline.utils.InfoCmp;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
public class ArrowMenu { public class ArrowMenu {
@@ -14,16 +16,22 @@ public class ArrowMenu {
private final List<String> options; private final List<String> options;
private int selected = 0; private int selected = 0;
private final Terminal terminal; 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 { public ArrowMenu(String title, List<String> options) throws IOException {
this.title = title; this.title = title;
this.options = options; this.options = options;
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows");
System.setProperty("jline.terminal", isWindows ? "win" : "unsupported");
this.terminal = TerminalBuilder.builder() this.terminal = TerminalBuilder.builder()
.system(true) .system(true)
.jna(true) .jna(isWindows)
.jansi(true)
.encoding(StandardCharsets.UTF_8)
.build(); .build();
this.rawInput = terminal.input();
} }
public int show() throws IOException { public int show() throws IOException {
@@ -34,33 +42,43 @@ public class ArrowMenu {
try { try {
while (true) { while (true) {
printPagedMenu(); printPagedMenu();
int key = terminal.reader().read(); int b = rawInput.read();
if (b == -1) continue;
if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц' // w/W/k/K or ц (0xD1 0x86) = up
|| key == 'k' || key == 'K' || key == 'л' || key == 'Л') { // Up / Arrow 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(); selected = (selected - 1 + options.size()) % options.size();
} }
else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы' else if (b == 's' || b == 'S' || b == 'j' || b == 'J') {
|| key == 'j' || key == 'J' || key == 'о' || key == 'О') { // Down / Arrow Down
selected = (selected + 1) % options.size(); selected = (selected + 1) % options.size();
} }
else if (key == 13 || key == 10) { // Enter // ESC sequences: arrows + cyrillic start byte
return selected; else if (b == 0x1B) {
} int next = nonBlockingRead();
else if (key == 27) { // Esc or arrow escape seq if (next == -1) {
int next = terminal.reader().read(); return -1;
if (next == 91) { // '[' start of arrow escape sequence }
int arrow = terminal.reader().read(); if (next == 0x5B) { // '['
if (arrow == 65) { // 'A' Up arrow int arrow = nonBlockingRead();
if (arrow == 0x41) { // Up
selected = (selected - 1 + options.size()) % options.size(); selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 66) { // 'B' Down arrow } else if (arrow == 0x42) { // Down
selected = (selected + 1) % options.size(); selected = (selected + 1) % options.size();
} }
// else unknown escape seq, ignore
} else {
return -1; // genuine Esc
} }
} }
else if (b == 0xD1) {
int second = nonBlockingRead();
if (second == 0x86) { // ц
selected = (selected - 1 + options.size()) % options.size();
} else if (second == 0x8B) { // ы
selected = (selected + 1) % options.size();
}
}
else if (b == 13 || b == 10) {
return selected;
}
} }
} finally { } finally {
terminal.puts(InfoCmp.Capability.cursor_visible); 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() { private void printPagedMenu() {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("\033[H\033[2J"); sb.append("\033[H\033[2J");
// Заголовок (фиксированный)
sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n"); sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n");
sb.append(ZAnsi.yellow(title)).append("\n\n"); sb.append(ZAnsi.yellow(title)).append("\n\n");
// Вычисляем диапазон отображаемых элементов
int start = Math.max(0, selected - (VISIBLE_ITEMS / 2)); int start = Math.max(0, selected - (VISIBLE_ITEMS / 2));
int end = Math.min(options.size(), start + VISIBLE_ITEMS); int end = Math.min(options.size(), start + VISIBLE_ITEMS);
// Если в конце списка подтягиваем вверх
if (end - start < VISIBLE_ITEMS && start > 0) { if (end - start < VISIBLE_ITEMS && start > 0) {
start = Math.max(0, end - VISIBLE_ITEMS); start = Math.max(0, end - VISIBLE_ITEMS);
} }
@@ -94,10 +124,10 @@ public class ArrowMenu {
} }
} }
// Подсказка внизу (фиксированная)
sb.append("\n") 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.print(sb);
System.out.flush();
} }
} }
@@ -1,6 +1,7 @@
package me.sashegdev.zernmc.launcher.ui.jfx; package me.sashegdev.zernmc.launcher.ui.jfx;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.web.WebView; import javafx.scene.web.WebView;
import javafx.scene.web.WebEngine; import javafx.scene.web.WebEngine;
@@ -8,9 +9,13 @@ import javafx.stage.Stage;
import javafx.concurrent.Worker; import javafx.concurrent.Worker;
import com.google.gson.Gson; import com.google.gson.Gson;
import me.sashegdev.zernmc.launcher.api.LauncherAPI; 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.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.utils.Config;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStream; import java.io.InputStream;
@@ -42,12 +47,65 @@ public class JFXLauncher extends Application {
private final LauncherAPI api = new LauncherAPI(); private final LauncherAPI api = new LauncherAPI();
private final Gson gson = new Gson(); private final Gson gson = new Gson();
private HttpServer server; private HttpServer server;
private StringBuilder logBuffer = new StringBuilder(); private static StringBuilder launcherLogBuffer = new StringBuilder();
private static StringBuilder gameLogBuffer = new StringBuilder(); private static StringBuilder gameLogBuffer = new StringBuilder();
private static Path gameLogFile; private static Path gameLogFile;
private static Path launcherLogFile;
private Stage mainStage; 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) { public static void appendGameLog(String log) {
System.out.println("[GAME] " + log);
synchronized (gameLogBuffer) { synchronized (gameLogBuffer) {
gameLogBuffer.append(log).append("\n"); gameLogBuffer.append(log).append("\n");
@@ -59,8 +117,11 @@ public class JFXLauncher extends Application {
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
} }
for (LogConsumer consumer : gameLogConsumers) {
try { consumer.onLog(log); } catch (Exception ignored) {}
}
} }
public static void initGameLog(Path instanceDir) { public static void initGameLog(Path instanceDir) {
synchronized (gameLogBuffer) { synchronized (gameLogBuffer) {
gameLogBuffer.setLength(0); gameLogBuffer.setLength(0);
@@ -73,14 +134,30 @@ public class JFXLauncher extends Application {
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
public static void clearGameLog() {
synchronized (gameLogBuffer) {
gameLogBuffer.setLength(0);
}
}
public static String getGameLogs() { public static String getGameLogs() {
synchronized (gameLogBuffer) { synchronized (gameLogBuffer) {
return gameLogBuffer.toString(); return gameLogBuffer.toString();
} }
} }
public static String getLauncherLogs() {
synchronized (launcherLogBuffer) {
return launcherLogBuffer.toString();
}
}
public static void main(String[] args) { public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[JFX] Shutdown hook triggered");
LaunchService.killAllProcesses();
}));
launch(args); launch(args);
} }
@@ -93,15 +170,15 @@ public class JFXLauncher extends Application {
String serverVersion = getServerVersion(); String serverVersion = getServerVersion();
if (serverVersion != null && !serverVersion.isEmpty()) { 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)) { if (downloadAssetsFromMeta(serverVersion)) {
System.out.println("[JFX] Assets загружены через мета"); System.out.println("[JFX] Assets loaded via meta");
return; 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()); Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) { if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
try (JarFile jar = new JarFile(jarPath.toFile())) { 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) { } 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; return true;
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println("[JFX] Ошибка загрузки через мета: " + e.getMessage()); System.out.println("[JFX] Error loading via meta: " + e.getMessage());
return false; return false;
} }
} }
@@ -204,8 +281,15 @@ public class JFXLauncher extends Application {
this.mainStage = stage; this.mainStage = stage;
try { 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(); extractAssets();
log("Запуск JFX UI..."); log("Starting JFX UI...");
startServer(); startServer();
WebView webView = new WebView(); WebView webView = new WebView();
@@ -215,7 +299,7 @@ public class JFXLauncher extends Application {
engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
log("[UI] Load state: " + oldState + " -> " + newState); log("[UI] Load state: " + oldState + " -> " + newState);
if (newState == Worker.State.SUCCEEDED) { if (newState == Worker.State.SUCCEEDED) {
log("Страница загружена"); log("Page loaded");
} else if (newState == Worker.State.FAILED) { } else if (newState == Worker.State.FAILED) {
log("[UI] Load FAILED: " + engine.getLoadWorker().getException()); log("[UI] Load FAILED: " + engine.getLoadWorker().getException());
} }
@@ -227,20 +311,24 @@ public class JFXLauncher extends Application {
engine.load(url); engine.load(url);
stage.setTitle(APP_TITLE); stage.setTitle(APP_TITLE);
stage.setWidth(1200); stage.setWidth(1280);
stage.setHeight(800); stage.setHeight(800);
stage.setMinWidth(800);
stage.setMinHeight(600);
stage.setScene(new Scene(webView)); stage.setScene(new Scene(webView));
stage.show(); stage.show();
log("Окно отображено"); log("Window displayed");
stage.setOnCloseRequest(e -> { stage.setOnCloseRequest(e -> {
log("Закрытие..."); log("Closing...");
stopServer(); LaunchService.killAllProcesses();
if (server != null) server.stop(0);
Platform.exit();
}); });
} catch (Exception e) { } catch (Exception e) {
log("Ошибка: " + e.getMessage()); log("Error: " + e.getMessage());
e.printStackTrace(); e.printStackTrace();
throw new RuntimeException(e); throw new RuntimeException(e);
} }
@@ -250,19 +338,31 @@ public class JFXLauncher extends Application {
server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0); server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0);
server.createContext("/api/login", this::handleLogin); server.createContext("/api/login", this::handleLogin);
server.createContext("/api/auto-login", this::handleAutoLogin);
server.createContext("/api/account", this::handleAccount); server.createContext("/api/account", this::handleAccount);
server.createContext("/api/instances", this::handleInstances); server.createContext("/api/instances", this::handleInstances);
server.createContext("/api/launch", this::handleLaunch); server.createContext("/api/launch", this::handleLaunch);
server.createContext("/api/install", this::handleInstall); server.createContext("/api/install", this::handleInstall);
server.createContext("/api/install/progress", this::handleInstallProgress);
server.createContext("/api/logs", this::handleLogs); 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", 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", this::handleExit);
server.createContext("/api/exit-parent", this::handleExitParent);
server.createContext("/assets/", this::handleStatic); server.createContext("/assets/", this::handleStatic);
server.setExecutor(Executors.newCachedThreadPool()); server.setExecutor(Executors.newCachedThreadPool());
server.start(); server.start();
log("HTTP сервер на порту " + PORT); log("HTTP server on port " + PORT);
} }
private void stopServer() { private void stopServer() {
@@ -272,7 +372,7 @@ public class JFXLauncher extends Application {
private void handleLogin(HttpExchange exchange) { private void handleLogin(HttpExchange exchange) {
try { try {
if (!"POST".equals(exchange.getRequestMethod())) { if (!"POST".equals(exchange.getRequestMethod())) {
sendJson(exchange, Map.of("success", false, "error", "Метод не поддерживается")); sendJson(exchange, Map.of("success", false, "error", "Method not supported"));
return; return;
} }
@@ -286,7 +386,7 @@ public class JFXLauncher extends Application {
data.put("username", result.getData().getUsername()); data.put("username", result.getData().getUsername());
data.put("token", result.getData().getToken()); data.put("token", result.getData().getToken());
sendJson(exchange, Map.of("success", true, "data", data)); sendJson(exchange, Map.of("success", true, "data", data));
log("Вход: " + username); log("Login: " + username);
} else { } else {
sendJson(exchange, Map.of("success", false, "error", result.getError())); sendJson(exchange, Map.of("success", false, "error", result.getError()));
} }
@@ -298,7 +398,7 @@ public class JFXLauncher extends Application {
private void handleAccount(HttpExchange exchange) { private void handleAccount(HttpExchange exchange) {
try { try {
if (!api.isLoggedIn()) { if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Не авторизован")); sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return; return;
} }
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
@@ -311,6 +411,24 @@ public class JFXLauncher extends Application {
sendJson(exchange, Map.of("success", false, "error", e.getMessage())); sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
} }
} }
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) { private void handleInstances(HttpExchange exchange) {
try { try {
@@ -331,20 +449,30 @@ public class JFXLauncher extends Application {
private void handleLaunch(HttpExchange exchange) { private void handleLaunch(HttpExchange exchange) {
try { try {
if (!api.isLoggedIn()) { if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Не авторизован")); sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return; return;
} }
Map<String, String> body = parseJson(exchange.getRequestBody()); Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name"); 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); var result = api.launch(name);
if (result.isSuccess()) { if (result.isSuccess()) {
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("pid", result.getData().getPid()); data.put("pid", result.getData().getPid());
data.put("status", result.getData().getStatus()); data.put("status", result.getData().getStatus());
sendJson(exchange, Map.of("success", true, "data", data)); sendJson(exchange, Map.of("success", true, "data", data));
log("Запущено: " + name); log("Launched: " + name);
} else { } else {
sendJson(exchange, Map.of("success", false, "error", result.getError())); sendJson(exchange, Map.of("success", false, "error", result.getError()));
} }
@@ -354,9 +482,14 @@ public class JFXLauncher extends Application {
} }
private void handleInstall(HttpExchange exchange) { private void handleInstall(HttpExchange exchange) {
if (installInProgress) {
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
return;
}
try { try {
if (!api.isLoggedIn()) { if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Не авторизован")); sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return; return;
} }
@@ -364,8 +497,9 @@ public class JFXLauncher extends Application {
String name = body.get("name"); String name = body.get("name");
String version = body.get("version"); String version = body.get("version");
String loader = body.get("loader"); 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); var createResult = api.instances().createInstance(name);
if (!createResult.isSuccess()) { if (!createResult.isSuccess()) {
@@ -377,29 +511,313 @@ public class JFXLauncher extends Application {
if (instance != null) { if (instance != null) {
instance.setMinecraftVersion(version); instance.setMinecraftVersion(version);
instance.setLoaderType(loader); instance.setLoaderType(loader);
if (loaderVersion != null) {
instance.setLoaderVersion(loaderVersion);
}
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) {
sendJson(exchange, Map.of("success", true, "data", true)); log("Install error: " + e.getMessage());
log("Установлено: " + name); 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) { } catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage())); sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
} }
} }
private void handleLogs(HttpExchange exchange) { 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) { private void handleGameLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", getGameLogs())); sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
} }
private void handleExit(HttpExchange exchange) { private void handleGameLogsStream(HttpExchange exchange) {
log("Выход..."); try {
if (mainStage != null) mainStage.close(); 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); 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) { private void handleStatic(HttpExchange exchange) {
try { try {
String path = exchange.getRequestURI().getPath(); String path = exchange.getRequestURI().getPath();
@@ -457,7 +875,18 @@ public class JFXLauncher extends Application {
private void log(String msg) { private void log(String msg) {
String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n"; String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n";
logBuffer.append(entry); synchronized (launcherLogBuffer) {
launcherLogBuffer.append(entry);
}
System.out.println("[JFX] " + msg); 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 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 serverUrl = "http://87.120.187.36:1582";
private static String lastUsername = "Player"; 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 { static {
load(); load();
@@ -36,9 +39,13 @@ public class Config {
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096")); maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
serverUrl = props.getProperty("serverUrl", serverUrl); serverUrl = props.getProperty("serverUrl", serverUrl);
lastUsername = props.getProperty("lastUsername", lastUsername); 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) { } 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("maxMemory", String.valueOf(maxMemory));
props.setProperty("serverUrl", serverUrl); props.setProperty("serverUrl", serverUrl);
props.setProperty("lastUsername", lastUsername); 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)) { try (var os = Files.newOutputStream(CONFIG_FILE)) {
props.store(os, "ZernMC Launcher Configuration"); props.store(os, "ZernMC Launcher Configuration");
} }
} catch (IOException e) { } 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() { 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);
long recommended = (long) (totalRamMB * 0.70); // 70% от доступной
// Ограничения recommended = Math.max(1536, recommended);
recommended = Math.max(1536, recommended); // минимум 1.5 GB recommended = Math.min(recommended, totalRamMB - 1024);
recommended = Math.min(recommended, totalRamMB - 1024); // оставляем минимум 1 GB системе
// Если текущее значение сильно отличается от рекомендуемого корректируем if (Math.abs(maxMemory - recommended) > 1024) {
if (Math.abs(maxMemory - recommended) > 1024) { // разница больше 1 GB
maxMemory = (int) recommended; maxMemory = (int) recommended;
save(); // сохраняем умную рекомендацию save();
System.out.println(ZAnsi.cyan("Автоматически рекомендовано RAM: " + maxMemory + " MB")); System.out.println(ZAnsi.cyan("Auto-recommended RAM: " + maxMemory + " MB"));
} }
} }
// Getters & Setters
public static int getMaxMemory() { public static int getMaxMemory() {
return maxMemory; return maxMemory;
} }
@@ -94,7 +95,6 @@ public class Config {
} }
public static void setMaxMemory(int memory) { public static void setMaxMemory(int memory) {
// Защита от слишком маленьких/больших значений
if (memory < 1024) memory = 1536; if (memory < 1024) memory = 1536;
if (memory > 32768) memory = 32768; if (memory > 32768) memory = 32768;
@@ -127,11 +127,44 @@ public class Config {
return CONFIG_DIR; 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() { public static String getRamInfo() {
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); 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() { public static void pause() {
System.out.print(ZAnsi.white("\nНажмите Enter для продолжения...")); System.out.print(ZAnsi.white("\nPress Enter to continue..."));
try { try {
System.in.read(); System.in.read();
// Очищаем буфер ввода
while (System.in.available() > 0) { while (System.in.available() > 0) {
System.in.read(); System.in.read();
} }
@@ -36,4 +35,4 @@ public class ConsoleUtils {
public static void separator() { public static void separator() {
System.out.println(ZAnsi.white("────────────────────────────────────────────────────────────")); System.out.println(ZAnsi.white("────────────────────────────────────────────────────────────"));
} }
} }
@@ -3,23 +3,20 @@ package me.sashegdev.zernmc.launcher.utils;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu; import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.Scanner; import java.util.Scanner;
/**
* Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu
*/
public class Input { public class Input {
// Используем UTF-8 явно это помогает на Windows private static final Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8);
private static final Scanner scanner = new Scanner(System.in, "UTF-8");
public static String readLine() { public static String readLine() {
return scanner.nextLine().trim(); return scanner.nextLine().trim();
} }
public static String readLine(String prompt) { public static String readLine(String prompt) {
flushInput(); // Очищаем буфер flushInput();
System.out.print(prompt); System.out.print(prompt);
return scanner.nextLine().trim(); return scanner.nextLine().trim();
} }
@@ -30,7 +27,7 @@ public class Input {
System.out.print(prompt); System.out.print(prompt);
return Integer.parseInt(scanner.nextLine().trim()); return Integer.parseInt(scanner.nextLine().trim());
} catch (NumberFormatException e) { } 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) { if (value >= min && value <= max) {
return value; 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 { public static boolean confirm(String question) throws IOException {
ConsoleUtils.clearScreen(); // опционально, можно убрать ConsoleUtils.clearScreen();
List<String> options = List.of( List<String> options = List.of(
"Да", "Yes",
"Нет" "No"
); );
ArrowMenu menu = new ArrowMenu(question, options); ArrowMenu menu = new ArrowMenu(question, options);
int choice = menu.show(); int choice = menu.show();
return choice == 0; // 0 = "Да" return choice == 0;
} }
/**
* Альтернативный confirm без очистки экрана
* @throws IOException
*/
public static boolean confirmInline(String question) 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); ArrowMenu menu = new ArrowMenu(question, options);
int choice = menu.show(); int choice = menu.show();
return choice == 0; return choice == 0;
} }
/**
* Закрытие сканнера (вызывать при выходе из программы, если нужно)
*/
public static void close() { public static void close() {
scanner.close(); scanner.close();
} }
/**
* Очищает буфер ввода от оставшихся символов
*/
public static void flushInput() { public static void flushInput() {
try { try {
while (System.in.available() > 0) { while (System.in.available() > 0) {
System.in.read(); System.in.read();
} }
} catch (IOException e) { } catch (IOException e) {
// Игнорируем
} }
} }
} }
@@ -6,11 +6,20 @@ public class ProgressBar {
private static final int BAR_LENGTH = 40; private static final int BAR_LENGTH = 40;
private static final DecimalFormat DF = new DecimalFormat("#.##"); 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) { 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) { if (total <= 0) {
System.out.print("\r" + ZAnsi.cyan(label) + " ..."); System.out.print("\r" + ZAnsi.cyan(label) + " ...");
return; return;
@@ -27,10 +36,16 @@ public class ProgressBar {
System.out.flush(); System.out.flush();
} }
/**
* Прогресс по байтам для одного файла (реальный прогресс)
*/
public static void showDownload(String label, long downloaded, long totalBytes) { 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) { if (totalBytes <= 0) {
System.out.print("\r" + ZAnsi.cyan(label) + " ..."); System.out.print("\r" + ZAnsi.cyan(label) + " ...");
return; return;
@@ -53,8 +68,16 @@ public class ProgressBar {
} }
public static void showAnimated(String label, long current, long total, String unit) { 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) { if (total <= 0) {
// Анимация для неизвестного размера
char[] spinner = {'|', '/', '-', '\\'}; char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (current / 1024) % 4; int idx = (int) (current / 1024) % 4;
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current)); System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
@@ -64,7 +87,13 @@ public class ProgressBar {
} }
public static void finish(String message) { 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(); System.out.flush();
} }
@@ -78,4 +107,4 @@ public class ProgressBar {
if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB"; if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB";
return DF.format(bytes / (1024.0 * 1024)) + " MB"; return DF.format(bytes / (1024.0 * 1024)) + " MB";
} }
} }
@@ -29,14 +29,9 @@ public class ZHttpClient {
private static String BASE_URL = "http://87.120.187.36:1582"; private static String BASE_URL = "http://87.120.187.36:1582";
// Глобальный прокси режим (для обратной совместимости)
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
/**
* Переопределить URL сервера (для тестов).
* Внимание: не потокобезопасно, использовать только в тестах.
*/
public static void setBaseUrl(String url) { public static void setBaseUrl(String url) {
BASE_URL = url; BASE_URL = url;
} }
@@ -45,7 +40,6 @@ public class ZHttpClient {
return BASE_URL; return BASE_URL;
} }
// Умное проксирование по сервисам
public enum ServiceType { public enum ServiceType {
ZERN_SERVER("http://87.120.187.36:1582", true), ZERN_SERVER("http://87.120.187.36:1582", true),
FABRIC_META("https://meta.fabricmc.net", false), FABRIC_META("https://meta.fabricmc.net", false),
@@ -69,17 +63,15 @@ public class ZHttpClient {
public boolean isAlwaysDirect() { return alwaysDirect; } public boolean isAlwaysDirect() { return alwaysDirect; }
} }
// Статусы сервисов
private static final Map<ServiceType, Boolean> serviceProxyMode = new ConcurrentHashMap<>(); private static final Map<ServiceType, Boolean> serviceProxyMode = new ConcurrentHashMap<>();
private static final Map<ServiceType, Integer> serviceFailCount = 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, Long> serviceLastCheckTime = new ConcurrentHashMap<>();
private static final Map<ServiceType, Boolean> serviceHealthy = new ConcurrentHashMap<>(); private static final Map<ServiceType, Boolean> serviceHealthy = new ConcurrentHashMap<>();
private static final int MAX_FAILS_BEFORE_PROXY = 2; private static final int MAX_FAILS_BEFORE_PROXY = 2;
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута private static final long HEALTH_CHECK_INTERVAL_MS = 60000;
private static final long CHECK_TIMEOUT_MS = 7000; // 7 секунд на проверку private static final long CHECK_TIMEOUT_MS = 7000;
// Статистика
private static int directSuccessCount = 0; private static int directSuccessCount = 0;
private static int proxySuccessCount = 0; private static int proxySuccessCount = 0;
private static int directFailCount = 0; private static int directFailCount = 0;
@@ -92,14 +84,13 @@ public class ZHttpClient {
} }
} }
/**
* Вызывать один раз при запуске лаунчера
*/
public static void checkAllServicesOnStartup() { public static void checkAllServicesOnStartup() {
checkAllServicesOnStartup(false);
}
public static void checkAllServicesOnStartup(boolean verbose) {
if (proxyTested.get()) return; if (proxyTested.get()) return;
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
List<ServiceType> servicesToCheck = List.of( List<ServiceType> servicesToCheck = List.of(
ServiceType.ZERN_SERVER, ServiceType.ZERN_SERVER,
ServiceType.GOOGLE, ServiceType.GOOGLE,
@@ -116,14 +107,20 @@ public class ZHttpClient {
serviceHealthy.put(service, isHealthy); serviceHealthy.put(service, isHealthy);
if (service.isAlwaysDirect()) { if (service.isAlwaysDirect()) {
System.out.println(isHealthy ? if (verbose) {
ZAnsi.green(" " + service.name() + " - OK") : System.out.println(isHealthy ?
ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); ZAnsi.green(" " + service.name() + " - OK") :
ZAnsi.red(" " + service.name() + " - NOT ACCESSIBLE (critical!)"));
}
} else { } else {
if (isHealthy) { if (isHealthy) {
System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает")); if (verbose) {
System.out.println(ZAnsi.green(" " + service.name() + " - direct connection works"));
}
} else { } 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); serviceProxyMode.put(service, true);
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
} }
@@ -131,30 +128,31 @@ public class ZHttpClient {
} }
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { 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); proxyTested.set(true);
startHealthCheckThread(); if (verbose) {
printStats(); startHealthCheckThread();
printStats();
}
} }
/**
* Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки)
*/
public static void forceCheckMojangServices() { 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)) { for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) {
boolean healthy = checkServiceHealth(service); boolean healthy = checkServiceHealth(service);
serviceHealthy.put(service, healthy); serviceHealthy.put(service, healthy);
if (healthy) { if (healthy) {
System.out.println(ZAnsi.green(" " + service.name() + " доступен напрямую")); System.out.println(ZAnsi.green(" " + service.name() + " accessible directly"));
serviceProxyMode.put(service, false); serviceProxyMode.put(service, false);
serviceFailCount.put(service, 0); serviceFailCount.put(service, 0);
} else { } else {
System.out.println(ZAnsi.yellow(" " + service.name() + " недоступен → прокси режим активирован")); System.out.println(ZAnsi.yellow(" " + service.name() + " not accessible -> proxy mode activated"));
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
} }
@@ -165,9 +163,6 @@ public class ZHttpClient {
return checkDirectConnection(service.getBaseUrl()); return checkDirectConnection(service.getBaseUrl());
} }
/**
* Улучшенная проверка прямого подключения
*/
private static boolean checkDirectConnection(String baseUrl) { private static boolean checkDirectConnection(String baseUrl) {
String testUrl = baseUrl; String testUrl = baseUrl;
@@ -187,7 +182,7 @@ public class ZHttpClient {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
int code = response.statusCode(); int code = response.statusCode();
return code == 200 || code == 404; // 404 для ресурсов нормально return code == 200 || code == 404;
} catch (Exception e) { } catch (Exception e) {
return false; return false;
} }
@@ -218,7 +213,7 @@ public class ZHttpClient {
if (isHealthy && serviceProxyMode.get(service)) { if (isHealthy && serviceProxyMode.get(service)) {
serviceProxyMode.put(service, false); serviceProxyMode.put(service, false);
serviceFailCount.put(service, 0); 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)) { } else if (!isHealthy && !serviceProxyMode.get(service)) {
int fails = serviceFailCount.getOrDefault(service, 0) + 1; int fails = serviceFailCount.getOrDefault(service, 0) + 1;
serviceFailCount.put(service, fails); serviceFailCount.put(service, fails);
@@ -226,7 +221,7 @@ public class ZHttpClient {
if (fails >= MAX_FAILS_BEFORE_PROXY) { if (fails >= MAX_FAILS_BEFORE_PROXY) {
serviceProxyMode.put(service, true); 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)) { if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
serviceProxyMode.put(service, true); 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 { public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
// Попытка прямого подключения
if (!shouldUseProxyForUrl(url)) { if (!shouldUseProxyForUrl(url)) {
try { try {
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
@@ -309,11 +301,9 @@ public class ZHttpClient {
directFailCount++; directFailCount++;
markServiceAsBlocked(url); markServiceAsBlocked(url);
} }
// Если ошибка соединения пробуем через прокси
} }
} }
// Через прокси
try { try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/download?url=" + encodedUrl; String proxyUrl = BASE_URL + "/download?url=" + encodedUrl;
@@ -335,13 +325,10 @@ public class ZHttpClient {
return response.body(); return response.body();
} catch (Exception e) { } 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 { public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
if (!shouldUseProxyForUrl(url)) { if (!shouldUseProxyForUrl(url)) {
try { try {
@@ -363,11 +350,9 @@ public class ZHttpClient {
directFailCount++; directFailCount++;
markServiceAsBlocked(url); markServiceAsBlocked(url);
} }
// fallback на прокси ниже
} }
} }
// Скачивание через прокси
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
@@ -387,8 +372,6 @@ public class ZHttpClient {
proxySuccessCount++; proxySuccessCount++;
} }
// ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ======================
public static String get(String endpoint) throws IOException, InterruptedException { public static String get(String endpoint) throws IOException, InterruptedException {
checkAllServicesOnStartup(); checkAllServicesOnStartup();
@@ -403,7 +386,6 @@ public class ZHttpClient {
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET(); .GET();
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
String accessToken = AuthManager.getAccessToken(); String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) { if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken); requestBuilder.header("Authorization", "Bearer " + accessToken);
@@ -430,7 +412,6 @@ public class ZHttpClient {
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET(); .GET();
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
String accessToken = AuthManager.getAccessToken(); String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) { if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken); requestBuilder.header("Authorization", "Bearer " + accessToken);
@@ -446,12 +427,10 @@ public class ZHttpClient {
proxySuccessCount++; proxySuccessCount++;
return response.body(); return response.body();
} catch (Exception e) { } 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 { public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException {
String url = "https://meta.fabricmc.net/v2/versions/loader"; String url = "https://meta.fabricmc.net/v2/versions/loader";
return parseFabricVersionsFromJson(getWithSmartProxy(url)); return parseFabricVersionsFromJson(getWithSmartProxy(url));
@@ -506,15 +485,13 @@ public class ZHttpClient {
return versions; return versions;
} }
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
public static String getLauncherVersionInfo() throws IOException, InterruptedException { public static String getLauncherVersionInfo() throws IOException, InterruptedException {
return get("/launcher/version"); return get("/launcher/version");
} }
public static void forceProxyMode() { public static void forceProxyMode() {
useProxyMode.set(true); useProxyMode.set(true);
System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим")); System.out.println(ZAnsi.yellow("Global proxy mode forced on"));
} }
public static void disableProxyMode() { public static void disableProxyMode() {
@@ -525,7 +502,7 @@ public class ZHttpClient {
serviceFailCount.put(type, 0); serviceFailCount.put(type, 0);
} }
} }
System.out.println(ZAnsi.green("Режим прокси выключен")); System.out.println(ZAnsi.green("Proxy mode disabled"));
} }
public static boolean isProxyMode() { public static boolean isProxyMode() {
@@ -533,18 +510,18 @@ public class ZHttpClient {
} }
public static void printStats() { public static void printStats() {
System.out.println(ZAnsi.cyan("\n=== Статистика сети ===")); System.out.println(ZAnsi.cyan("\n=== Network Stats ==="));
System.out.println(ZAnsi.white("Глобальный прокси: ") + (useProxyMode.get() ? "ВКЛ" : "ВЫКЛ")); System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF"));
System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount); System.out.println(ZAnsi.white("Direct successes: ") + directSuccessCount);
System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount); System.out.println(ZAnsi.white("Direct failures: ") + directFailCount);
System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount); 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()) { for (ServiceType type : ServiceType.values()) {
if (type.isAlwaysDirect()) continue; 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("[-]"); String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]");
System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health); System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health);
} }
} }
} }
+339 -71
View File
@@ -1,100 +1,368 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title> <title>ZernMC Launcher</title>
<link rel="stylesheet" href="style.css"> <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> </head>
<body> <body>
<canvas id="bg-canvas"></canvas>
<div id="app"> <div id="app">
<!-- Экран логина --> <!-- Login Screen -->
<div id="login-screen" class="screen"> <div id="login-screen" class="screen">
<div class="login-container"> <div class="login-container">
<h1 class="logo">ZernMC</h1> <div class="login-brand">
<p class="subtitle">Private Launcher</p> <div class="brand-icon">
<form id="login-form"> <svg width="56" height="56" viewBox="0 0 56 56" fill="none">
<input type="text" id="username" placeholder="Никнейм" required> <rect width="56" height="56" rx="14" fill="url(#brandGrad)"/>
<input type="password" id="password" placeholder="Пароль" required> <path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
<button type="submit" class="btn-primary">Войти</button> <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> </form>
<div id="login-error" class="error hidden"></div>
</div> </div>
</div> </div>
<!-- Главное меню --> <!-- Loading Overlay -->
<div id="main-screen" class="screen hidden"> <div id="loading-overlay" class="overlay hidden">
<!-- Хедер --> <div class="loader-ring"></div>
<header class="header"> <p class="loader-text">Loading...</p>
<h1 class="logo">ZernMC Launcher</h1> </div>
<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 Screen -->
<main class="main-content"> <div id="main-screen" class="screen hidden">
<!-- Слева: выбор сборки --> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<h2>Сборки</h2> <div class="sidebar-top">
<div id="instances-list" class="instances-container"> <div class="sidebar-brand">
<!-- Динамически заполняется через JS --> <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> </div>
</aside> </aside>
<!-- По центру: логи --> <main class="content">
<section class="logs-panel"> <!-- Packs View -->
<h2>Логи</h2> <div id="view-packs" class="view active">
<div id="logs-container"></div> <div class="view-header">
</section> <div>
</main> <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">
<footer class="footer"> <div class="pack-empty" id="pack-empty-state">
<div class="instance-info"> <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>
<span id="selected-name">-</span> <h3>No pack selected</h3>
<span id="selected-version">-</span> <p>Select a pack from the sidebar or add a new one</p>
<span id="selected-loader">-</span> </div>
</div> <div id="pack-detail-content" class="pack-detail-content hidden">
<button id="play-btn" class="btn-play" disabled>Выберите сборку</button> <div class="pack-hero">
</footer> <div class="pack-icon">
</div> <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 id="install-modal" class="modal hidden"> <div class="play-bar-info">
<div class="modal-content"> <span id="play-bar-name">Select a pack</span>
<h2>Установка сборки</h2> </div>
<form id="install-form"> <button id="play-btn" class="btn-play" disabled>
<label>Версия Minecraft <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<select id="install-mc-version"> Play
<option value="1.20.4">1.20.4</option> </button>
<option value="1.20.2">1.20.2</option> </div>
<option value="1.20.1">1.20.1</option>
<option value="1.19.2">1.19.2</option>
</select>
</label>
<label>Загрузчик
<select id="install-loader">
<option value="vanilla">Vanilla</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</label>
<label>Имя сборки
<input type="text" id="install-name" placeholder="MyServer" required>
</label>
<div class="modal-buttons">
<button type="button" class="btn-secondary" onclick="closeInstallModal()">Отмена</button>
<button type="submit" class="btn-primary">Установить</button>
</div> </div>
</form>
<!-- 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>
</div> </div>
</div> </div>
<!-- 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>
</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>
</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> </div>
<script src="launcher.js"></script> <script src="launcher.js"></script>
</body> </body>
</html> </html>
+544 -287
View File
@@ -1,311 +1,568 @@
const API_BASE = 'http://localhost:8080/api'; const API = '/api';
let state = { class ZernMCLauncher {
loggedIn: false, constructor() {
account: null, this.state = { account: null, instances: [], selectedPack: null, installing: false, serverPacks: [] };
instances: [], this.toastTimer = null;
selectedInstance: null this.progressPoller = null;
}; this.init();
// ============ API ============
async function apiCall(endpoint, options = {}) {
const url = `${API_BASE}${endpoint}`;
const config = {
headers: { 'Content-Type': 'application/json' },
...options
};
try {
const response = await fetch(url, config);
const data = await response.json();
return data;
} catch (e) {
log('Ошибка соединения с сервером: ' + e.message, 'error');
return { success: false, error: e.message };
} }
}
// ============ Login ============ async init() {
this.bindEvents();
async function login(username, password) { this.initBg();
log('Выполняется вход...', 'info'); await this.checkAuth();
const result = await apiCall('/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
if (result.success) {
state.loggedIn = true;
state.account = result.data;
log('Вход выполнен: ' + result.data.username, 'success');
showMainScreen();
await loadInstances();
} else {
log('Ошибка входа: ' + result.error, 'error');
showError(result.error);
} }
return result;
}
function showError(message) { // ==================== BACKGROUND ====================
const el = document.getElementById('login-error'); initBg() {
el.textContent = message; const c = document.getElementById('bg-canvas');
el.classList.remove('hidden'); const ctx = c.getContext('2d');
} let mx = 0, my = 0, ox = 0, oy = 0;
function hideError() { const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; draw(); };
document.getElementById('login-error').classList.add('hidden'); 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();
};
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();
}
// ============ Account ============ // ==================== API ====================
async req(endpoint, opts = {}) {
async function loadAccountInfo() { try {
const result = await apiCall('/account'); const r = await fetch(`${API}${endpoint}`, {
if (result.success) { ...opts,
state.account = result.data; headers: { 'Content-Type': 'application/json', ...opts.headers }
state.loggedIn = true; });
document.getElementById('account-name').textContent = result.data.username; return await r.json();
} catch (e) {
const statusEl = document.getElementById('account-status'); return { success: false, error: e.message };
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 ============ // ==================== AUTH ====================
async checkAuth() {
async function loadInstances() { this.showLoading(true);
log('Загрузка списка сборок...', 'info'); const auto = await this.req('/auto-login');
const result = await apiCall('/instances'); if (auto.success && auto.autoLogin) {
this.state.account = auto.data;
if (result.success) { this.enterMain();
state.instances = result.data; this.toast(`Welcome back, ${auto.data.username}`, 'success');
renderInstances(); } else {
log('Загружено ' + result.data.length + ' сборок', 'success'); const acct = await this.req('/account');
} else { if (acct.success) {
log('Ошибка загрузки: ' + result.error, 'error'); this.state.account = acct.data;
} this.enterMain();
} } else {
this.showLogin();
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>`;
} }
this.showLoading(false);
}
card.innerHTML = ` async handleLogin(e) {
<div class="instance-name">${inst.name}</div> e.preventDefault();
<div class="instance-details">${details}</div> 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');
container.appendChild(card); if (!username || !password) { this.showLoginError('Enter username and password'); return; }
});
}
function selectInstance(name) { btn.disabled = true;
state.selectedInstance = state.instances.find(i => i.name === name); label.textContent = 'Signing in...';
spinner.classList.remove('hidden');
document.querySelectorAll('.instance-card').forEach(c => { const r = await this.req('/login', { method: 'POST', body: JSON.stringify({ username, password }) });
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.disabled = false;
btn.textContent = 'Играть'; label.textContent = 'Sign In';
btn.classList.remove('update'); spinner.classList.add('hidden');
} else {
btn.disabled = true;
btn.textContent = 'Выберите сборку';
}
}
// ============ Launch ============ if (r.success) {
this.state.account = r.data;
async function launchInstance() { this.enterMain();
if (!state.selectedInstance) return; this.toast(`Welcome, ${r.data.username}!`, 'success');
} else {
const name = state.selectedInstance.name; // If login fails, try register (auto-create account)
log('Запуск сборки: ' + name, 'info'); 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 }) });
const result = await apiCall('/launch', { if (reg.success) {
method: 'POST', this.state.account = reg.data;
body: JSON.stringify({ name }) this.enterMain();
}); this.toast(`Account created! Welcome, ${reg.data.username}!`, 'success');
return;
if (result.success) {
log('Сборка запущена! PID: ' + result.data.pid, 'success');
} else {
log('Ошибка запуска: ' + result.error, 'error');
}
}
// ============ Install ============
function openInstallModal() {
document.getElementById('install-modal').classList.remove('hidden');
}
function closeInstallModal() {
document.getElementById('install-modal').classList.add('hidden');
}
async function installInstance(formData) {
log('Установка сборки...', 'info');
const result = await apiCall('/install', {
method: 'POST',
body: JSON.stringify(formData)
});
if (result.success) {
log('Сборка установлена!', 'success');
closeInstallModal();
await loadInstances();
} else {
log('Ошибка установки: ' + result.error, 'error');
}
return result;
}
// ============ Logs ============
function log(message, type = 'info') {
const container = document.getElementById('logs-container');
if (!container) return;
const line = document.createElement('div');
line.className = 'log-line ' + type;
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
container.appendChild(line);
container.scrollTop = container.scrollHeight;
}
function clearLogs() {
document.getElementById('logs-container').innerHTML = '';
}
// ============ Screens ============
function showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
clearError();
}
function showMainScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('hidden');
if (state.account) {
document.getElementById('account-name').textContent = state.account.username;
const statusEl = document.getElementById('account-status');
statusEl.textContent = state.account.passActive ? 'PRO' : 'FREE';
statusEl.className = 'badge ' + (state.account.passActive ? 'active' : 'inactive');
}
}
// ============ Init ============
document.addEventListener('DOMContentLoaded', async () => {
log('Запуск лаунчера...', 'info');
await loadAccountInfo();
if (!state.loggedIn) {
showLoginScreen();
} else {
showMainScreen();
await loadInstances();
}
// Start polling for server logs
startLogPolling();
});
let lastLogLength = 0;
let lastGameLogLength = 0;
function startLogPolling() {
setInterval(async () => {
// Launcher logs
const result = await apiCall('/logs');
if (result.success && result.data && result.data.length > lastLogLength) {
const newLogs = result.data.substring(lastLogLength);
const lines = newLogs.split('\n').filter(l => l.trim());
lines.forEach(line => {
if (line.includes('[JFX]')) {
log(line.replace('[JFX] ', ''), 'info');
} }
}); }
lastLogLength = result.data.length; this.showLoginError(r.error || 'Login failed');
}
}
showLoginError(msg) {
const el = document.getElementById('login-error');
el.textContent = msg;
el.classList.remove('hidden');
}
showLogin() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
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');
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 {
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();
}
async loadServerPacksList() {
const r = await this.req('/packs');
if (r.success && r.data) {
this.state.serverPacks = r.data;
}
}
// ==================== 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(); });
});
}
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));
}
// ==================== INSTANCES ====================
async loadInstances() {
const r = await this.req('/instances');
if (r.success && r.data) {
this.state.instances = r.data;
this.renderSidebar();
}
}
renderSidebar() {
const serverList = document.getElementById('server-packs-list');
serverList.innerHTML = '';
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);
});
}
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';
} }
// Game logs // Load pack description from API
const gameResult = await apiCall('/game-logs'); const descEl = document.getElementById('pack-description-text');
if (gameResult.success && gameResult.data && gameResult.data.length > lastGameLogLength) { const galleryEl = document.getElementById('pack-gallery');
const newLogs = gameResult.data.substring(lastGameLogLength); galleryEl.innerHTML = '';
const lines = newLogs.split('\n').filter(l => l.trim()); if (inst.isServerPack && inst.serverPackName) {
lines.forEach(line => { descEl.textContent = 'Loading description...';
log('[GAME] ' + line, 'info'); this.loadPackDescription(inst.serverPackName, descEl, galleryEl);
}); } else {
lastGameLogLength = gameResult.data.length; descEl.textContent = '';
} }
}, 2000); }
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;
}
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 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>';
}
}
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;
}
} }
// ============ Form Handlers ============ const app = new ZernMCLauncher();
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
await login(username, password);
});
document.getElementById('play-btn').addEventListener('click', async () => {
await launchInstance();
});
document.getElementById('install-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
name: document.getElementById('install-name').value,
version: document.getElementById('install-mc-version').value,
loader: document.getElementById('install-loader').value
};
await installInstance(formData);
});
// Expose functions globally for inline handlers
window.closeInstallModal = closeInstallModal;
+459 -366
View File
@@ -1,438 +1,531 @@
:root { :root {
--bg-primary: #1a1a2e; --bg-deep: #07070a;
--bg-secondary: #16213e; --bg-surface: #0c0c12;
--bg-tertiary: #0f3460; --bg-elevated: #111118;
--bg-card: #16161f;
--bg-card-hover: #1c1c28;
--bg-inset: #0a0a0f;
--accent: #e94560; --accent: #e94560;
--accent-hover: #ff6b6b; --accent-glow: rgba(233, 69, 96, 0.25);
--text-primary: #eaeaea; --accent-soft: rgba(233, 69, 96, 0.1);
--text-secondary: #a0a0a0; --text: #eeeef0;
--text-secondary: #88889a;
--text-muted: #555566;
--border: #1e1e2a;
--border-light: #2a2a3a;
--success: #4ade80; --success: #4ade80;
--error: #f87171;
--warning: #fbbf24; --warning: #fbbf24;
--error: #ef4444; --info: #60a5fa;
--border: #2d2d4a; --radius-sm: 6px;
--shadow: rgba(0, 0, 0, 0.3); --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;
} }
* { *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
margin: 0;
padding: 0; html { font-size: 14px; }
box-sizing: border-box;
}
body { body {
font-family: 'Segoe UI', system-ui, sans-serif; font-family: var(--font);
background: var(--bg-primary); background: var(--bg-deep);
color: var(--text-primary); color: var(--text);
height: 100vh; min-height: 100vh;
overflow: hidden; overflow: hidden;
-webkit-font-smoothing: antialiased;
} }
#app { #bg-canvas {
height: 100vh; position: fixed; inset: 0; width: 100%; height: 100%;
display: flex; z-index: 0; opacity: 0.08; pointer-events: none;
flex-direction: column;
} }
/* Screens */ #app { position: relative; z-index: 1; height: 100vh; display: flex; }
.screen { .screen {
position: absolute; position: absolute; inset: 0;
inset: 0; display: flex; align-items: center; justify-content: center;
display: flex; transition: opacity 0.4s ease, transform 0.4s ease;
flex-direction: column;
} }
.hidden { .screen.hidden { opacity: 0; transform: scale(0.97); pointer-events: none; }
display: none !important;
}
/* Login Screen */ .hidden { display: none !important; }
#login-screen {
justify-content: center;
align-items: center;
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
}
/* ========== LOGIN ========== */
.login-container { .login-container {
background: var(--bg-secondary); background: var(--bg-elevated);
padding: 3rem; border: 1px solid var(--border);
border-radius: 16px; border-radius: var(--radius-lg);
box-shadow: 0 25px 50px var(--shadow); padding: 48px 40px 40px;
width: 100%; width: 100%;
max-width: 400px; max-width: 380px;
border: 1px solid var(--border); box-shadow: var(--shadow);
animation: floatIn 0.5s ease forwards;
} }
.logo { @keyframes floatIn {
font-size: 2.5rem; from { opacity: 0; transform: translateY(20px); }
text-align: center; to { opacity: 1; transform: translateY(0); }
color: var(--accent);
margin-bottom: 0.5rem;
} }
.subtitle { .login-brand { text-align: center; margin-bottom: 36px; }
text-align: center; .brand-icon { margin-bottom: 16px; }
color: var(--text-secondary); .brand-title {
margin-bottom: 2rem; 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 { .login-form { display: flex; flex-direction: column; gap: 20px; }
display: flex;
flex-direction: column; .field { position: relative; }
gap: 1rem; .field label {
position: absolute; top: 50%; left: 14px; transform: translateY(-50%);
font-size: 13px; color: var(--text-muted);
transition: var(--transition); pointer-events: none;
background: var(--bg-elevated); padding: 0 4px;
} }
.field input:focus + label,
input, select { .field input:not(:placeholder-shown) + label {
background: var(--bg-primary); top: 0; font-size: 11px; color: var(--accent);
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 {
input:focus, select:focus { 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; outline: none;
}
.field input:focus {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
} }
.field select {
input::placeholder { width: 100%; padding: 12px 14px; font-size: 14px;
color: var(--text-secondary); 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 { .btn-primary {
background: var(--accent); width: 100%; padding: 14px; border: none; border-radius: var(--radius-sm);
color: white; background: linear-gradient(135deg, var(--accent), #ff6b6b);
border: none; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
padding: 0.875rem 1rem; font-family: var(--font); transition: var(--transition);
border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 8px;
font-size: 1rem; min-height: 48px; position: relative;
font-weight: 600; }
cursor: pointer; .btn-primary:hover { transform: translateY(-1px); box-shadow: var(--shadow-glow); }
transition: background 0.2s; .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 { .login-hint { text-align: center; font-size: 12px; color: var(--text-muted); margin-top: 4px; }
background: var(--accent-hover);
.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 { /* ========== SIDEBAR ========== */
background: var(--text-secondary); .sidebar {
cursor: not-allowed; 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 { .btn-secondary {
background: var(--bg-tertiary); display: flex; align-items: center; gap: 6px;
color: var(--text-primary); 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); 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 { /* ========== PLAY BAR ========== */
background: var(--bg-secondary); .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 { /* ========== PACK DESCRIPTION ========== */
color: var(--error); .pack-description {
text-align: center; padding: 16px; background: var(--bg-card);
margin-top: 1rem; border: 1px solid var(--border); border-radius: var(--radius-md);
padding: 0.75rem;
border-radius: 8px;
background: rgba(239, 68, 68, 0.1);
} }
.pack-description-text {
/* Main Screen */ font-size: 13px; color: var(--text-secondary); line-height: 1.6;
#main-screen {
display: flex;
flex-direction: column;
height: 100vh;
} }
.pack-gallery {
/* Header */ display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap;
.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-item {
.header .logo { width: 120px; height: 80px; border-radius: var(--radius-sm);
font-size: 1.5rem; background: var(--bg-elevated); border: 1px solid var(--border-light);
margin: 0; display: flex; align-items: center; justify-content: center;
} color: var(--text-muted); font-size: 11px;
.account-info {
display: flex;
align-items: center;
gap: 1rem;
}
#account-name {
font-weight: 600;
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
.badge.active {
background: rgba(74, 222, 128, 0.2);
color: var(--success);
}
.badge.inactive {
background: rgba(239, 68, 68, 0.2);
color: var(--error);
}
.role-badge {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
}
/* Main Content */
.main-content {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr;
gap: 1px;
background: var(--border);
overflow: hidden; overflow: hidden;
} }
.pack-gallery-item img {
/* Sidebar */ width: 100%; height: 100%; object-fit: cover;
.sidebar {
background: var(--bg-secondary);
padding: 1rem;
overflow-y: auto;
}
.sidebar h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.instances-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.instance-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.instance-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.instance-card.selected {
border-color: var(--accent);
background: rgba(233, 69, 96, 0.1);
}
.instance-name {
font-weight: 600;
margin-bottom: 0.5rem;
}
.instance-details {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.instance-version, .instance-loader {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: var(--bg-tertiary);
}
.instance-server-version {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: rgba(251, 191, 36, 0.2);
color: var(--warning);
}
/* Logs Panel */
.logs-panel {
background: var(--bg-primary);
padding: 1rem;
display: flex;
flex-direction: column;
overflow: hidden;
}
.logs-panel h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 1rem;
}
#logs-container {
flex: 1;
background: #0d0d1a;
border-radius: 8px;
padding: 1rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.875rem;
overflow-y: auto;
line-height: 1.6;
}
.log-line {
margin-bottom: 0.25rem;
white-space: pre-wrap;
word-break: break-all;
}
.log-line.info { color: var(--text-primary); }
.log-line.success { color: var(--success); }
.log-line.warning { color: var(--warning); }
.log-line.error { color: var(--error); }
/* Footer */
.footer {
background: var(--bg-secondary);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border);
}
.instance-info {
display: flex;
gap: 1rem;
align-items: center;
}
.instance-info span {
padding: 0.5rem 1rem;
background: var(--bg-primary);
border-radius: 6px;
font-size: 0.875rem;
}
#selected-name {
font-weight: 600;
color: var(--accent);
} }
.btn-play { .btn-play {
background: var(--success); display: flex; align-items: center; gap: 8px;
color: #0a0a0a; padding: 12px 28px; border: none; border-radius: var(--radius-sm);
border: none; background: linear-gradient(135deg, var(--success), #22c55e);
padding: 0.875rem 2rem; color: #07070a; font-size: 15px; font-weight: 700; cursor: pointer;
border-radius: 8px; font-family: var(--font); transition: var(--transition);
font-size: 1.125rem; box-shadow: 0 4px 20px rgba(74,222,128,0.35);
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.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) { /* ========== NEWS ========== */
transform: scale(1.05); .news-grid {
box-shadow: 0 0 20px rgba(74, 222, 128, 0.4); display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px; overflow-y: auto; padding-bottom: 24px;
} }
.news-card {
.btn-play:disabled { background: var(--bg-card); border: 1px solid var(--border);
background: var(--text-secondary); border-radius: var(--radius-md); padding: 24px; display: flex;
cursor: not-allowed; flex-direction: column; gap: 12px; transition: var(--transition);
} }
.news-card:hover { border-color: var(--border-light); }
.btn-play.update { .news-card-badge {
background: var(--warning); 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 { .modal {
position: fixed; background: var(--bg-elevated); border: 1px solid var(--border);
inset: 0; border-radius: var(--radius-lg); width: 90%; max-width: 480px;
background: rgba(0, 0, 0, 0.8); max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);
display: flex; animation: floatIn 0.3s ease;
justify-content: center; }
align-items: center; .modal-head {
z-index: 100; 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 { .select-wrap select {
background: var(--bg-secondary); width: 100%; padding: 10px 12px; font-size: 13px;
padding: 2rem; background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: 16px; border-radius: var(--radius-sm); color: var(--text);
width: 100%; font-family: var(--font); cursor: pointer; outline: none;
max-width: 450px;
border: 1px solid var(--border);
} }
.select-wrap select:focus { border-color: var(--accent); }
.modal-content h2 { .install-progress { padding-top: 16px; border-top: 1px solid var(--border); }
margin-bottom: 1.5rem; .progress-track {
height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden;
} }
.progress-fill {
#install-form { height: 100%; width: 0%;
display: flex; background: linear-gradient(90deg, var(--accent), #ff6b6b);
flex-direction: column; border-radius: 3px; transition: width 0.3s ease;
gap: 1rem;
} }
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
#install-form label { /* ========== TOAST ========== */
display: flex; .toast {
flex-direction: column; position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
gap: 0.5rem; padding: 12px 24px; border-radius: var(--radius-sm);
font-size: 0.875rem; font-size: 13px; font-weight: 500; z-index: 200;
color: var(--text-secondary); 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 select, #install-form input { /* ========== SCROLLBAR ========== */
width: 100%; ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ========== RESPONSIVE ========== */
@media (max-width: 900px) {
.sidebar { width: 200px; min-width: 200px; }
.content { padding: 16px; }
} }
@media (max-width: 700px) {
.modal-buttons { .sidebar { width: 56px; min-width: 56px; }
display: flex; .sidebar-brand-text, .sidebar-nav .nav-btn span,
gap: 1rem; .section-header, .pack-entry-info, .user-info,
justify-content: flex-end; .sidebar-bottom .user-card .btn-icon:first-child { display: none; }
margin-top: 1rem; .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; }
} }
/* 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);
}
+39 -1
View File
@@ -15,7 +15,8 @@ def parse_args():
mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload") mode_group.add_argument("--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("--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("--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 # Additional options
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)") parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
@@ -53,6 +54,43 @@ async def run_test_mode():
logger.info("All packs validated successfully") logger.info("All packs validated successfully")
sys.exit(0) 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): def run_production_mode(host: str, port: int, workers: int):
"""Run with multiple workers""" """Run with multiple workers"""
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}") logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
+362 -39
View File
@@ -12,7 +12,7 @@ import json
import structlog import structlog
from cachetools import TTLCache from cachetools import TTLCache
from fastapi import Depends, FastAPI, HTTPException, Request, Response 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 from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
# Disable httpx debug logging # 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 pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
from models import PackMeta from models import PackMeta
from middleware import LoggingMiddleware from middleware import LoggingMiddleware
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 log_manager import init_logging
from auth import get_current_user, router as auth_router, init_db, verify_jwt from auth import get_current_user, router as auth_router, init_db, verify_jwt
from roles import Permissions, has_permission from roles import Permissions, has_permission
from admin_router import router as admin_router from admin_router import router as admin_router
import asyncio
import hashlib
import aiofiles
import mimetypes
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes # Cache for manifests - expires after 5 minutes
@@ -37,6 +42,18 @@ manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds") BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions" 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 # IP Filtering Configuration
import os import os
import middleware as mw import middleware as mw
@@ -147,7 +164,51 @@ async def lifespan(app: FastAPI):
logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True) logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True)
logger.info("All packs ready. Server is running.") logger.info("All packs ready. Server is running.")
# 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 # Scan launcher versions and generate meta
logger.info("Scanning launcher versions...") logger.info("Scanning launcher versions...")
@@ -172,6 +233,54 @@ async def lifespan(app: FastAPI):
global proxy_client global proxy_client
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
# 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 yield
# Cleanup proxy client # Cleanup proxy client
@@ -512,6 +621,136 @@ app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
# Add Logging middleware # Add Logging middleware
app.add_middleware(LoggingMiddleware) 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 # Register routers
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
@@ -583,18 +822,16 @@ async def activate_pass_page():
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ====================== # ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
@app.get("/packs") @app.get("/packs")
async def list_packs(current_user: dict = Depends(get_current_user)): async def list_packs(
"""List all available 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): if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
raise HTTPException( raise HTTPException(403, "Requires active pass")
status_code=403,
detail="Для просмотра сборок требуется активная проходка"
)
packs = [] packs = []
for pack_dir in PACKS_DIR.iterdir(): for pack_dir in PACKS_DIR.iterdir():
if pack_dir.is_dir(): if pack_dir.is_dir():
meta_path = DATA_DIR / f"{pack_dir.name}.meta" meta_path = DATA_DIR / f"{pack_dir.name}.meta"
@@ -770,13 +1007,13 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
client_ip=client_ip) client_ip=client_ip)
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
logger.info("Serving file", logger.info("Serving file",
pack=pack_name, pack=pack_name,
file=file_path, file=file_path,
size=full_path.stat().st_size, size=full_path.stat().st_size,
client_ip=client_ip) 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}") logger.warning(f"Failed to generate launcher meta: {e}")
return return
mirrors = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
meta = { meta = {
"version": version, "version": version,
"type": "builds", "type": "builds",
"release_date": datetime.utcnow().isoformat(), "release_date": datetime.utcnow().isoformat(),
"files": files "files": files,
"mirrors": mirrors
} }
try: try:
@@ -1128,16 +1368,16 @@ async def get_launcher_version():
@app.get("/launcher/download/jar") @app.get("/launcher/download/jar")
async def download_launcher_jar(): async def download_launcher_jar(request: Request = None):
"""Download launcher JAR file""" """Download launcher JAR file"""
# Prefer new shaded JAR, fallback to old
file_path = BUILDS_DIR / "zernmclauncher.jar" file_path = BUILDS_DIR / "zernmclauncher.jar"
if not file_path.exists():
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
if not file_path.exists(): if not file_path.exists():
raise HTTPException(404, "JAR file not found") 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( return FileResponse(
path=file_path, path=file_path,
filename="zernmclauncher.jar", filename="zernmclauncher.jar",
@@ -1146,13 +1386,16 @@ async def download_launcher_jar():
@app.get("/launcher/download/exe") @app.get("/launcher/download/exe")
async def download_launcher_exe(): async def download_launcher_exe(request: Request = None):
"""Download launcher EXE file (Windows)""" """Download launcher EXE file (Windows)"""
file_path = BUILDS_DIR / "ZernMCLauncher.exe" file_path = BUILDS_DIR / "ZernMCLauncher.exe"
if not file_path.exists(): if not file_path.exists():
raise HTTPException(404, "EXE file not found") 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( return FileResponse(
path=file_path, path=file_path,
filename="ZernMCLauncher.exe", filename="ZernMCLauncher.exe",
@@ -1161,17 +1404,20 @@ async def download_launcher_exe():
@app.get("/launcher/download/zip/{filename}") @app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str): async def download_launcher_zip(filename: str, request: Request = None):
"""Download specific launcher ZIP archive""" """Download specific launcher ZIP archive"""
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"] valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"): if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"):
raise HTTPException(400, "Invalid filename") raise HTTPException(400, "Invalid filename")
file_path = BUILDS_DIR / filename file_path = BUILDS_DIR / filename
if not file_path.exists(): if not file_path.exists():
raise HTTPException(404, "ZIP file not found") 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( return FileResponse(
path=file_path, path=file_path,
filename=filename, filename=filename,
@@ -1233,12 +1479,82 @@ async def get_launcher_meta_list():
versions = get_launcher_versions() versions = get_launcher_versions()
return { return {
"versions": [ "versions": [
{"version": v["version"], "meta": v["meta"]} {"version": v["version"], "meta": v["meta"]}
for v in versions for v in versions
] ]
} }
@app.get("/launcher/mirrors")
async def get_launcher_mirrors():
"""Get list of available mirrors"""
mirrors_list = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
return {"mirrors": mirrors_list}
# ====================== SYNC FOR MIRRORS ======================
def verify_sync_api_key(request: Request):
"""Verify API key for sync endpoints"""
api_key = request.headers.get("X-Sync-Key")
if not api_key or api_key != SYNC_API_KEY:
raise HTTPException(401, "Invalid or missing sync API key")
@app.get("/launcher/sync/{version}")
async def get_sync_manifest(version: str, request: Request):
"""Get sync manifest for mirror servers - returns files to download/delete"""
verify_sync_api_key(request)
if SERVER_ROLE != "main":
raise HTTPException(403, "Sync only available on main server")
# Check for incremental sync (if-modified-since)
last_sync = request.headers.get("If-Modified-Since")
# Get server meta
meta = get_launcher_version_meta(version)
if not meta:
raise HTTPException(404, f"Version {version} not found")
# Build sync response
sync_data = {
"version": version,
"files": [],
"delete": [], # Files that were removed from main
"timestamp": datetime.utcnow().isoformat()
}
for f in meta.get("files", []):
sync_data["files"].append({
"path": f["path"],
"size": f["size"],
"hash": f["hash"],
"url": f"/launcher/file/{version}/{f['path']}"
})
return sync_data
@app.get("/launcher/sync/{version}/file/{file_path:path}")
async def sync_download_file(version: str, file_path: str, request: Request):
"""Download file for mirror sync"""
verify_sync_api_key(request)
if SERVER_ROLE != "main":
raise HTTPException(403, "Sync only available on main server")
full_path = BUILDS_DIR / file_path
if ".." in file_path:
raise HTTPException(403, "Invalid path")
if not full_path.exists():
raise HTTPException(404, "File not found")
return await send_file_async(full_path, request, cache=False)
@app.get("/launcher/meta/{version}") @app.get("/launcher/meta/{version}")
async def get_launcher_version_meta_handler(version: str): async def get_launcher_version_meta_handler(version: str):
"""Get meta for specific launcher version""" """Get meta for specific launcher version"""
@@ -1305,18 +1621,22 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
full_path = alt_path full_path = alt_path
else: else:
raise HTTPException(404, "File not found: " + file_path) 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}") @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)""" """Download full ZIP for specific version (for new installs)"""
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip" zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
if not zip_path.exists(): if not zip_path.exists():
raise HTTPException(404, f"ZIP for version {version} not found") 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( return FileResponse(
path=zip_path, path=zip_path,
filename=f"ZernMC-win-{version}.zip", filename=f"ZernMC-win-{version}.zip",
@@ -1776,10 +2096,13 @@ async def global_exception_handler(request: Request, exc: Exception):
if __name__ == "__main__": if __name__ == "__main__":
args = parse_args() args = parse_args()
if args.test: if args.test:
import asyncio import asyncio
asyncio.run(run_test_mode()) asyncio.run(run_test_mode())
elif args.sync:
import asyncio
asyncio.run(run_sync_mode())
elif args.dev: elif args.dev:
run_development_mode(args.host, args.port, args.reload) run_development_mode(args.host, args.port, args.reload)
else: 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())