diff --git a/launcher/.gitignore b/launcher/.gitignore new file mode 100644 index 0000000..738b87b --- /dev/null +++ b/launcher/.gitignore @@ -0,0 +1,21 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +dependency-reduced-pom.xml + +# IDE +.idea/ +*.iml +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build outputs +server/builds/ +server/logs/ \ No newline at end of file diff --git a/launcher/bootstrap/pom.xml b/launcher/bootstrap/pom.xml new file mode 100644 index 0000000..71d5a92 --- /dev/null +++ b/launcher/bootstrap/pom.xml @@ -0,0 +1,97 @@ + + + + 4.0.0 + + + me.sashegdev + ZernMCLauncher + 1.0.8 + + + zernmc-bootstrap + jar + ZernMC Bootstrap + ZernMC Launcher - Bootstrap (auto-updater) + + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + ../../server/builds/zernmc/zernmc.jar + + + me.sashegdev.zernmc.launcher.Bootstrap + + 1.0.8 + ZernMC Bootstrap + ZernMC + + + + + + + + + com.akathist.maven.plugins.launch4j + launch4j-maven-plugin + 2.5.0 + + + l4j + package + + launch4j + + + ../../server/builds/zernmc/zernmc.exe + ../../server/builds/zernmc/zernmc.jar + gui + true + + lib/jre21-custom + 21 + + + 1.0.8.0 + 1.0.8 + ZernMC Launcher Bootstrap + 1.0.8.0 + 1.0.8 + ZernMC Launcher + ZernMC + zernmc + zernmc.exe + + + + + + + + \ No newline at end of file diff --git a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java new file mode 100644 index 0000000..0cf2ba6 --- /dev/null +++ b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java @@ -0,0 +1,204 @@ +package me.sashegdev.zernmc.launcher; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.regex.*; + +public class Bootstrap { + private static final String VERSION_FILE = "build.version"; + private static final String JAR_NAME = "bin/ZernMCLauncher.jar"; + private static final String BASE_URL = "http://87.120.187.36:1582/launcher/download?type=jar"; + + private static Path baseDir; + private static Path logDir; + + public static void main(String[] args) throws Exception { + baseDir = Paths.get("").toAbsolutePath(); + logDir = baseDir.resolve("logs"); + Files.createDirectories(logDir); + + log("=== ZernMC Launcher ==="); + + if (args.length > 0 && args[0].equals("--launcher")) { + launchUI(); + return; + } + + String currentVersion = readCurrentVersion(); + String serverVersion = getServerVersion(); + + log("Локальная: " + currentVersion); + log("Сервер: " + serverVersion); + + if (isNewer(serverVersion, currentVersion)) { + log("Доступно обновление!"); + downloadUpdate(); + } else { + log("Актуально"); + } + + launchGame(); + } + + private static void log(String msg) { + String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg; + System.out.println(entry); + try { + Files.writeString(logDir.resolve("launcher.log"), entry + "\n", + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (Exception ignored) {} + } + + private static String readCurrentVersion() { + Path f = baseDir.resolve(VERSION_FILE); + try { + if (Files.exists(f)) return Files.readString(f).trim(); + } catch (Exception ignored) {} + return "0.0.0"; + } + + private static String getServerVersion() { + try { + URL url = new URL(BASE_URL.replace("download?type=jar", "version")); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + if (conn.getResponseCode() == 200) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line = br.readLine(); + if (line != null && line.contains("version")) { + int start = line.indexOf("\"version\":\""); + if (start >= 0) { + start += 11; + int end = line.indexOf("\"", start); + if (end > start) { + return line.substring(start, end); + } + } + } + } + } + } catch (Exception ignored) {} + return "unknown"; + } + + private static boolean isNewer(String server, String current) { + try { + String[] sa = server.split("\\."); + String[] ca = current.split("\\."); + for (int i = 0; i < Math.min(sa.length, ca.length); i++) { + int sv = Integer.parseInt(sa[i]); + int cv = Integer.parseInt(ca[i]); + if (sv > cv) return true; + if (sv < cv) return false; + } + return sa.length > ca.length; + } catch (Exception ignored) {} + return false; + } + + private static void downloadUpdate() throws Exception { + URL url = new URL(BASE_URL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + + if (conn.getResponseCode() == 200) { + Path tmp = baseDir.resolve(JAR_NAME + ".new"); + try (InputStream in = conn.getInputStream(); + OutputStream out = new FileOutputStream(tmp.toFile())) { + byte[] buf = new byte[8192]; + int len; + long total = 0; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + total += len; + System.out.print("\rСкачано: " + (total/1024/1024) + " MB"); + } + } + log("Скачано"); + + Path jarFile = baseDir.resolve(JAR_NAME); + Path backup = baseDir.resolve(JAR_NAME + ".old"); + + if (Files.exists(jarFile)) Files.move(jarFile, backup); + Files.move(tmp, jarFile); + if (Files.exists(backup)) Files.delete(backup); + + String newVersion = getServerVersion(); + Files.writeString(baseDir.resolve(VERSION_FILE), newVersion); + log("Обновлено до v" + newVersion); + } + } + +private static void launchUI() throws Exception { + // Запускаем JAR файл с аргументом --cli + Path javaBin = baseDir.resolve("lib").resolve("jre21-custom").resolve("bin").resolve("java"); + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + javaBin = javaBin.resolveSibling("java.exe"); + } + + if (!Files.exists(javaBin)) { + javaBin = Paths.get(System.getProperty("java.home"), "bin", "java"); + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + javaBin = javaBin.resolveSibling("java.exe"); + } + } + + Path jarPath = baseDir.resolve(JAR_NAME); + + ProcessBuilder pb = new ProcessBuilder( + javaBin.toAbsolutePath().toString(), + "-jar", + jarPath.toAbsolutePath().toString(), + "--cli" + ); + pb.directory(baseDir.toFile()); + pb.inheritIO(); + Process p = pb.start(); + int code = p.waitFor(); + System.exit(code); + } + + private static void launchGame() throws Exception { + Path javaBin = baseDir.resolve("lib").resolve("jre21-custom").resolve("bin").resolve("java"); + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + javaBin = javaBin.resolveSibling("java.exe"); + } + + if (!Files.exists(javaBin)) { + javaBin = baseDir.resolve("bin").resolve("java"); + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + javaBin = javaBin.resolveSibling("java.exe"); + } + } + + if (!Files.exists(javaBin)) { + javaBin = Paths.get(System.getProperty("java.home"), "bin", "java"); + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + javaBin = javaBin.resolveSibling("java.exe"); + } + } + + log("Java: " + javaBin); + log("Запуск..."); + + Path jarPath = baseDir.resolve(JAR_NAME); + + ProcessBuilder pb = new ProcessBuilder( + javaBin.toAbsolutePath().toString(), + "-jar", + jarPath.toAbsolutePath().toString(), + "--launcher" + ); + pb.directory(baseDir.toFile()); + pb.inheritIO(); + Process p = pb.start(); + int code = p.waitFor(); + log("Завершено: " + code); + System.exit(code); + } +} \ No newline at end of file diff --git a/launcher/bootstrap/src/test/java/me/sashegdev/zernmc/launcher/BootstrapVersionTest.java b/launcher/bootstrap/src/test/java/me/sashegdev/zernmc/launcher/BootstrapVersionTest.java new file mode 100644 index 0000000..8493f0b --- /dev/null +++ b/launcher/bootstrap/src/test/java/me/sashegdev/zernmc/launcher/BootstrapVersionTest.java @@ -0,0 +1,68 @@ +package me.sashegdev.zernmc.launcher; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class BootstrapVersionTest { + + @Test + public void testVersionParsing() { + assertEquals("1.0.8", getVersion("{\"version\":\"1.0.8\",\"updated_at\":\"2026-05-06T04:38:07\"}")); + assertEquals("1.0.2", getVersion("{\"version\":\"1.0.2\"}")); + assertEquals("2.0.0", getVersion("{\"version\":\"2.0.0\",\"download_jar\":\"/launcher/download/jar\"}")); + assertEquals("unknown", getVersion("invalid json")); + assertEquals("unknown", getVersion("{\"ver\":\"1.0.8\"}")); + assertEquals("unknown", getVersion("")); + assertEquals("unknown", getVersion(null)); + } + + @Test + public void testVersionComparison() { + assertTrue(isNewer("1.0.8", "1.0.7")); + assertTrue(isNewer("1.0.8", "1.0.2")); + assertTrue(isNewer("2.0.0", "1.0.8")); + assertFalse(isNewer("1.0.8", "1.0.8")); + assertFalse(isNewer("1.0.7", "1.0.8")); + assertTrue(isNewer("1.0.10", "1.0.9")); + assertTrue(isNewer("1.0.9", "1.0.8")); + assertFalse(isNewer("unknown", "1.0.8")); + assertFalse(isNewer("1.0.8", "unknown")); + } + + @Test + public void testEdgeCases() { + assertEquals("unknown", getVersion(null)); + assertEquals("unknown", getVersion("")); + assertFalse(isNewer("unknown", "1.0.8")); + assertFalse(isNewer("1.0.8", "unknown")); + } + + private String getVersion(String line) { + if (line != null && line.contains("version")) { + int start = line.indexOf("\"version\":\""); + if (start >= 0) { + start += 11; + int end = line.indexOf("\"", start); + if (end > start) { + return line.substring(start, end); + } + } + } + return "unknown"; + } + + private boolean isNewer(String server, String current) { + try { + String[] sa = server.split("\\."); + String[] ca = current.split("\\."); + for (int i = 0; i < Math.min(sa.length, ca.length); i++) { + int sv = Integer.parseInt(sa[i]); + int cv = Integer.parseInt(ca[i]); + if (sv > cv) return true; + if (sv < cv) return false; + } + return sa.length > ca.length; + } catch (Exception ignored) {} + return false; + } +} \ No newline at end of file diff --git a/launcher/launcher/pom.xml b/launcher/launcher/pom.xml new file mode 100644 index 0000000..1985917 --- /dev/null +++ b/launcher/launcher/pom.xml @@ -0,0 +1,152 @@ + + + + 4.0.0 + + + me.sashegdev + ZernMCLauncher + 1.0.8 + + + zernmc-launcher + jar + ZernMC Launcher + ZernMC Launcher - UI + + + + org.openjfx + javafx-controls + 21 + linux + + + org.openjfx + javafx-web + 21 + linux + + + org.openjfx + javafx-media + 21 + linux + + + org.apache.httpcomponents + httpclient + 4.5.14 + + + com.fasterxml.jackson.core + jackson-databind + 2.15.2 + + + com.google.code.gson + gson + 2.10.1 + + + org.json + json + 20230227 + + + net.java.dev.jna + jna + 5.13.0 + + + org.jline + jline + 3.21.0 + + + org.fusesource.jansi + jansi + 2.4.0 + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + org.mockito + mockito-core + 5.7.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + ../../server/builds/zernmc/bin/ZernMCLauncher.jar + + + me.sashegdev.zernmc.launcher.Main + + 1.0.8 + ZernMC Launcher + ZernMC + + + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + package + run + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java new file mode 100644 index 0000000..cbabe25 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java @@ -0,0 +1,241 @@ +package me.sashegdev.zernmc.launcher; + +import me.sashegdev.zernmc.launcher.api.LauncherAPI; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.menu.*; +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.ui.jcef.UILauncher; +import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher; +import me.sashegdev.zernmc.launcher.utils.*; +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; + +public class Main { + + private static final String CURRENT_VERSION = Version.getCurrentVersion(); + private static final LauncherAPI api = new LauncherAPI(); + + public static void main(String[] args) throws IOException { + System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true"); + System.setProperty("file.encoding", "UTF-8"); + System.setProperty("sun.err.encoding", "UTF-8"); + System.setProperty("sun.stdout.encoding", "UTF-8"); + + if (args.length > 0 && args[0].equals("--cli")) { + launchCLI(args); + return; + } + + try { + launchUI(args); + } catch (Exception e) { + System.out.println("UI недоступен, переход в CLI режим: " + e.getMessage()); + launchCLI(args); + } + } + + private static void launchUI(String[] args) throws Exception { + System.out.println("Запуск JFX UI..."); + JFXLauncher.main(args); + } + + private static void launchCLI(String[] args) throws IOException { + ZAnsi.install(); + System.out.print("\033[H\033[2J"); + System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION)); + + ZHttpClient.checkAllServicesOnStartup(); + checkAndAutoUpdateLauncher(); + + System.out.println(ZAnsi.cyan("Проверка авторизации...")); + var sessionResponse = api.checkSession(); + + if (!sessionResponse.isSuccess()) { + LoginMenu loginMenu = new LoginMenu(); + boolean loggedIn = loginMenu.show(); + if (!loggedIn) { + System.out.println(ZAnsi.yellow("До свидания!")); + ZAnsi.uninstall(); + System.exit(0); + } + } else { + var sessionInfo = sessionResponse.getData(); + System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.getUsername() + "!")); + } + + try { + mainLoop(); + } catch (Exception e) { + System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage())); + e.printStackTrace(); + } finally { + ZAnsi.uninstall(); + } + } + + private static void checkAndAutoUpdateLauncher() { + System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера...")); + try { + String json = ZHttpClient.getLauncherVersionInfo(); + String serverVersion = extractVersion(json); + + System.out.println(ZAnsi.white("Текущая версия: ") + CURRENT_VERSION); + System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion); + + if (Version.isNewer(CURRENT_VERSION, serverVersion)) { + System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")")); + System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n")); + performAutoUpdate(serverVersion); + restartLauncher(); + } else { + System.out.println(ZAnsi.brightGreen("Лаунчер актуален.")); + } + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера.")); + System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); + } + } + + private static void performAutoUpdate(String newVersion) throws Exception { + String downloadUrl = ZHttpClient.getBaseUrl() + "/launcher/download?type=jar"; + Path currentJar = getCurrentJarPath(); + Path tempJar = currentJar.getParent().resolve("zernmc-launcher-new.jar"); + + System.out.println(ZAnsi.cyan("Скачивание версии " + newVersion + "...")); + + HttpClient client = HttpClient.newBuilder().build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(java.net.URI.create(downloadUrl)) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(tempJar)); + + if (response.statusCode() != 200) { + throw new IOException("Сервер вернул код: " + response.statusCode()); + } + + long size = Files.size(tempJar); + System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)")); + + Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING); + System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!")); + } + + private static void restartLauncher() { + try { + String javaPath = System.getProperty("java.home") + "/bin/java"; + String jarPath = getCurrentJarPath().toAbsolutePath().toString(); + + System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией...")); + + new ProcessBuilder(javaPath, "-jar", jarPath) + .inheritIO() + .start(); + + System.exit(0); + } catch (Exception e) { + System.err.println(ZAnsi.brightRed("Не удалось перезапустить лаунчер.")); + System.exit(1); + } + } + + private static String extractVersion(String json) { + try { + return json.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1"); + } catch (Exception e) { + return "unknown"; + } + } + + private static Path getCurrentJarPath() { + try { + return Path.of(Main.class.getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()); + } catch (Exception e) { + return Path.of("zernmc-launcher-1.0-jar-with-dependencies.jar"); + } + } + + // ====================== ГЛАВНЫЙ ЦИКЛ ====================== + private static void mainLoop() throws Exception { + if (Config.isZernMCBuild()) { + zernMCFlow(); + } else { + globalFlow(); + } + } + + // ====================== ZERNMC FLOW ====================== + private static void zernMCFlow() throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("=== ZernMC Private Launcher ===")); + + // 1. Проверка подключения к серверу + System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу...")); + try { + String response = ZHttpClient.get("/health"); + System.out.println(ZAnsi.brightGreen("✓ Сервер доступен")); + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу")); + System.out.println(ZAnsi.white("Ошибка: " + e.getMessage())); + ConsoleUtils.pause(); + System.exit(1); + } + + // 2. Авторизация + boolean sessionRestored = AuthManager.loadSavedSession(); + if (!sessionRestored) { + LoginMenu loginMenu = new LoginMenu(); + boolean loggedIn = loginMenu.show(); + if (!loggedIn) { + System.exit(0); + } + } else { + System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!")); + } + + // 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow) + LaunchMenu launchMenu = new LaunchMenu(); + launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри + } + + // ====================== GLOBAL FLOW ====================== + private static void globalFlow() throws Exception { + while (true) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("=== ZernMC Launcher ===")); + + List options = List.of( + "Запустить игру", + "Проверка обновлений", + "Настройки", + "Проверка подключения к серверам", + "Выход" + ); + + ArrowMenu menu = new ArrowMenu("Главное меню", options); + int choice = menu.show(); + + if (choice == -1 || choice == 4) { + System.out.println(ZAnsi.yellow("До свидания!")); + break; + } + + switch (choice) { + case 0 -> new LaunchMenu().show(); // обычный LaunchMenu + case 1 -> new UpdateMenu().show(); + case 2 -> new SettingsMenu().show(); + case 3 -> new ServerCheckMenu().show(); + } + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/ApiResponse.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/ApiResponse.java new file mode 100644 index 0000000..c2337ae --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/ApiResponse.java @@ -0,0 +1,33 @@ +package me.sashegdev.zernmc.launcher.api; + +public class ApiResponse { + private boolean success; + private T data; + private String error; + + public ApiResponse(boolean success, T data, String error) { + this.success = success; + this.data = data; + this.error = error; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, null); + } + + public static ApiResponse error(String error) { + return new ApiResponse<>(false, null, error); + } + + public boolean isSuccess() { + return success; + } + + public T getData() { + return data; + } + + public String getError() { + return error; + } +} diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/LauncherAPI.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/LauncherAPI.java new file mode 100644 index 0000000..a0c4795 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/LauncherAPI.java @@ -0,0 +1,74 @@ +package me.sashegdev.zernmc.launcher.api; + +import me.sashegdev.zernmc.launcher.api.auth.AuthService; +import me.sashegdev.zernmc.launcher.api.instance.InstanceService; +import me.sashegdev.zernmc.launcher.api.launch.LaunchService; + +import java.util.List; + +/** + * Центральный фасад для внутреннего API лаунчера. + * Используется как единая точка входа для UI и других компонентов. + */ +public class LauncherAPI { + + private final AuthService authService; + private final InstanceService instanceService; + private final LaunchService launchService; + + public LauncherAPI() { + this.authService = new AuthService(); + this.instanceService = new InstanceService(); + this.launchService = new LaunchService(); + } + + public AuthService auth() { + return authService; + } + + public InstanceService instances() { + return instanceService; + } + + public LaunchService launch() { + return launchService; + } + + // ====================== Удобные методы ====================== + + public boolean isLoggedIn() { + return authService.isLoggedIn(); + } + + public String getCurrentUsername() { + return authService.getCurrentUsername(); + } + + public ApiResponse checkSession() { + return authService.checkSession(); + } + + public ApiResponse login(String username, String password) { + return authService.login(username, password); + } + + public ApiResponse logout() { + return authService.logout(); + } + + public ApiResponse> getAllInstances() { + return instanceService.getAllInstances(); + } + + public ApiResponse getLaunchInfo(String instanceName) { + return launchService.getLaunchInfo(instanceName); + } + + public ApiResponse prepareLaunch(String instanceName) { + return launchService.prepareLaunch(instanceName); + } + + public ApiResponse launch(String instanceName) { + return launchService.launch(instanceName); + } +} diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/auth/AuthService.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/auth/AuthService.java new file mode 100644 index 0000000..48097c0 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/auth/AuthService.java @@ -0,0 +1,134 @@ +package me.sashegdev.zernmc.launcher.api.auth; + +import me.sashegdev.zernmc.launcher.api.ApiResponse; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.IOException; + +public class AuthService { + + public ApiResponse login(String username, String password) { + try { + AuthManager.AuthResult result = AuthManager.login(username, password); + if (result.success) { + LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken()); + return ApiResponse.success(loginResult); + } + return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль"); + } catch (Exception e) { + return ApiResponse.error("Ошибка авторизации: " + e.getMessage()); + } + } + + public ApiResponse logout() { + try { + AuthManager.logout(); + return ApiResponse.success(true); + } catch (Exception e) { + return ApiResponse.error("Ошибка при выходе: " + e.getMessage()); + } + } + + public ApiResponse checkSession() { + try { + boolean restored = AuthManager.loadSavedSession(); + if (restored) { + SessionInfo info = new SessionInfo( + AuthManager.getUsername(), + AuthManager.getAccessToken(), + AuthManager.hasActivePass() + ); + return ApiResponse.success(info); + } + return ApiResponse.error("Сессия не найдена"); + } catch (Exception e) { + return ApiResponse.error("Ошибка проверки сессии: " + e.getMessage()); + } + } + + public ApiResponse activatePass(String passCode) { + try { + String response = post("/auth/pass/activate", + "{\"code\":\"" + passCode + "\"}"); + return ApiResponse.success(true); + } catch (Exception e) { + return ApiResponse.error("Ошибка активации проходки: " + e.getMessage()); + } + } + + private String post(String endpoint, String jsonBody) throws Exception { + String fullUrl = ZHttpClient.getBaseUrl() + endpoint; + java.net.URL url = new java.net.URL(fullUrl); + java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection(); + + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); + + if (AuthManager.getAccessToken() != null && !AuthManager.getAccessToken().equals("0")) { + conn.setRequestProperty("Authorization", "Bearer " + AuthManager.getAccessToken()); + } + + conn.setDoOutput(true); + + try (var os = conn.getOutputStream()) { + byte[] input = jsonBody.getBytes(java.nio.charset.StandardCharsets.UTF_8); + os.write(input); + } + + int statusCode = conn.getResponseCode(); + var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream(); + + String responseBody; + try (var scanner = new java.util.Scanner(is, java.nio.charset.StandardCharsets.UTF_8.name())) { + responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; + } + + conn.disconnect(); + + if (statusCode != 200) { + throw new IOException("HTTP " + statusCode + ": " + responseBody); + } + + return responseBody; + } + + public boolean isLoggedIn() { + return AuthManager.isLoggedIn(); + } + + public String getCurrentUsername() { + return AuthManager.getUsername(); + } + + public static class LoginResult { + private String username; + private String token; + + public LoginResult(String username, String token) { + this.username = username; + this.token = token; + } + + public String getUsername() { return username; } + public String getToken() { return token; } + } + + public static class SessionInfo { + private String username; + private String token; + private boolean passActive; + + public SessionInfo(String username, String token, boolean passActive) { + this.username = username; + this.token = token; + this.passActive = passActive; + } + + public String getUsername() { return username; } + public String getToken() { return token; } + public boolean isPassActive() { return passActive; } + } +} diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/instance/InstanceService.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/instance/InstanceService.java new file mode 100644 index 0000000..7ce0647 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/instance/InstanceService.java @@ -0,0 +1,98 @@ +package me.sashegdev.zernmc.launcher.api.instance; + +import me.sashegdev.zernmc.launcher.api.ApiResponse; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +public class InstanceService { + + public ApiResponse> getAllInstances() { + try { + List instances = InstanceManager.getAllInstances(); + List infoList = instances.stream() + .map(this::toInstanceInfo) + .collect(Collectors.toList()); + return ApiResponse.success(infoList); + } catch (IOException e) { + return ApiResponse.error("Ошибка получения списка сборок: " + e.getMessage()); + } + } + + public ApiResponse getInstance(String name) { + try { + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + return ApiResponse.error("Сборка не найдена: " + name); + } + return ApiResponse.success(toInstanceInfo(instance)); + } catch (Exception e) { + return ApiResponse.error("Ошибка получения сборки: " + e.getMessage()); + } + } + + public ApiResponse createInstance(String name) { + try { + boolean created = InstanceManager.createInstanceFolder(name); + if (!created) { + return ApiResponse.error("Сборка с таким именем уже существует: " + name); + } + Instance instance = InstanceManager.getInstance(name); + return ApiResponse.success(toInstanceInfo(instance)); + } catch (IOException e) { + return ApiResponse.error("Ошибка создания сборки: " + e.getMessage()); + } + } + + public ApiResponse deleteInstance(String name) { + try { + boolean deleted = InstanceManager.deleteInstance(name); + if (!deleted) { + return ApiResponse.error("Не удалось удалить сборку: " + name); + } + return ApiResponse.success(true); + } catch (Exception e) { + return ApiResponse.error("Ошибка удаления сборки: " + e.getMessage()); + } + } + + public ApiResponse isInstanceExists(String name) { + try { + Instance instance = InstanceManager.getInstance(name); + return ApiResponse.success(instance != null); + } catch (Exception e) { + return ApiResponse.error("Ошибка проверки сборки: " + e.getMessage()); + } + } + + private InstanceInfo toInstanceInfo(Instance instance) { + return new InstanceInfo( + instance.getName(), + instance.getPath().toString(), + instance.getMinecraftVersion(), + instance.getLoaderType() + ); + } + + public static class InstanceInfo { + private String name; + private String path; + private String version; + private String loaderType; + + public InstanceInfo(String name, String path, String version, String loaderType) { + this.name = name; + this.path = path; + this.version = version; + this.loaderType = loaderType; + } + + public String getName() { return name; } + public String getPath() { return path; } + public String getVersion() { return version; } + public String getLoaderType() { return loaderType; } + } +} diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/launch/LaunchService.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/launch/LaunchService.java new file mode 100644 index 0000000..c203138 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/api/launch/LaunchService.java @@ -0,0 +1,157 @@ +package me.sashegdev.zernmc.launcher.api.launch; + +import me.sashegdev.zernmc.launcher.api.ApiResponse; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; +import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +public class LaunchService { + + public ApiResponse prepareLaunch(String instanceName) { + try { + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + return ApiResponse.error("Сборка не найдена: " + instanceName); + } + + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + LaunchOptions options = new LaunchOptions(); + + List command = builder.build(options); + + LaunchInfo info = new LaunchInfo( + instanceName, + command, + instance.getPath().toString() + ); + return ApiResponse.success(info); + } catch (Exception e) { + return ApiResponse.error("Ошибка подготовки запуска: " + e.getMessage()); + } + } + + public ApiResponse launch(String instanceName) { + try { + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + return ApiResponse.error("Сборка не найдена: " + instanceName); + } + + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + LaunchOptions options = new LaunchOptions(); + + List command = builder.build(options); + + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.directory(instance.getPath().toFile()); + processBuilder.inheritIO(); + + Process process = processBuilder.start(); + + ProcessInfo info = new ProcessInfo( + instanceName, + process.pid(), + "RUNNING" + ); + return ApiResponse.success(info); + } catch (Exception e) { + return ApiResponse.error("Ошибка запуска: " + e.getMessage()); + } + } + + public ApiResponse isReady(String instanceName) { + try { + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + return ApiResponse.error("Сборка не найдена: " + instanceName); + } + + Path versionJson = instance.getPath().resolve("version.json"); + boolean hasVersionJson = versionJson.toFile().exists(); + + return ApiResponse.success(hasVersionJson); + } catch (Exception e) { + return ApiResponse.error("Ошибка проверки готовности: " + e.getMessage()); + } + } + + public ApiResponse getLaunchInfo(String instanceName) { + try { + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + return ApiResponse.error("Сборка не найдена: " + instanceName); + } + + InstanceInfo info = new InstanceInfo( + instance.getName(), + instance.getMinecraftVersion(), + instance.getLoaderType(), + instance.getLoaderVersion(), + instance.getAssetIndex() + ); + return ApiResponse.success(info); + } catch (Exception e) { + return ApiResponse.error("Ошибка получения информации: " + e.getMessage()); + } + } + + public static class LaunchInfo { + private String instanceName; + private List command; + private String workingDirectory; + + public LaunchInfo(String instanceName, List command, String workingDirectory) { + this.instanceName = instanceName; + this.command = command; + this.workingDirectory = workingDirectory; + } + + public String getInstanceName() { return instanceName; } + public List getCommand() { return command; } + public String getWorkingDirectory() { return workingDirectory; } + } + + public static class ProcessInfo { + private String instanceName; + private long pid; + private String status; + + public ProcessInfo(String instanceName, long pid, String status) { + this.instanceName = instanceName; + this.pid = pid; + this.status = status; + } + + public String getInstanceName() { return instanceName; } + public long getPid() { return pid; } + public String getStatus() { return status; } + } + + public static class InstanceInfo { + private String name; + private String minecraftVersion; + private String loaderType; + private String loaderVersion; + private String assetIndex; + + public InstanceInfo(String name, String minecraftVersion, String loaderType, + String loaderVersion, String assetIndex) { + this.name = name; + this.minecraftVersion = minecraftVersion; + this.loaderType = loaderType; + this.loaderVersion = loaderVersion; + this.assetIndex = assetIndex; + } + + public String getName() { return name; } + public String getMinecraftVersion() { return minecraftVersion; } + public String getLoaderType() { return loaderType; } + public String getLoaderVersion() { return loaderVersion; } + public String getAssetIndex() { return assetIndex; } + } +} diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java new file mode 100644 index 0000000..d169b23 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java @@ -0,0 +1,354 @@ +package me.sashegdev.zernmc.launcher.auth; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.annotations.SerializedName; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class AuthManager { + + private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json"); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private static volatile AuthSession session = null; + private static volatile UserInfo userInfo = null; + + // === Роли === + public static final int ROLE_USER = 0; + public static final int ROLE_PASS_HOLDER = 1; + public static final int ROLE_MODERATOR = 2; + public static final int ROLE_ELDER = 3; + public static final int ROLE_CREATOR = 4; + + // === Права доступа === + public static final String PERM_VIEW_PACKS = "view_packs"; + public static final String PERM_DOWNLOAD_PACK = "download_pack"; + + public static boolean loadSavedSession() { + if (!Files.exists(AUTH_FILE)) return false; + try { + String json = Files.readString(AUTH_FILE); + AuthSession loaded = GSON.fromJson(json, AuthSession.class); + if (loaded == null || loaded.accessToken == null) return false; + + session = loaded; + userInfo = fetchUserInfo(); + + if (isAccessTokenExpired()) { + return tryRefresh(); + } + return true; + } catch (Exception e) { + return false; + } + } + + // ====================== АВТОРИЗАЦИЯ ====================== + public static AuthResult login(String username, String password) { + return authRequest("/auth/login", username, password); + } + + public static AuthResult register(String username, String password) { + return authRequest("/auth/register", username, password); + } + + private static AuthResult authRequest(String endpoint, String username, String password) { + try { + String body = GSON.toJson(new LoginRequest(username, password)); + SimpleHttpResponse resp = post(endpoint, body); + + if (resp.statusCode() == 200) { + session = GSON.fromJson(resp.body(), AuthSession.class); + session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn; + saveSession(); + userInfo = fetchUserInfo(); + return AuthResult.ok(); + } else if (resp.statusCode() == 422) { + return AuthResult.fail("Ошибка валидации: " + extractError(resp.body())); + } else { + return AuthResult.fail(extractError(resp.body())); + } + } catch (Exception e) { + e.printStackTrace(); + return AuthResult.fail("Ошибка соединения: " + e.getMessage()); + } + } + + public static void logout() { + if (session != null && session.refreshToken != null) { + try { + post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}"); + } catch (Exception ignored) {} + } + session = null; + userInfo = null; + try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} + } + + public static boolean isLoggedIn() { + return session != null && session.accessToken != null; + } + + public static String getUsername() { + return session != null ? session.username : "Player"; + } + + public static String getUuid() { + return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000"; + } + + public static String getAccessToken() { + if (session == null) return "0"; + if (isAccessTokenExpired()) { + tryRefresh(); + } + return session != null && session.accessToken != null ? session.accessToken : "0"; + } + + private static boolean isAccessTokenExpired() { + if (session == null) return true; + return System.currentTimeMillis() / 1000L >= session.expiresAt - 300; + } + + private static boolean tryRefresh() { + if (session == null || session.refreshToken == null) return false; + try { + String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}"; + SimpleHttpResponse resp = post("/auth/refresh", body); + + if (resp.statusCode() == 200) { + AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class); + newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn; + session = newSession; + userInfo = fetchUserInfo(); + saveSession(); + return true; + } + } catch (Exception ignored) {} + session = null; + userInfo = null; + try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} + return false; + } + + private static void saveSession() { + try { + Files.createDirectories(AUTH_FILE.getParent()); + Files.writeString(AUTH_FILE, GSON.toJson(session)); + } catch (IOException e) { + System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage())); + } + } + + // ==================== ПОЛУЧЕНИЕ ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ==================== + private static UserInfo fetchUserInfo() { + if (!isLoggedIn() || session.accessToken == null) return null; + + try { + // Используем существующий метод ZHttpClient.get() + вручную добавляем токен + java.net.HttpURLConnection conn = null; + try { + URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me"); + conn = (java.net.HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Authorization", "Bearer " + session.accessToken); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) return null; + + StringBuilder response = new StringBuilder(); + try (var reader = new java.io.BufferedReader( + new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + return GSON.fromJson(response.toString(), UserInfo.class); + } finally { + if (conn != null) conn.disconnect(); + } + } catch (Exception e) { + System.err.println("Не удалось получить UserInfo: " + e.getMessage()); + return null; + } + } + + // ==================== ПРОВЕРКИ ПРАВ ==================== + public static boolean hasPass() { + if (userInfo != null) return userInfo.has_pass; + return getRole() >= ROLE_PASS_HOLDER; + } + + public static boolean canViewPacks() { + if (userInfo != null && userInfo.permissions != null) { + return userInfo.permissions.contains(PERM_VIEW_PACKS); + } + return hasPass(); // fallback для старых аккаунтов + } + + public static boolean canDownloadPacks() { + if (userInfo != null && userInfo.permissions != null) { + return userInfo.permissions.contains(PERM_DOWNLOAD_PACK); + } + return hasPass(); // fallback + } + + public static int getRole() { + return session != null ? session.role : ROLE_USER; + } + + // ====================== POST ====================== + private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception { + String fullUrl = ZHttpClient.getBaseUrl() + endpoint; + HttpURLConnection conn = null; + + try { + URL url = new URL(fullUrl); + conn = (HttpURLConnection) url.openConnection(); + + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); + conn.setRequestProperty("Connection", "close"); + + if (session != null && session.accessToken != null) { + conn.setRequestProperty("Authorization", "Bearer " + session.accessToken); + } + + conn.setDoOutput(true); + conn.setConnectTimeout(15000); + conn.setReadTimeout(15000); + + byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(input.length); + + try (var os = conn.getOutputStream()) { + os.write(input); + os.flush(); + } + + int statusCode = conn.getResponseCode(); + var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream(); + + String responseBody; + try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) { + responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; + } + + return new SimpleHttpResponse(statusCode, responseBody); + + } finally { + if (conn != null) conn.disconnect(); + } + } + + private static String extractError(String body) { + try { + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + if (json.has("detail")) { + if (json.get("detail").isJsonArray()) { + return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString(); + } + return json.get("detail").getAsString(); + } + } catch (Exception ignored) {} + return body.length() > 200 ? body.substring(0, 200) + "..." : body; + } + + public static boolean hasActivePass() { + if (!isLoggedIn()) return false; + try { + String response = ZHttpClient.get("/auth/pass/my"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + return json.has("has_active") && json.get("has_active").getAsBoolean(); + } catch (Exception e) { + System.err.println(ZAnsi.red("Не удалось проверить проходки: ") + e.getMessage()); + return false; + } + } + + public static String getPassStatus() { + if (!isLoggedIn()) return "Не авторизован"; + try { + String response = ZHttpClient.get("/auth/pass/my"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean(); + return hasActive ? "Есть активная проходка" : "Проходка отсутствует"; + } catch (Exception e) { + return "Ошибка проверки"; + } + } + + // ====================== ВНУТРЕННИЕ КЛАССЫ ====================== + public static class AuthSession { + @SerializedName("access_token") public String accessToken; + @SerializedName("refresh_token") public String refreshToken; + @SerializedName("expires_in") public int expiresIn; + public transient long expiresAt; + public String username; + public String uuid; + public int role; + } + + public static class UserInfo { + public int id; + public String username; + public String uuid; + public int role; + public String role_name; + public boolean has_pass; + public List permissions; + + public boolean hasPermission(String perm) { + return permissions != null && permissions.contains(perm); + } + } + + private static class LoginRequest { + final String username; + final String password; + LoginRequest(String u, String p) { + this.username = u; + this.password = p; + } + } + + public static class AuthResult { + public final boolean success; + public final String error; + private AuthResult(boolean s, String e) { success = s; error = e; } + public static AuthResult ok() { return new AuthResult(true, null); } + public static AuthResult fail(String msg) { return new AuthResult(false, msg); } + } +} + +// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ====================== +class SimpleHttpResponse { + final int statusCode; + final String body; + + SimpleHttpResponse(int statusCode, String body) { + this.statusCode = statusCode; + this.body = body; + } + + int statusCode() { return statusCode; } + String body() { return body; } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java new file mode 100644 index 0000000..9d75539 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java @@ -0,0 +1,770 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; +import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib; +import me.sashegdev.zernmc.launcher.minecraft.PackDownloader; +import me.sashegdev.zernmc.launcher.minecraft.ServerPack; +import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; +import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion; +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.Input; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.awt.*; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class LaunchMenu { + + public void show() throws Exception { + if (Config.isZernMCBuild()) { + showZernMCOnly(); + } else { + showGlobal(); + } + } + + // ====================== ZERNMC BUILD ====================== + private void showZernMCOnly() throws Exception { + while (true) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("=== ZernMC Private Launcher ===")); + System.out.println(ZAnsi.cyan("Доступны только серверные сборки")); + + if (!awaitActivePass()) { + return; + } + + PackDownloader tempDownloader = new PackDownloader(null); + List availablePacks = tempDownloader.getAvailablePacks(); + + if (availablePacks.isEmpty()) { + System.out.println(ZAnsi.yellow("На данный момент нет доступных сборок на сервере.")); + ConsoleUtils.pause(); + return; + } + + List options = availablePacks.stream() + .map(p -> String.format("%s [%s + %s v%d] — %d файлов", + p.getName(), + p.getMinecraftVersion(), + p.getLoaderType(), + p.getVersion(), + p.getFilesCount())) + .collect(Collectors.toList()); + + options.add("Назад в главное меню"); + + ArrowMenu menu = new ArrowMenu("Выберите сборку", options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return; + + ServerPack selected = availablePacks.get(choice); + installAndRunServerPack(selected); + } + } + + private boolean awaitActivePass() throws Exception { + if (AuthManager.hasActivePass()) { + System.out.println(ZAnsi.brightGreen("✓ Активная проходка подтверждена")); + return true; + } + + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.brightRed("У вас нет активной проходки!")); + System.out.println(ZAnsi.white("Для доступа к сборкам ZernMC требуется активная проходка.")); + System.out.println(); + + openActivationWebsite(); + + System.out.println(ZAnsi.cyan("Ожидаем активацию проходки... (проверка каждые 10 секунд)")); + System.out.println(ZAnsi.white("Нажмите Enter для отмены")); + + for (int i = 0; i < 60; i++) { + try { + if (System.in.available() > 0) { + Input.readLine(); + System.out.println(ZAnsi.yellow("\nОжидание отменено.")); + return false; + } + } catch (Exception ignored) {} + + Thread.sleep(10000); + + if (AuthManager.hasActivePass()) { + System.out.println(ZAnsi.brightGreen("\n✓ Проходка успешно активирована!")); + return true; + } + + System.out.print(ZAnsi.cyan(".")); + if ((i + 1) % 6 == 0) System.out.println(); + } + + System.out.println(ZAnsi.brightRed("\n\nВремя ожидания истекло.")); + return false; + } + + private void openActivationWebsite() { + //String url = "https://launcher.ru.zernmc.ru/activate-pass"; + String url = ZHttpClient.getBaseUrl() + "/activate-pass"; + + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(new URI(url)); + System.out.println(ZAnsi.cyan("Браузер открыт: " + url)); + } else { + System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически.")); + System.out.println(ZAnsi.white("Откройте вручную: " + url)); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("Ошибка открытия браузера: " + e.getMessage())); + System.out.println(ZAnsi.white("Ссылка: " + url)); + } + } + + private void installAndRunServerPack(ServerPack selected) throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("Установка сборки: " + selected.getName())); + + System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion()); + System.out.println(ZAnsi.white(" Лоадер: ") + selected.getLoaderType() + + (selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : "")); + System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion()); + System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount()); + + String localName = askPackName(); + if (localName == null) return; + + if (InstanceManager.getInstance(localName) != null) { + System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); + ConsoleUtils.pause(); + return; + } + + InstanceManager.createInstanceFolder(localName); + Instance newInstance = InstanceManager.getInstance(localName); + + PackDownloader packDownloader = new PackDownloader(newInstance); + boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected); + + if (!success) { + System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку.")); + ConsoleUtils.pause(); + return; + } + + System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!")); + ConsoleUtils.pause(); + + launchExistingInstance(newInstance); + } + + // ====================== GLOBAL BUILD ====================== + private void showGlobal() throws Exception { + while (true) { + ConsoleUtils.clearScreen(); + List instances = InstanceManager.getAllInstances(); + + List options = instances.stream() + .map(Instance::toString) + .collect(Collectors.toList()); + + options.add("Установить новую сборку"); + options.add("Назад в главное меню"); + + ArrowMenu menu = new ArrowMenu("Управление сборками", options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) break; + + if (choice == instances.size()) { + installNewPackGlobal(); + continue; + } + + Instance selected = instances.get(choice); + manageInstance(selected); + } + } + + private void installNewPackGlobal() throws Exception { + ConsoleUtils.clearScreen(); + + List options = List.of( + "Установить сборку с сервера ZernMC", + "Установить Vanilla Minecraft", + "Создать сборку вручную (Fabric/Forge)", + "Назад" + ); + + ArrowMenu menu = new ArrowMenu("Установка новой сборки", options); + int choice = menu.show(); + + if (choice == -1 || choice == 3) return; + + switch (choice) { + case 0 -> installServerPackGlobal(); + case 1 -> createVanillaInstance(); + case 2 -> createCustomInstance(); + } + } + + private void installServerPackGlobal() throws Exception { + if (!awaitActivePass()) return; + + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Получение списка доступных сборок...")); + + PackDownloader tempDownloader = new PackDownloader(null); + List availablePacks = tempDownloader.getAvailablePacks(); + + if (availablePacks.isEmpty()) { + System.out.println(ZAnsi.yellow("Нет доступных сборок на сервере.")); + ConsoleUtils.pause(); + return; + } + + List options = availablePacks.stream() + .map(p -> String.format("%s [%s + %s v%d] — %d файлов", + p.getName(), + p.getMinecraftVersion(), + p.getLoaderType(), + p.getVersion(), + p.getFilesCount())) + .collect(Collectors.toList()); + options.add("Назад"); + + ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return; + + ServerPack selected = availablePacks.get(choice); + + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("Установка сборки: " + selected.getName())); + + System.out.print(ZAnsi.white("\nВведите название локальной сборки (Enter = имя пака): ")); + String localName = Input.readLine().trim(); + if (localName.isEmpty()) localName = selected.getName(); + + if (InstanceManager.getInstance(localName) != null) { + System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); + ConsoleUtils.pause(); + return; + } + + InstanceManager.createInstanceFolder(localName); + Instance newInstance = InstanceManager.getInstance(localName); + + PackDownloader packDownloader = new PackDownloader(newInstance); + boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected); + + if (success) { + System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!")); + } else { + System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку.")); + } + + ConsoleUtils.pause(); + } + + // ====================== manageInstance — полностью восстановлен ====================== + private void manageInstance(Instance instance) throws Exception { + while (true) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName())); + System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion())); + System.out.println(ZAnsi.white("Лоадер: " + instance.getLoaderType() + + (instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : ""))); + + if (instance.isServerPack()) { + System.out.println(ZAnsi.green("Серверная сборка: v" + instance.getServerVersion())); + } + + List options = new ArrayList<>(); + options.add("Запустить сборку"); + if (instance.isServerPack()) { + options.add("Проверить обновления"); + } + options.add("Изменить версию лоадера"); + options.add("Удалить сборку"); + options.add("Назад"); + + ArrowMenu menu = new ArrowMenu("Действия", options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return; + + switch (choice) { + case 0 -> launchExistingInstance(instance); + case 1 -> { + if (instance.isServerPack()) { + checkAndUpdateServerPack(instance); + } else { + changeLoaderVersion(instance); + } + } + case 2 -> { + if (instance.isServerPack()) { + changeLoaderVersion(instance); + } else { + deleteInstance(instance); + } + } + case 3 -> deleteInstance(instance); + } + } + } + + private void checkAndUpdateServerPack(Instance instance) throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName())); + + PackDownloader downloader = new PackDownloader(instance); + boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); + + if (!hasUpdate) { + System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")")); + ConsoleUtils.pause(); + return; + } + + System.out.println(ZAnsi.brightYellow("Доступно обновление!")); + if (Input.confirm("Обновить сборку")) { + boolean success = downloader.updatePack(instance.getServerPackName()); + if (success) { + System.out.println(ZAnsi.brightGreen("Сборка успешно обновлена!")); + } else { + System.out.println(ZAnsi.brightRed("Не удалось обновить сборку.")); + } + } else { + System.out.println(ZAnsi.yellow("Обновление отменено.")); + } + ConsoleUtils.pause(); + } + + private void changeLoaderVersion(Instance instance) throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Изменение версии лоадера для " + instance.getName())); + + String currentLoader = instance.getLoaderType(); + String mcVersion = instance.getMinecraftVersion(); + + if ("vanilla".equalsIgnoreCase(currentLoader)) { + System.out.println(ZAnsi.yellow("Это vanilla сборка. Нельзя изменить лоадер.")); + ConsoleUtils.pause(); + return; + } + + String newLoaderVersion; + if ("fabric".equalsIgnoreCase(currentLoader)) { + newLoaderVersion = askFabricLoaderVersion(); + } else if ("neoforge".equalsIgnoreCase(currentLoader)) { + newLoaderVersion = askNeoForgeVersion(mcVersion); + } else { + newLoaderVersion = askForgeVersion(mcVersion); + } + + if (newLoaderVersion == null) return; + + System.out.println(ZAnsi.cyan("Переустановка лоадера " + currentLoader + " -> " + newLoaderVersion + "...")); + + MinecraftLib lib = new MinecraftLib(instance); + boolean success; + + try { + if ("fabric".equalsIgnoreCase(currentLoader)) { + success = lib.installFabric(mcVersion, newLoaderVersion); + } else if ("neoforge".equalsIgnoreCase(currentLoader)) { + success = lib.installNeoForge(mcVersion, newLoaderVersion); + } else { + success = lib.installForge(mcVersion, newLoaderVersion); + } + + if (success) { + System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!")); + } else { + System.out.println(ZAnsi.brightRed("Не удалось изменить версию лоадера.")); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage())); + } + + ConsoleUtils.pause(); + } + + private void deleteInstance(Instance instance) throws IOException { + ConsoleUtils.clearScreen(); + + List confirmOptions = List.of( + "Да, удалить сборку", + "Нет, отменить" + ); + + ArrowMenu confirmMenu = new ArrowMenu( + "Вы действительно хотите удалить сборку '" + instance.getName() + "'?", + confirmOptions + ); + + int choice = confirmMenu.show(); + + if (choice == 0) { + boolean deleted = InstanceManager.deleteInstance(instance.getName()); + if (deleted) { + System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена.")); + } else { + System.out.println(ZAnsi.brightRed("Не удалось удалить сборку.")); + } + } else { + System.out.println(ZAnsi.yellow("Удаление отменено.")); + } + + ConsoleUtils.pause(); + } + + private void launchExistingInstance(Instance instance) { + if (instance.isServerPack() && !AuthManager.hasActivePass()) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!")); + ConsoleUtils.pause(); + return; + } + + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName())); + + MinecraftLib lib = new MinecraftLib(instance); + LaunchOptions options = new LaunchOptions(); + + options.setUsername(AuthManager.getUsername()); + options.setUuid(AuthManager.getUuid()); + options.setAccessToken(AuthManager.getAccessToken()); + + try { + lib.launch(options); + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage())); + e.printStackTrace(); + } + + ConsoleUtils.pause(); + } + + // ====================== Остальные вспомогательные методы ====================== + + private String askPackName() { + System.out.print(ZAnsi.white("\nВведите название новой сборки: ")); + String name = Input.readLine().trim(); + if (name.isEmpty()) { + System.out.println(ZAnsi.yellow("Отменено.")); + return null; + } + return name; + } + + private void createVanillaInstance() throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Получение списка версий Minecraft...")); + + VersionInstaller versionInstaller = new VersionInstaller(null); + List allVersions = versionInstaller.getAvailableVersions(); + + List versionOptions = allVersions.stream() + .map(v -> v.getId() + " (" + v.getType() + ")") + .collect(Collectors.toList()); + versionOptions.add("Назад"); + + ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions); + int versionChoice = versionMenu.show(); + + if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; + + MinecraftVersion selectedMc = allVersions.get(versionChoice); + String mcVersion = selectedMc.getId(); + + String packName = askPackName(); + if (packName == null) return; + + if (InstanceManager.getInstance(packName) != null) { + System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); + ConsoleUtils.pause(); + return; + } + + InstanceManager.createInstanceFolder(packName); + Instance newInstance = InstanceManager.getInstance(packName); + + MinecraftLib lib = new MinecraftLib(newInstance); + boolean success = lib.installMinecraft(mcVersion); + + if (success) { + System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!")); + } else { + System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку.")); + } + + ConsoleUtils.pause(); + } + + private void createCustomInstance() throws Exception { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.cyan("Получение списка версий Minecraft...")); + + VersionInstaller versionInstaller = new VersionInstaller(null); + List allVersions = versionInstaller.getAvailableVersions(); + + List versionOptions = allVersions.stream() + .map(v -> v.getId() + " (" + v.getType() + ")") + .collect(Collectors.toList()); + versionOptions.add("Назад"); + + ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions); + int versionChoice = versionMenu.show(); + + if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; + + MinecraftVersion selectedMc = allVersions.get(versionChoice); + String mcVersion = selectedMc.getId(); + + List loaderOptions = buildLoaderOptions(mcVersion); + ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions); + int loaderChoice = loaderMenu.show(); + + if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return; + + String selectedLoader = loaderOptions.get(loaderChoice); + + if (selectedLoader.contains("Vanilla")) { + createVanillaInstance(); + return; + } + + String loaderType; + if (selectedLoader.contains("Fabric")) { + loaderType = "fabric"; + } else if (selectedLoader.contains("NeoForge")) { + loaderType = "neoforge"; + } else { + loaderType = "forge"; + } + + String loaderVersion; + if (loaderType.equals("fabric")) { + loaderVersion = askFabricLoaderVersion(); + } else if (loaderType.equals("neoforge")) { + loaderVersion = askNeoForgeVersion(mcVersion); + } else { + loaderVersion = askForgeVersion(mcVersion); + } + + if (loaderVersion == null) return; + + String packName = askPackName(); + if (packName == null) return; + + if (InstanceManager.getInstance(packName) != null) { + System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!")); + ConsoleUtils.pause(); + return; + } + + InstanceManager.createInstanceFolder(packName); + Instance newInstance = InstanceManager.getInstance(packName); + + MinecraftLib lib = new MinecraftLib(newInstance); + + boolean success; + if (loaderType.equals("fabric")) { + success = lib.installFabric(mcVersion, loaderVersion); + } else if (loaderType.equals("neoforge")) { + success = lib.installNeoForge(mcVersion, loaderVersion); + } else { + success = lib.installForge(mcVersion, loaderVersion); + } + + if (success) { + System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!")); + } else { + System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку.")); + } + + ConsoleUtils.pause(); + } + + private List buildLoaderOptions(String mcVersion) { + List options = new ArrayList<>(); + + if (isFabricSupported(mcVersion)) options.add("Fabric"); + if (isNeoForgeSupported(mcVersion)) options.add("NeoForge"); + if (isForgeSupported(mcVersion)) options.add("Forge"); + options.add("Vanilla"); + options.add("Назад"); + + return options; + } + + private boolean isFabricSupported(String version) { + return version.matches("^1\\.(1[4-9]|[2-9]\\d).*"); + } + + private boolean isForgeSupported(String version) { + if (version.matches("^1\\.2[2-9].*") || version.matches("^\\d{2}.*")) return false; + return version.matches("^1\\.(1[2-9]|[2-9]\\d).*") || + version.matches("^1\\.20.*") || version.matches("^1\\.21.*"); + } + + private boolean isNeoForgeSupported(String version) { + return version.matches("^1\\.20\\.[1-9].*") || + version.matches("^1\\.21.*") || + version.matches("^\\d{2}\\..*"); + } + + private String askFabricLoaderVersion() throws Exception { + System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader...")); + List versions = ZHttpClient.getFabricLoaderVersions(); + + List options = versions.stream() + .limit(30) + .map(v -> "Fabric Loader " + v) + .collect(Collectors.toList()); + options.add("Назад"); + + ArrowMenu menu = new ArrowMenu("Выбор версии Fabric Loader", options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return null; + return versions.get(choice); + } + + private String askForgeVersion(String mcVersion) throws Exception { + System.out.println(ZAnsi.cyan("Получение списка версий Forge для " + mcVersion + "...")); + + List allForgeVersions = getAllForgeVersions(); + + List compatibleVersions = allForgeVersions.stream() + .filter(v -> v.startsWith(mcVersion + "-")) + .map(v -> v.substring(mcVersion.length() + 1)) + .collect(Collectors.toList()); + + if (compatibleVersions.isEmpty()) { + System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion)); + ConsoleUtils.pause(); + return null; + } + + List options = compatibleVersions.stream() + .limit(30) + .map(v -> "Forge " + v) + .collect(Collectors.toList()); + options.add("Назад"); + + ArrowMenu menu = new ArrowMenu("Выбор версии Forge для " + mcVersion, options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return null; + + return compatibleVersions.get(choice); + } + + private List getAllForgeVersions() throws Exception { + String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"); + + List versions = new ArrayList<>(); + int index = 0; + + while ((index = xml.indexOf("", index)) != -1) { + int start = index + 9; + int end = xml.indexOf("", start); + if (end == -1) break; + + String version = xml.substring(start, end).trim(); + versions.add(version); + index = end; + } + + versions.sort((a, b) -> b.compareTo(a)); + return versions; + } + + private String askNeoForgeVersion(String mcVersion) throws Exception { + System.out.println(ZAnsi.cyan("Получение списка версий NeoForge для " + mcVersion + "...")); + + List allNeoForgeVersions = getAllNeoForgeVersions(); + + List compatibleVersions = allNeoForgeVersions.stream() + .filter(v -> isNeoForgeVersionCompatible(v, mcVersion)) + .collect(Collectors.toList()); + + if (compatibleVersions.isEmpty()) { + System.out.println(ZAnsi.yellow("Не найдено совместимых версий NeoForge для " + mcVersion)); + ConsoleUtils.pause(); + return null; + } + + List options = compatibleVersions.stream() + .limit(30) + .map(v -> "NeoForge " + v) + .collect(Collectors.toList()); + options.add("Назад"); + + ArrowMenu menu = new ArrowMenu("Выбор версии NeoForge для " + mcVersion, options); + int choice = menu.show(); + + if (choice == -1 || choice == options.size() - 1) return null; + + return compatibleVersions.get(choice); + } + + private boolean isNeoForgeVersionCompatible(String version, String mcVersion) { + if (mcVersion.equals("1.20.1")) { + return version.startsWith("47."); + } + String majorMinor = mcVersion.replace("1.", ""); + String[] parts = majorMinor.split("\\."); + int targetMajor = Integer.parseInt(parts[0]); + return version.startsWith(targetMajor + "."); + } + + private List getAllNeoForgeVersions() throws Exception { + List versions = new ArrayList<>(); + + String[] mavenUrls = { + "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml", + "https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml" + }; + + for (String mavenUrl : mavenUrls) { + try { + String xml = ZHttpClient.downloadString(mavenUrl); + int index = 0; + while ((index = xml.indexOf("", index)) != -1) { + int start = index + 9; + int end = xml.indexOf("", start); + if (end == -1) break; + + String version = xml.substring(start, end).trim(); + if (!versions.contains(version)) { + versions.add(version); + } + index = end; + } + } catch (Exception e) { + // Skip if one maven doesn't have the artifact + } + } + + versions.sort((a, b) -> b.compareTo(a)); + return versions; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java new file mode 100644 index 0000000..6a106a6 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java @@ -0,0 +1,207 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.auth.AuthManager.AuthResult; +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.Input; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; + +import java.io.IOException; +import java.util.List; + +/** + * Экран входа/регистрации. + * Показывается при старте лаунчера, если нет сохранённой сессии. + * + * show() возвращает true — пользователь вошёл/зарегистрировался + * false — пользователь выбрал выход из лаунчера + */ +public class LoginMenu { + + /** + * Главный экран выбора действия. + */ + public boolean show() throws IOException { + while (true) { + ConsoleUtils.clearScreen(); + printBanner(); + + List options = List.of( + "Войти в аккаунт", + "Создать аккаунт", + "Выйти из лаунчера" + ); + + ArrowMenu menu = new ArrowMenu("Добро пожаловать в ZernMC!", options); + int choice = menu.show(); + + if (choice == -1 || choice == 2) return false; + + boolean success = switch (choice) { + case 0 -> doLogin(); + case 1 -> doRegister(); + default -> false; + }; + + if (success) return true; + // Если не успех — покажем меню снова (ошибка уже напечатана внутри методов) + } + } + + /** + * Показывается когда пользователь уже вошёл — предлагает выйти из аккаунта. + */ + public void showAccountMenu() throws IOException { + ConsoleUtils.clearScreen(); + + System.out.println(ZAnsi.header("=== Аккаунт ===")); + System.out.println(); + System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername())); + System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid())); + System.out.println(); + + List options = List.of( + "Выйти из аккаунта", + "Назад" + ); + + ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options); + int choice = menu.show(); + + if (choice == 0) { + AuthManager.logout(); + System.out.println(ZAnsi.yellow("Вы вышли из аккаунта.")); + ConsoleUtils.pause(); + } + } + + // ====================== ПРИВАТНЫЕ МЕТОДЫ ====================== + + private boolean doLogin() throws IOException { + ConsoleUtils.clearScreen(); + printBanner(); + System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]")); + System.out.println(); + + String username = Input.readLine(ZAnsi.white(" Имя пользователя: ")); + if (username.isEmpty()) return false; + + String password = readPassword(" Пароль: "); + if (password.isEmpty()) return false; + + System.out.println(); + System.out.print(ZAnsi.cyan(" Выполняем вход...")); + + AuthResult result = AuthManager.login(username, password); + + if (result.success) { + System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! ")); + ConsoleUtils.pause(); + return true; + } else { + System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " ")); + ConsoleUtils.pause(); + return false; + } + } + + private boolean doRegister() throws IOException { + ConsoleUtils.clearScreen(); + printBanner(); + System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]")); + System.out.println(); + System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _")); + System.out.println(ZAnsi.yellow(" Длина имени: 3-16 символов | Длина пароля: от 6 символов")); + System.out.println(); + + String username = Input.readLine(ZAnsi.white(" Имя пользователя: ")); + if (username.isEmpty()) return false; + + String password = readPassword(" Пароль: "); + if (password.isEmpty()) return false; + + String confirm = readPassword(" Повторите пароль: "); + if (!password.equals(confirm)) { + System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!")); + ConsoleUtils.pause(); + return false; + } + + System.out.println(); + System.out.print(ZAnsi.cyan(" Создаём аккаунт...")); + + AuthResult result = AuthManager.register(username, password); + + if (result.success) { + System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! ")); + ConsoleUtils.pause(); + return true; + } else { + System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " ")); + ConsoleUtils.pause(); + return false; + } + } + + /** + * Читаем пароль — стараемся скрыть вывод через Console, + * если недоступно (IDE/терминал без TTY) — читаем обычным способом. + */ + private String readPassword(String prompt) throws IOException { + org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder() + .system(true) + .jna(true) + .build(); + + passTerminal.enterRawMode(); + passTerminal.writer().print(prompt); + passTerminal.writer().flush(); + + StringBuilder password = new StringBuilder(); + + try { + while (true) { + int key = passTerminal.reader().read(); + + if (key == 27) { + // Escape sequence — consume remaining bytes (arrow keys, etc.) + int next = passTerminal.reader().read(50); + if (next == 91) { // '[' — arrow key sequence + passTerminal.reader().read(50); // consume 'A'/'B'/'C'/'D' + } + continue; + } + + if (key == 13 || key == 10) { // Enter + passTerminal.writer().println(); + break; + } else if (key == 127 || key == 8) { // Backspace + if (password.length() > 0) { + password.setLength(password.length() - 1); + passTerminal.writer().print("\b \b"); + passTerminal.writer().flush(); + } + } else if (key == 3) { // Ctrl+C + passTerminal.writer().println(); + System.exit(0); + } else if (key >= 32 && key < 127) { // Printable characters + password.append((char) key); + passTerminal.writer().print('*'); + passTerminal.writer().flush(); + } + } + } finally { + passTerminal.close(); + } + + return password.toString(); + } + + private void printBanner() { + System.out.println(ZAnsi.header("╔══════════════════════════════╗")); + System.out.println(ZAnsi.header("║ ZernMC Launcher ║")); + System.out.println(ZAnsi.header("╚══════════════════════════════╝")); + System.out.println(); + } +} diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java new file mode 100644 index 0000000..51e113b --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java @@ -0,0 +1,141 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; + +public class ServerCheckMenu { + + public void show() throws IOException { + while (true) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.header("Диагностика подключения")); + + List options = List.of( + "Проверить подключение к ZernMC серверу", + "Проверить доступ к Mojang (Minecraft)", + "Проверить доступ к Fabric Meta", + "Проверить доступ к Forge Maven", + "Назад в главное меню" + ); + + ArrowMenu menu = new ArrowMenu("Выберите проверку", options); + int choice = menu.show(); + + if (choice == -1 || choice == 4) { + return; + } + + ConsoleUtils.clearScreen(); + + switch (choice) { + case 0 -> checkZernServer(); + case 1 -> checkMojang(); + case 2 -> checkFabric(); + case 3 -> checkForge(); + } + + ConsoleUtils.pause(); + } + } + + private void checkZernServer() { + System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу...")); + + try { + String response = ZHttpClient.get("/health"); + System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!")); + System.out.println(ZAnsi.white("Ответ сервера: ") + response); + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу")); + System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); + } + } + + private void checkMojang() { + System.out.println(ZAnsi.cyan("Проверка доступа к Mojang...")); + + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + System.out.println(ZAnsi.brightGreen("[OK] Mojang доступен")); + } else { + System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode())); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang")); + System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); + } + } + + private void checkFabric() { + System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta...")); + + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://meta.fabricmc.net/v2/versions")) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta доступен")); + } else { + System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta вернул код " + response.statusCode())); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta")); + System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); + } + } + + private void checkForge() { + System.out.println(ZAnsi.cyan("Проверка доступа к Forge Maven...")); + + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml")) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + System.out.println(ZAnsi.brightGreen("[OK] Forge Maven доступен")); + } else { + System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven вернул код " + response.statusCode())); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Forge Maven")); + System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/SettingsMenu.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/SettingsMenu.java new file mode 100644 index 0000000..7c16845 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/SettingsMenu.java @@ -0,0 +1,68 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.Input; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; + +import java.io.IOException; +import java.util.List; + +public class SettingsMenu { + + public void show() throws IOException { + List options = List.of( + "Настроить путь к Java", + "Настроить выделенную память (RAM)", + "Дополнительные JVM параметры", + "Назад в главное меню" + ); + + ArrowMenu menu = new ArrowMenu("Настройки лаунчера", options); + int choice = menu.show(); + + if (choice == -1 || choice == 3) return; + + ConsoleUtils.clearScreen(); + + switch (choice) { + case 0 -> configureJava(); + case 1 -> configureRam(); + case 2 -> configureJvmArgs(); + } + + ConsoleUtils.pause(); + } + + private void configureJava() { + System.out.println(ZAnsi.cyan("Путь к Java:")); + System.out.println(" " + Config.getJreDir().toAbsolutePath()); + System.out.println(ZAnsi.white("\nJava будет искаться автоматически в папке ~/.zernmc/jre/")); + System.out.println("Если нужно — положите туда свою версию Java."); + } + + private void configureRam() { + System.out.println(ZAnsi.cyan("Настройка выделенной памяти")); + System.out.println(Config.getRamInfo()); + + int newRam = Input.readInt( + ZAnsi.white("\nВведите новое значение RAM в MB (или 0 для отмены): "), + 0, 32768 + ); + + if (newRam == 0) { + System.out.println(ZAnsi.yellow("Настройка отменена.")); + return; + } + + Config.setMaxMemory(newRam); + System.out.println(ZAnsi.brightGreen("Выделенная память изменена на " + newRam + " MB")); + } + + private void configureJvmArgs() { + System.out.println(ZAnsi.yellow("Дополнительные JVM параметры")); + System.out.println("Пока в разработке."); + System.out.println("В будущем здесь будет список предустановленных оптимизаций."); + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/UpdateMenu.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/UpdateMenu.java new file mode 100644 index 0000000..41f8b20 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/UpdateMenu.java @@ -0,0 +1,152 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; +import me.sashegdev.zernmc.launcher.minecraft.PackDownloader; +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.Input; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class UpdateMenu { + + public void show() throws IOException { + List options = List.of( + "Проверить обновления сборки (модпака)", + "Проверить обновления лаунчера", + "Назад в главное меню" + ); + + ArrowMenu menu = new ArrowMenu("Проверка обновлений", options); + int choice = menu.show(); + + if (choice == -1 || choice == 2) return; + + ConsoleUtils.clearScreen(); + + if (choice == 0) { + try { + checkPackUpdates(); + } catch (Exception e) { + System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage())); + e.printStackTrace(); + ConsoleUtils.pause(); + } + } else { + checkLauncherUpdates(); + } + } + + private void checkPackUpdates() throws Exception { + System.out.println(ZAnsi.cyan("Проверка обновлений сборок...")); + + List instances = InstanceManager.getAllInstances(); + List serverInstances = instances.stream() + .filter(Instance::isServerPack) + .collect(Collectors.toList()); + + if (serverInstances.isEmpty()) { + System.out.println(ZAnsi.yellow("Нет сборок, установленных с сервера.")); + ConsoleUtils.pause(); + return; + } + + System.out.println(ZAnsi.cyan("\nПроверка обновлений для серверных сборок:\n")); + + boolean hasUpdates = false; + List updatableInstances = new ArrayList<>(); + + for (Instance instance : serverInstances) { + PackDownloader downloader = new PackDownloader(instance); + + try { + boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); + if (hasUpdate) { + System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!")); + updatableInstances.add(instance); + hasUpdates = true; + } else { + System.out.println(ZAnsi.green(instance.getName() + " - Актуальна")); + } + } catch (Exception e) { + System.out.println(ZAnsi.red(instance.getName() + " - Ошибка проверки: " + e.getMessage())); + } + } + + if (!hasUpdates) { + System.out.println(ZAnsi.green("\nВсе сборки актуальны!")); + ConsoleUtils.pause(); + return; + } + + // Предлагаем обновить каждую сборку отдельно + for (Instance instance : updatableInstances) { + System.out.println(ZAnsi.brightYellow("\nОбновить сборку '" + instance.getName() + "'?")); + if (Input.confirm("Обновить")) { + System.out.println(ZAnsi.cyan("Обновление " + instance.getName() + "...")); + PackDownloader downloader = new PackDownloader(instance); + + try { + boolean success = downloader.updatePack(instance.getServerPackName()); + if (success) { + System.out.println(ZAnsi.brightGreen(instance.getName() + " обновлен")); + } else { + System.out.println(ZAnsi.brightRed(instance.getName() + " не удалось обновить")); + } + } catch (Exception e) { + System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage())); + } + } else { + System.out.println(ZAnsi.yellow(" Пропущено: " + instance.getName())); + } + } + + ConsoleUtils.pause(); + } + + private void checkLauncherUpdates() { + System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера...")); + + try { + String json = ZHttpClient.getLauncherVersionInfo(); + String serverVersion = extractVersion(json); + String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion(); + + System.out.println(ZAnsi.white("Текущая версия: ") + currentVersion); + System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion); + + if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) { + System.out.println(ZAnsi.brightYellow("\nДоступна новая версия!")); + if (Input.confirm("Обновить лаунчер?")) { + // Обновление будет при следующем запуске + System.out.println(ZAnsi.green("Лаунчер будет обновлен при следующем запуске.")); + } + } else { + System.out.println(ZAnsi.brightGreen("Лаунчер актуален.")); + } + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера.")); + System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage()); + } + + ConsoleUtils.pause(); + } + + private String extractVersion(String json) { + try { + int start = json.indexOf("\"version\""); + if (start == -1) return "unknown"; + start = json.indexOf("\"", start + 9) + 1; + int end = json.indexOf("\"", start); + return json.substring(start, end); + } catch (Exception e) { + return "unknown"; + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java new file mode 100644 index 0000000..7c37175 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java @@ -0,0 +1,172 @@ +package me.sashegdev.zernmc.launcher.minecraft; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + + + +// ЭТОТ КЛАСС РАБОТАЕТ НЕ ТРОГАТЬ ТОТ КТО БУДЕТ ЧИТАТЬ (на момент 1.0.2) +public class Instance { + private final String name; + private final Path path; + + private String minecraftVersion; + private String loaderType; // vanilla, fabric, forge, neoforge + private String loaderVersion; + private String assetIndex; + private boolean isServerPack; // флаг, что это сборка с сервера + private int serverVersion; // версия сборки на сервере + private String serverPackName; // имя пака на сервере + private String fabricVersionId; + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public Instance(String name, Path path) { + this.name = name; + this.path = path; + loadMetadata(); + } + + public String getName() { return name; } + public Path getPath() { return path; } + + public String getMinecraftVersion() { return minecraftVersion; } + public void setMinecraftVersion(String minecraftVersion) { + this.minecraftVersion = minecraftVersion; + saveMetadata(); + } + + public String getLoaderType() { return loaderType != null ? loaderType : "vanilla"; } + public void setLoaderType(String loaderType) { + this.loaderType = loaderType; + saveMetadata(); + } + + public String getLoaderVersion() { return loaderVersion; } + public void setLoaderVersion(String loaderVersion) { + this.loaderVersion = loaderVersion; + saveMetadata(); + } + + public String getAssetIndex() { + return assetIndex != null ? assetIndex : minecraftVersion; // fallback для старых сборок + } + + public void setAssetIndex(String assetIndex) { + this.assetIndex = assetIndex; + saveMetadata(); + } + + public String getFabricVersionId() { + return fabricVersionId; + } + + public void setFabricVersionId(String fabricVersionId) { + this.fabricVersionId = fabricVersionId; + saveMetadata(); + } + + + public boolean isServerPack() { + return isServerPack; + } + + public void setServerPack(boolean serverPack) { + this.isServerPack = serverPack; + saveMetadata(); + } + + public int getServerVersion() { + return serverVersion; + } + + public void setServerVersion(int serverVersion) { + this.serverVersion = serverVersion; + saveMetadata(); + } + + public String getServerPackName() { + return serverPackName; + } + + public void setServerPackName(String serverPackName) { + this.serverPackName = serverPackName; + saveMetadata(); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(name); + if (minecraftVersion != null) { + sb.append(" [").append(minecraftVersion); + if (!"vanilla".equalsIgnoreCase(getLoaderType())) { + sb.append(" + ").append(getLoaderType()); + if (loaderVersion != null) sb.append(" ").append(loaderVersion); + } + sb.append("]"); + if (isServerPack) { + sb.append("v").append(serverVersion); + } + } else { + sb.append(" [?]"); + } + return sb.toString(); + } + + // ====================== Метаданные ====================== + private void loadMetadata() { + Path metaFile = path.resolve("instance.json"); + if (!Files.exists(metaFile)) return; + try { + String json = Files.readString(metaFile); + InstanceMeta meta = GSON.fromJson(json, InstanceMeta.class); + this.minecraftVersion = meta.minecraftVersion; + this.loaderType = meta.loaderType; + this.loaderVersion = meta.loaderVersion; + this.assetIndex = meta.assetIndex; + this.isServerPack = meta.isServerPack; + this.serverVersion = meta.serverVersion; + this.serverPackName = meta.serverPackName; + } catch (Exception ignored) {} + } + + private void saveMetadata() { + Path metaFile = path.resolve("instance.json"); + InstanceMeta meta = new InstanceMeta( + minecraftVersion, loaderType, loaderVersion, assetIndex, + isServerPack, serverVersion, serverPackName + ); + try { + Files.writeString(metaFile, GSON.toJson(meta)); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private static class InstanceMeta { + String minecraftVersion; + String loaderType; + String loaderVersion; + String assetIndex; + boolean isServerPack = false; + int serverVersion = 0; + String serverPackName; + + + public InstanceMeta(String minecraftVersion, String loaderType, + String loaderVersion, String assetIndex, + boolean isServerPack, int serverVersion, + String serverPackName) { + this.minecraftVersion = minecraftVersion; + this.loaderType = loaderType; + this.loaderVersion = loaderVersion; + this.assetIndex = assetIndex; + this.isServerPack = isServerPack; + this.serverVersion = serverVersion; + this.serverPackName = serverPackName; + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/InstanceManager.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/InstanceManager.java new file mode 100644 index 0000000..9d16fbe --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/InstanceManager.java @@ -0,0 +1,72 @@ +package me.sashegdev.zernmc.launcher.minecraft; + +import me.sashegdev.zernmc.launcher.utils.Config; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; + +public class InstanceManager { + + private static final Path INSTANCES_DIR = Config.getInstancesDir(); + + public static List getAllInstances() throws IOException { + if (!Files.exists(INSTANCES_DIR)) { + Files.createDirectories(INSTANCES_DIR); + return List.of(); + } + + return Files.list(INSTANCES_DIR) + .filter(Files::isDirectory) + .map(path -> new Instance(path.getFileName().toString(), path)) + .collect(Collectors.toList()); + } + + public static Instance getInstance(String name) { + Path instancePath = INSTANCES_DIR.resolve(name); + if (Files.exists(instancePath) && Files.isDirectory(instancePath)) { + return new Instance(name, instancePath); + } + return null; + } + + public static boolean deleteInstance(String instanceName) { + if (instanceName == null || instanceName.isBlank()) { + return false; + } + + Path instancePath = INSTANCES_DIR.resolve(instanceName); + + if (!Files.exists(instancePath)) { + return false; + } + + try { + // Рекурсивно удаляем всю папку сборки + Files.walk(instancePath) + .sorted((a, b) -> b.compareTo(a)) // удаляем снизу вверх + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + System.err.println("Не удалось удалить: " + path); + } + }); + return true; + } catch (IOException e) { + e.printStackTrace(); + return false; + } + } + + public static boolean createInstanceFolder(String name) throws IOException { + Path path = INSTANCES_DIR.resolve(name); + if (Files.exists(path)) { + return false; + } + Files.createDirectories(path); + return true; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java new file mode 100644 index 0000000..9593357 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java @@ -0,0 +1,196 @@ +package me.sashegdev.zernmc.launcher.minecraft; + +import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller; +import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller; +import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller; +import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; +import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class MinecraftLib { + + private final Instance instance; + + public MinecraftLib(Instance instance) { + this.instance = instance; + } + + //Установка + public boolean installMinecraft(String versionId) throws Exception { + VersionInstaller installer = new VersionInstaller(instance.getPath()); + + String assetIndex = installer.install(versionId); // ← теперь возвращается String + + if (assetIndex != null && !assetIndex.isEmpty()) { + instance.setMinecraftVersion(versionId); + instance.setAssetIndex(assetIndex); // ← сохраняем правильный индекс! + instance.setLoaderType("vanilla"); + return true; + } + return false; + } + + public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception { + ForgeInstaller installer = new ForgeInstaller(instance); + return installer.install(minecraftVersion, forgeVersion); + } + + public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception { + NeoForgeInstaller installer = new NeoForgeInstaller(instance); + return installer.install(minecraftVersion, neoforgeVersion); + } + + public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception { + FabricInstaller installer = new FabricInstaller(instance); + boolean success = installer.install(minecraftVersion, loaderVersion); + + if (success) { + // Сохраняем информацию в Instance + instance.setMinecraftVersion(minecraftVersion); + instance.setLoaderType("fabric"); + instance.setLoaderVersion(loaderVersion); + } + return success; + } + + /** + * Полная установка сборки (vanilla + loader + моды) + * Пока заглушка — будем расширять + */ + public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception { + System.out.println(ZAnsi.cyan("Начинается полная установка сборки: " + packName)); + + // 1. Устанавливаем Minecraft + boolean mcInstalled = installMinecraft(minecraftVersion); + if (!mcInstalled) { + System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion)); + return false; + } + + // 2. Устанавливаем лоадер + if ("fabric".equalsIgnoreCase(loaderType)) { + boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion); + if (!fabricInstalled) { + System.out.println(ZAnsi.brightRed("Не удалось установить Fabric")); + return false; + } + } else if ("forge".equalsIgnoreCase(loaderType)) { + boolean forgeInstalled = installForge(minecraftVersion, loaderVersion); + if (!forgeInstalled) { + System.out.println(ZAnsi.brightRed("Не удалось установить Forge")); + return false; + } + } else if ("neoforge".equalsIgnoreCase(loaderType)) { + boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion); + if (!neoforgeInstalled) { + System.out.println(ZAnsi.brightRed("Не удалось установить NeoForge")); + return false; + } + } + + // 3. В будущем здесь будет diff и скачивание модов + + System.out.println(ZAnsi.brightGreen("Базовая установка сборки завершена!")); + return true; + } + + //Запуск + public void launch(LaunchOptions options) throws Exception { + System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName())); + cleanupOldLoaders(); + + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); + List command = builder.build(options); + + System.out.println(ZAnsi.cyan("Команда запуска (" + command.size() + " аргументов):")); + command.forEach(arg -> System.out.println(" " + arg)); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(instance.getPath().toFile()); + pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); + pb.redirectError(ProcessBuilder.Redirect.INHERIT); + pb.redirectInput(ProcessBuilder.Redirect.INHERIT); + + System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n")); + ConsoleUtils.clearScreen(); + + Process process = pb.start(); + int exitCode = process.waitFor(); + + System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode)); + } + + private void safeDeleteDirectory(Path dir) { + try { + Files.walk(dir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException ignored) {} + }); + } catch (IOException ignored) {} + } + + private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException { + if (!Files.exists(versionsDir)) return; + + try (var stream = Files.walk(versionsDir)) { + stream.filter(Files::isDirectory) + .filter(dir -> dir.getFileName().toString().contains("fabric-loader") || + dir.getFileName().toString().contains("forge") || + dir.getFileName().toString().contains("neoforge")) + .filter(dir -> !dir.getFileName().toString().contains(keepVersion)) + .forEach(this::safeDeleteDirectory); + } + } + + private void deleteAllExcept(Path baseDir, String keepVersion) throws IOException { + if (!Files.exists(baseDir)) return; + + try (var stream = Files.walk(baseDir)) { + stream.filter(Files::isDirectory) + .filter(dir -> { + String name = dir.getFileName().toString(); + return name.contains(".") && !name.contains(keepVersion); + }) + .forEach(this::safeDeleteDirectory); + } + } + + private void cleanupOldLoaders() throws IOException { + String loaderType = instance.getLoaderType().toLowerCase(); + String currentLoaderVer = instance.getLoaderVersion(); + + if (currentLoaderVer == null) return; + + System.out.println(ZAnsi.yellow("Выполняем очистку старых версий лоадера...")); + + // Удаляем все старые fabric-loader / forge + Path libraries = instance.getPath().resolve("libraries"); + + if ("fabric".equals(loaderType)) { + deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer); + } else if ("forge".equals(loaderType)) { + deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer); + } else if ("neoforge".equals(loaderType)) { + deleteAllExcept(libraries.resolve("net/neoforged/neoforge"), currentLoaderVer); + } + + // Также чистим versions/ от старых fabric/forge версий + Path versionsDir = instance.getPath().resolve("versions"); + deleteOldVersionDirs(versionsDir, currentLoaderVer); + } + + + + public Instance getInstance() { + return instance; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java new file mode 100644 index 0000000..2aae36d --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java @@ -0,0 +1,567 @@ +package me.sashegdev.zernmc.launcher.minecraft; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.*; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +public class PackDownloader { + + private final Instance instance; + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private final HttpClient httpClient = HttpClient.newHttpClient(); + //private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + public PackDownloader(Instance instance) { + this.instance = instance; + } + + /** + * Получить список доступных паков с сервера + */ + public List getAvailablePacks() throws Exception { + String accessToken = AuthManager.getAccessToken(); + if (accessToken == null) { + throw new IOException("Не авторизован. Требуется проходка для просмотра сборок."); + } + if (!AuthManager.canViewPacks()) { + throw new IOException("Для просмотра сборок требуется активная проходка"); + } + + // Используем HttpURLConnection для GET с авторизацией + java.net.HttpURLConnection connection = null; + try { + java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs"); + connection = (java.net.HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + accessToken); + connection.setConnectTimeout(15000); + connection.setReadTimeout(15000); + + int responseCode = connection.getResponseCode(); + + if (responseCode == 403) { + throw new IOException("Для просмотра сборок требуется активная проходка"); + } + + StringBuilder response = new StringBuilder(); + try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); + java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + + if (responseCode != 200) { + throw new IOException("HTTP " + responseCode); + } + + return parsePacksResponse(response.toString()); + + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private List parsePacksResponse(String responseBody) { + JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject(); + JsonArray packsArray = root.getAsJsonArray("packs"); + List result = new ArrayList<>(); + + for (JsonElement elem : packsArray) { + JsonObject pack = elem.getAsJsonObject(); + + if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) { + continue; + } + + try { + String name = pack.get("name").getAsString(); + int version = pack.has("version") ? pack.get("version").getAsInt() : 0; + String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown"; + String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla"; + String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() + ? pack.get("loader_version").getAsString() : ""; + int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0; + + LocalDateTime updatedAt = null; + if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) { + try { + updatedAt = LocalDateTime.parse(pack.get("updated_at").getAsString(), + DateTimeFormatter.ISO_DATE_TIME); + } catch (Exception ignored) {} + } + + result.add(new ServerPack(name, version, minecraftVersion, loaderType, + loaderVersion, updatedAt, filesCount)); + } catch (Exception e) { + System.err.println("Ошибка парсинга пака: " + e.getMessage()); + } + } + + return result; + } + + /** + * Получить манифест пака + */ + public PackManifest getPackManifest(String packName) throws Exception { + String response = ZHttpClient.get("/pack/" + packName); + return gson.fromJson(response, PackManifest.class); + } + + /** + * Установить или обновить сборку с сервера + */ + public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception { + System.out.println(ZAnsi.cyan("Установка сборки " + packName + " с сервера...")); + + // 1. Получаем манифест + PackManifest manifest = getPackManifest(packName); + + // 2. Сначала устанавливаем Minecraft + Loader через MinecraftLib + MinecraftLib lib = new MinecraftLib(instance); + + System.out.println(ZAnsi.cyan("Установка Minecraft " + manifest.getMinecraftVersion() + "...")); + + boolean needsMinecraftInstall = instance.getMinecraftVersion() == null || + !instance.getMinecraftVersion().equals(manifest.getMinecraftVersion()); + + if (needsMinecraftInstall) { + if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) { + boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); + if (!success) { + System.err.println(ZAnsi.brightRed("Не удалось установить Fabric")); + return false; + } + } else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) { + boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); + if (!success) { + System.err.println(ZAnsi.brightRed("Не удалось установить NeoForge")); + return false; + } + } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { + boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); + if (!success) { + System.err.println(ZAnsi.brightRed("Не удалось установить Forge")); + return false; + } + } else { + boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); + if (!success) { + System.err.println(ZAnsi.brightRed("Не удалось установить Vanilla Minecraft")); + return false; + } + } + } else { + System.out.println(ZAnsi.green("Minecraft уже установлен, пропускаем...")); + } + + // 3. Сканируем локальные файлы ТОЛЬКО если есть файлы для скачивания + Map localFiles = scanLocalFiles(); + + // Если в сборке нет файлов (только vanilla/loader), пропускаем diff + if (manifest.files == null || manifest.files.isEmpty()) { + System.out.println(ZAnsi.green("Сборка не содержит дополнительных файлов")); + + // Обновляем метаданные инстанса + instance.setServerPack(true); + instance.setServerPackName(packName); + instance.setServerVersion(manifest.getVersion()); + instance.setMinecraftVersion(manifest.getMinecraftVersion()); + instance.setLoaderType(manifest.getLoaderType()); + instance.setLoaderVersion(manifest.getLoaderVersion()); + instance.setAssetIndex(manifest.getAssetIndex()); + + System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!")); + return true; + } + + // 4. Отправляем diff запрос + System.out.println(ZAnsi.cyan("Проверка файлов сборки...")); + DiffResponse diff = getDiff(packName, localFiles); + + // 5. Применяем изменения + boolean success = applyDiff(diff, packName); + + if (success) { + // 6. Обновляем метаданные инстанса + instance.setServerPack(true); + instance.setServerPackName(packName); + instance.setServerVersion(manifest.getVersion()); + instance.setMinecraftVersion(manifest.getMinecraftVersion()); + instance.setLoaderType(manifest.getLoaderType()); + instance.setLoaderVersion(manifest.getLoaderVersion()); + instance.setAssetIndex(manifest.getAssetIndex()); + + System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!")); + } + + return success; + } + + /** + * Проверить наличие обновлений для серверной сборки + */ + public boolean checkForUpdates(String packName) throws Exception { + if (!instance.isServerPack()) return false; + + PackManifest manifest = getPackManifest(packName); + int serverVersion = manifest.getVersion(); + int localVersion = instance.getServerVersion(); + + return serverVersion > localVersion; + } + + /** + * Обновить существующую серверную сборку + */ + public boolean updatePack(String packName) throws Exception { + System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName() + "...")); + + PackManifest manifest = getPackManifest(packName); + int serverVersion = manifest.getVersion(); + + if (serverVersion <= instance.getServerVersion()) { + System.out.println(ZAnsi.green("Сборка уже актуальна (v" + instance.getServerVersion() + ")")); + return true; + } + + System.out.println(ZAnsi.yellow("Доступно обновление: v" + instance.getServerVersion() + " → v" + serverVersion)); + + // Сканируем локальные файлы + Map localFiles = scanLocalFiles(); + + // Получаем diff + DiffResponse diff = getDiff(packName, localFiles); + + // Применяем изменения + boolean success = applyDiff(diff, packName); + + if (success) { + instance.setServerVersion(serverVersion); + System.out.println(ZAnsi.brightGreen("Сборка обновлена до v" + serverVersion)); + } + + return success; + } + + /** + * Сканирование локальных файлов и вычисление хешей + */ + private Map scanLocalFiles() throws IOException { + Map files = new HashMap<>(); + Path instancePath = instance.getPath(); + + // Игнорируемые директории + Set ignoredDirs = Set.of( + "resourcepacks", "shaderpacks", "saves", "logs", + "crash-reports", "screenshots", "journeymap", "config", + "natives", "assets", "libraries", "versions", "cache" + ); + + if (!Files.exists(instancePath)) { + return files; + } + + Files.walk(instancePath) + .filter(Files::isRegularFile) + .forEach(file -> { + Path relative = instancePath.relativize(file); + String path = relative.toString().replace("\\", "/"); + + // Проверяем, не в игнорируемой ли директории + for (String ignored : ignoredDirs) { + if (path.startsWith(ignored + "/") || path.startsWith(ignored + "\\")) { + return; + } + } + + try { + String hash = calculateHash(file); + files.put(path, hash); + } catch (Exception e) { + // Пропускаем файлы, которые не можем прочитать + } + }); + + return files; + } + + /** + * Отправить diff запрос на сервер + */ + private DiffResponse getDiff(String packName, Map localFiles) throws Exception { + String json = gson.toJson(localFiles); + + // Получаем токен авторизации + String accessToken = AuthManager.getAccessToken(); + if (accessToken == null) { + throw new IOException("Не авторизован. Требуется проходка для скачивания сборок."); + } + if (!AuthManager.canDownloadPacks()) { + throw new IOException("Для скачивания сборок требуется активная проходка"); + } + + String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff"; + + // Используем HttpURLConnection для полного контроля + java.net.HttpURLConnection connection = null; + try { + java.net.URL urlObj = new java.net.URL(url); + connection = (java.net.HttpURLConnection) urlObj.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + accessToken); + connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length)); + connection.setDoOutput(true); + connection.setConnectTimeout(30000); + connection.setReadTimeout(30000); + + // Отправляем JSON + try (java.io.OutputStream os = connection.getOutputStream()) { + byte[] input = json.getBytes("UTF-8"); + os.write(input, 0, input.length); + os.flush(); + } + + int responseCode = connection.getResponseCode(); + + // Читаем ответ + StringBuilder response = new StringBuilder(); + try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); + java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + + String responseBody = response.toString(); + + if (responseCode == 403) { + throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору."); + } + + if (responseCode != 200) { + throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody)); + } + + return gson.fromJson(responseBody, DiffResponse.class); + + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private String extractErrorFromResponse(String body) { + try { + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + if (json.has("detail")) { + return json.get("detail").getAsString(); + } + } catch (Exception ignored) {} + return body.length() > 200 ? body.substring(0, 200) + "..." : body; + } + + /** + * Применить diff (скачать новые файлы, удалить старые) + */ + private boolean applyDiff(DiffResponse diff, String packName) { + System.out.println(ZAnsi.cyan("\nПрименение изменений:")); + System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов"); + System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов"); + + // Создаем директории если нужно + try { + Files.createDirectories(instance.getPath()); + } catch (IOException e) { + System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage())); + return false; + } + + // Удаляем файлы + for (String filePath : diff.getToDelete()) { + Path fullPath = instance.getPath().resolve(filePath); + try { + if (Files.deleteIfExists(fullPath)) { + System.out.println(ZAnsi.yellow(" Удален: " + filePath)); + } + } catch (IOException e) { + System.err.println(ZAnsi.red(" Ошибка удаления " + filePath + ": " + e.getMessage())); + } + } + + // Скачиваем файлы + AtomicInteger downloaded = new AtomicInteger(0); + int total = diff.getToDownload().size(); + + for (FileInfo file : diff.getToDownload()) { + String path = file.getPath(); + Path fullPath = instance.getPath().resolve(path); + + try { + // Создаем директории + Files.createDirectories(fullPath.getParent()); + + // Скачиваем файл + downloadFile(file, fullPath); + + // Проверяем хеш + String actualHash = calculateHash(fullPath); + if (!actualHash.equals(file.getHash())) { + throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() + + ", получен: " + actualHash); + } + + downloaded.incrementAndGet(); + if (total > 0) { + ProgressBar.show("Скачивание", downloaded.get(), total, "файлов"); + } + + } catch (Exception e) { + System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage())); + return false; + } + } + + if (total > 0) { + ProgressBar.finish("Скачивание"); + } + + return true; + } + + /** + * Скачать один файл с сервера + */ + private void downloadFile(FileInfo file, Path destination) throws Exception { + String url = ZHttpClient.getBaseUrl() + file.getUrl(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(java.net.URI.create(url)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, + HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + // Скачиваем с прогрессом + try (InputStream in = response.body(); + FileOutputStream out = new FileOutputStream(destination.toFile())) { + + byte[] buffer = new byte[8192]; + int bytesRead; + long totalRead = 0; + long fileSize = file.getSize(); + + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + totalRead += bytesRead; + + if (fileSize > 0 && totalRead % 8192 == 0) { + ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize); + } + } + } + + ProgressBar.clearLine(); + } + + /** + * Вычисление SHA256 хеша файла + */ + private String calculateHash(Path file) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + try (InputStream in = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + digest.update(buffer, 0, bytesRead); + } + } + + byte[] hashBytes = digest.digest(); + StringBuilder sb = new StringBuilder(); + for (byte b : hashBytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + // ====================== Вложенные классы ====================== + + public static class PackManifest { + private String pack_name; + private int version; + private String minecraft_version; + private String loader_type; + private String loader_version; + private String asset_index; + private Map files; + + public String getPackName() { return pack_name; } + public int getVersion() { return version; } + public String getMinecraftVersion() { return minecraft_version; } + public String getLoaderType() { return loader_type; } + public String getLoaderVersion() { return loader_version; } + public String getAssetIndex() { return asset_index != null ? asset_index : minecraft_version; } + public Map getFiles() { return files; } + public boolean isEmpty() { return files == null || files.isEmpty(); } + } + + public static class DiffResponse { + private int version; + private List to_download; + private List to_delete; + private List to_update; + + public int getVersion() { return version; } + public List getToDownload() { return to_download != null ? to_download : new ArrayList<>(); } + public List getToDelete() { return to_delete != null ? to_delete : new ArrayList<>(); } + public List getToUpdate() { return to_update != null ? to_update : new ArrayList<>(); } + } + + public static class FileInfo { + private String path; + private String url; + private long size; + private String hash; + + public String getPath() { return path; } + public String getUrl() { return url; } + public long getSize() { return size; } + public String getHash() { return hash; } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/ServerPack.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/ServerPack.java new file mode 100644 index 0000000..9dfd9ee --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/ServerPack.java @@ -0,0 +1,46 @@ +package me.sashegdev.zernmc.launcher.minecraft; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class ServerPack { + private final String name; + private final int version; + private final String minecraftVersion; + private final String loaderType; + private final String loaderVersion; + private final LocalDateTime updatedAt; + private final int filesCount; + + public ServerPack(String name, int version, String minecraftVersion, + String loaderType, String loaderVersion, + LocalDateTime updatedAt, int filesCount) { + this.name = name; + this.version = version; + this.minecraftVersion = minecraftVersion; + this.loaderType = loaderType; + this.loaderVersion = loaderVersion; + this.updatedAt = updatedAt; + this.filesCount = filesCount; + } + + public String getName() { return name; } + public int getVersion() { return version; } + public String getMinecraftVersion() { return minecraftVersion; } + public String getLoaderType() { return loaderType; } + public String getLoaderVersion() { return loaderVersion; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public int getFilesCount() { return filesCount; } + + @Override + public String toString() { + if (updatedAt != null) { + return String.format("%s [%s + %s v%d] - %d файлов (обновлен: %s)", + name, minecraftVersion, loaderType, version, filesCount, + updatedAt.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))); + } else { + return String.format("%s [%s + %s v%d] - %d файлов", + name, minecraftVersion, loaderType, version, filesCount); + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java new file mode 100644 index 0000000..ebfb896 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java @@ -0,0 +1,213 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +public class FabricInstaller { + + private final Instance instance; + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .build(); + + public FabricInstaller(Instance instance) { + this.instance = instance; + } + + public boolean install(String minecraftVersion, String loaderVersion) throws Exception { + System.out.println(ZAnsi.cyan("Установка Fabric " + loaderVersion + " для Minecraft " + minecraftVersion)); + + Path instancePath = instance.getPath(); + cleanOldFabricLoaders(); + + VersionInstaller versionInstaller = new VersionInstaller(instancePath); + String assetIndex = versionInstaller.install(minecraftVersion); + + System.out.println(ZAnsi.green("Asset index получен: " + assetIndex)); + + instance.setAssetIndex(assetIndex); + instance.setMinecraftVersion(minecraftVersion); + + String installerVersion = getLatestInstallerVersion(); + String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/" + + installerVersion + "/fabric-installer-" + installerVersion + ".jar"; + + Path installerJar = instancePath.resolve("fabric-installer.jar"); + + if (!Files.exists(installerJar)) { + ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%"); + downloadFileWithFallback(installerUrl, installerJar); + ProgressBar.finish("Fabric Installer скачан"); + } + + System.out.println(ZAnsi.cyan("Запуск Fabric Installer...")); + + String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion; + + ProcessBuilder pb = new ProcessBuilder( + "java", "-jar", installerJar.toAbsolutePath().toString(), + "client", + "-dir", instancePath.toAbsolutePath().toString(), + "-mcversion", minecraftVersion, + "-loader", loaderVersion, + "-noprofile" + ); + + pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); + pb.redirectError(ProcessBuilder.Redirect.INHERIT); + + Process process = pb.start(); + int exitCode = process.waitFor(); + + if (exitCode != 0) { + System.out.println(ZAnsi.brightRed("Fabric Installer завершился с ошибкой (код " + exitCode + ")")); + return false; + } + + Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId); + + if (Files.exists(fabricVersionDir)) { + System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!")); + + instance.setLoaderType("fabric"); + instance.setLoaderVersion(loaderVersion); + instance.setFabricVersionId(fabricVersionId); // ← СОХРАНЯЕМ + + ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex); + + return true; + } else { + System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена.")); + return false; + } + } + + private void downloadFileWithFallback(String url, Path target) throws Exception { + try { + ZHttpClient.downloadFile(url, target); + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Не удалось скачать Fabric Installer: " + e.getMessage())); + throw e; + } + } + + private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException { + Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json"); + + if (!Files.exists(versionJson)) { + System.out.println(ZAnsi.yellow("JSON файл версии не найден: " + versionJson)); + return; + } + + String content = Files.readString(versionJson); + + // Проверяем и исправляем asset index + if (!content.contains("\"assets\":\"" + assetIndex + "\"")) { + System.out.println(ZAnsi.yellow("Исправляем asset index в JSON файле версии...")); + + // Заменяем assets на правильное значение + content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\""); + + // Также проверяем assetIndex + if (content.contains("\"assetIndex\"")) { + content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\""); + } + + Files.writeString(versionJson, content); + System.out.println(ZAnsi.green("Asset index исправлен на: " + assetIndex)); + } else { + System.out.println(ZAnsi.green("Asset index в JSON версии правильный: " + assetIndex)); + } + } + + private void cleanOldFabricLoaders() throws IOException { + Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader"); + if (!Files.exists(librariesDir)) return; + + System.out.println(ZAnsi.yellow("Очистка старых версий Fabric Loader...")); + + try (var stream = Files.walk(librariesDir)) { + stream.filter(Files::isDirectory) + .filter(dir -> dir.getFileName().toString().matches("\\d+\\.\\d+\\.\\d+.*")) + .forEach(dir -> { + try { + Files.walk(dir) + .sorted((a,b) -> b.compareTo(a)) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException ignored) {} + }); + } catch (IOException ignored) {} + }); + } + } + + private String getLatestInstallerVersion() throws Exception { + try { + // Используем ZHttpClient с умным прокси + String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml"); + int start = xml.indexOf("") + 8; + int end = xml.indexOf("", start); + return xml.substring(start, end).trim(); + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Ошибка получения версии Fabric Installer: " + e.getMessage())); + throw new Exception("Не удалось получить версию Fabric Installer", e); + } + } + + // под рефактор оставить + private String downloadString(String url) throws Exception { + Exception lastException = null; + + for (int attempt = 1; attempt <= 3; attempt++) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(30 * attempt)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() == 200) { + return resp.body(); + } + throw new IOException("HTTP " + resp.statusCode()); + } catch (Exception e) { + lastException = e; + System.out.println(ZAnsi.yellow("Попытка " + attempt + " не удалась: " + e.getMessage())); + if (attempt < 3) { + Thread.sleep(1000 * attempt); + } + } + } + + throw lastException; + } + + private void downloadFile(String url, Path target) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(60)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, + HttpResponse.BodyHandlers.ofFile(target)); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode() + " при скачивании " + url); + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java new file mode 100644 index 0000000..2330fd6 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java @@ -0,0 +1,270 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import java.io.*; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.HashMap; +import java.util.Map; + +public class ForgeInstaller { + + private final Instance instance; + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(30)) + .build(); + + public ForgeInstaller(Instance instance) { + this.instance = instance; + } + + public boolean install(String mcVersion, String forgeVersion) throws Exception { + System.out.println(ZAnsi.cyan("Установка Forge " + forgeVersion + " для Minecraft " + mcVersion)); + + // Шаг 1: Устанавливаем vanilla и получаем настоящий assetIndex + System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "...")); + VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); + + String assetIndex = vanillaInstaller.install(mcVersion); + + if (assetIndex == null || assetIndex.isEmpty()) { + System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft")); + return false; + } + + instance.setAssetIndex(assetIndex); + + // Шаг 2: Создаём launcher_profiles.json + createLauncherProfile(); + + // Шаг 3: Скачиваем Forge Installer с прогресс-баром + String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/" + + mcVersion + "-" + forgeVersion + + "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar"; + + Path installerJar = instance.getPath().resolve("forge-installer.jar"); + + System.out.println(ZAnsi.cyan("Скачивание Forge Installer...")); + downloadFileWithProgress(installerUrl, installerJar); + + // Шаг 4: Запускаем Forge Installer и показываем его вывод + System.out.println(ZAnsi.cyan("Запуск Forge Installer...")); + System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n")); + + boolean success = runForgeInstaller(installerJar); + + // После успешной установки Forge, но перед сохранением метаданных + if (success) { + // Докачиваем пропущенные библиотеки + try { + downloadMissingLibraries(mcVersion, forgeVersion); + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage())); + } + + System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " успешно установлен!")); + instance.setMinecraftVersion(mcVersion); + instance.setLoaderType("forge"); + instance.setLoaderVersion(forgeVersion); + + // Очищаем временный файл установщика + Files.deleteIfExists(installerJar); + return true; + } else { + System.out.println(ZAnsi.brightRed("\nОшибка при установке Forge!")); + return false; + } + } + + private void createLauncherProfile() throws IOException { + Path profilePath = instance.getPath().resolve("launcher_profiles.json"); + if (Files.exists(profilePath)) return; + + String minimalProfile = """ + { + "profiles": {}, + "selectedProfile": "Default" + } + """; + Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + System.out.println(ZAnsi.yellow("Создан launcher_profiles.json")); + } + + private void downloadFileWithProgress(String url, Path target) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1); + + try (InputStream in = response.body(); + FileOutputStream out = new FileOutputStream(target.toFile())) { + + byte[] buffer = new byte[8192]; + int bytesRead; + long totalRead = 0; + int lastPercent = -1; + + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + totalRead += bytesRead; + + if (contentLength > 0) { + int percent = (int) ((totalRead * 100) / contentLength); + if (percent != lastPercent) { + String downloaded = ProgressBar.formatBytes(totalRead); + String total = ProgressBar.formatBytes(contentLength); + ProgressBar.show("Forge Installer", percent, 100, "% (" + downloaded + "/" + total + ")"); + lastPercent = percent; + } + } else { + // Если размер неизвестен, показываем анимацию + char[] spinner = {'|', '/', '-', '\\'}; + int idx = (int) (totalRead / 1024) % 4; + System.out.print("\rСкачивание Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]); + } + } + } + + ProgressBar.finish("Forge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")"); + } + + private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException { + // Пробуем до 3 раз с разными опциями + int maxRetries = 3; + int attempt = 1; + + while (attempt <= maxRetries) { + System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries)); + + ProcessBuilder pb = new ProcessBuilder( + "java", + "-jar", + installerJar.toAbsolutePath().toString(), + "--installClient" + ); + + // Добавляем JVM аргументы для увеличения таймаутов + pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); + + pb.directory(instance.getPath().toFile()); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + // Читаем вывод в реальном времени + StringBuilder output = new StringBuilder(); + boolean hasErrors = false; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + + // Форматируем вывод Forge Installer + if (line.contains("Downloading") || line.contains("Extracting")) { + System.out.println(ZAnsi.blue(" -> " + line)); + } else if (line.contains("SUCCESS") || line.contains("successfully")) { + System.out.println(ZAnsi.brightGreen(" + " + line)); + } else if (line.contains("WARNING") || line.contains("warning")) { + System.out.println(ZAnsi.yellow(" ! " + line)); + } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { + System.out.println(ZAnsi.brightRed(" X " + line)); + if (line.contains("timed out") || line.contains("failed to download")) { + hasErrors = true; + } + } else if (!line.isBlank()) { + System.out.println(" " + line); + } + } + } + + int exitCode = process.waitFor(); + + // Если успешно или нет ошибок скачивания + if (exitCode == 0 && !hasErrors) { + return true; + } + + // Если ошибка и это не последняя попытка + if (attempt < maxRetries) { + System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд...")); + Thread.sleep(5000); + + // Очищаем временные файлы перед повтором + Path librariesDir = instance.getPath().resolve("libraries"); + if (Files.exists(librariesDir)) { + // Удаляем только частично скачанные библиотеки Forge + try (var stream = Files.walk(librariesDir)) { + stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar")) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException e) { /* ignore */ } + }); + } + } + } else { + System.out.println(ZAnsi.brightRed("Forge Installer завершился с кодом ошибки: " + exitCode)); + + // Показываем возможное решение + if (output.toString().contains("timed out")) { + System.out.println(ZAnsi.yellow("\nВозможные решения:")); + System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение")); + System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора")); + System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр")); + System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию Forge")); + } + } + + attempt++; + } + + return false; + } + + private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception { + System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек...")); + + // Список проблемных библиотек и их альтернативные URL + Map alternativeUrls = new HashMap<>(); + alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", + "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar"); + alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", + "https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"); + + Path librariesDir = instance.getPath().resolve("libraries"); + + for (Map.Entry entry : alternativeUrls.entrySet()) { + Path target = librariesDir.resolve(entry.getKey()); + if (!Files.exists(target)) { + Files.createDirectories(target.getParent()); + System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName())); + + for (int attempt = 1; attempt <= 3; attempt++) { + try { + downloadFileWithProgress(entry.getValue(), target); + break; + } catch (Exception e) { + if (attempt == 3) throw e; + System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3...")); + Thread.sleep(2000); + } + } + } + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java new file mode 100644 index 0000000..5f345d0 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java @@ -0,0 +1,271 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; + +import java.io.*; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.HashMap; +import java.util.Map; + +public class NeoForgeInstaller { + + private final Instance instance; + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(30)) + .build(); + + public NeoForgeInstaller(Instance instance) { + this.instance = instance; + } + + public boolean install(String mcVersion, String neoForgeVersion) throws Exception { + System.out.println(ZAnsi.cyan("Установка NeoForge " + neoForgeVersion + " для Minecraft " + mcVersion)); + + System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "...")); + VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); + String assetIndex = vanillaInstaller.install(mcVersion); + + if (assetIndex == null || assetIndex.isEmpty()) { + System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft")); + return false; + } + + instance.setAssetIndex(assetIndex); + createLauncherProfile(); + + String mavenGroup = getMavenGroup(mcVersion); + String mavenArtifact = getMavenArtifact(mcVersion); + + String installerUrl = "https://maven.neoforged.net/releases/" + + mavenGroup.replace('.', '/') + "/" + + mavenArtifact + "/" + + neoForgeVersion + + "/" + mavenArtifact + "-" + neoForgeVersion + "-installer.jar"; + + Path installerJar = instance.getPath().resolve("neoforge-installer.jar"); + + System.out.println(ZAnsi.cyan("Скачивание NeoForge Installer...")); + downloadFileWithProgress(installerUrl, installerJar); + + System.out.println(ZAnsi.cyan("Запуск NeoForge Installer...")); + System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n")); + + boolean success = runNeoForgeInstaller(installerJar); + + if (success) { + try { + downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact); + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage())); + } + + System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " успешно установлен!")); + instance.setMinecraftVersion(mcVersion); + instance.setLoaderType("neoforge"); + instance.setLoaderVersion(neoForgeVersion); + + Files.deleteIfExists(installerJar); + return true; + } else { + System.out.println(ZAnsi.brightRed("\nОшибка при установке NeoForge!")); + return false; + } + } + + private String getMavenGroup(String mcVersion) { + if (mcVersion.equals("1.20.1")) { + return "net.neoforged"; + } + return "net.neoforged"; + } + + private String getMavenArtifact(String mcVersion) { + if (mcVersion.equals("1.20.1")) { + return "forge"; + } + return "neoforge"; + } + + private void createLauncherProfile() throws IOException { + Path profilePath = instance.getPath().resolve("launcher_profiles.json"); + if (Files.exists(profilePath)) return; + + String minimalProfile = """ + { + "profiles": {}, + "selectedProfile": "Default" + } + """; + Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + System.out.println(ZAnsi.yellow("Создан launcher_profiles.json")); + } + + private void downloadFileWithProgress(String url, Path target) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1); + + try (InputStream in = response.body(); + FileOutputStream out = new FileOutputStream(target.toFile())) { + + byte[] buffer = new byte[8192]; + int bytesRead; + long totalRead = 0; + int lastPercent = -1; + + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + totalRead += bytesRead; + + if (contentLength > 0) { + int percent = (int) ((totalRead * 100) / contentLength); + if (percent != lastPercent) { + String downloaded = ProgressBar.formatBytes(totalRead); + String total = ProgressBar.formatBytes(contentLength); + ProgressBar.show("NeoForge Installer", percent, 100, "% (" + downloaded + "/" + total + ")"); + lastPercent = percent; + } + } else { + char[] spinner = {'|', '/', '-', '\\'}; + int idx = (int) (totalRead / 1024) % 4; + System.out.print("\rСкачивание NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]); + } + } + } + + ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")"); + } + + private boolean runNeoForgeInstaller(Path installerJar) throws IOException, InterruptedException { + int maxRetries = 3; + int attempt = 1; + + while (attempt <= maxRetries) { + System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries)); + + ProcessBuilder pb = new ProcessBuilder( + "java", + "-jar", + installerJar.toAbsolutePath().toString(), + "--installClient" + ); + + pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); + pb.directory(instance.getPath().toFile()); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + StringBuilder output = new StringBuilder(); + boolean hasErrors = false; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + + if (line.contains("Downloading") || line.contains("Extracting")) { + System.out.println(ZAnsi.blue(" -> " + line)); + } else if (line.contains("SUCCESS") || line.contains("successfully")) { + System.out.println(ZAnsi.brightGreen(" + " + line)); + } else if (line.contains("WARNING") || line.contains("warning")) { + System.out.println(ZAnsi.yellow(" ! " + line)); + } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { + System.out.println(ZAnsi.brightRed(" X " + line)); + if (line.contains("timed out") || line.contains("failed to download")) { + hasErrors = true; + } + } else if (!line.isBlank()) { + System.out.println(" " + line); + } + } + } + + int exitCode = process.waitFor(); + + if (exitCode == 0 && !hasErrors) { + return true; + } + + if (attempt < maxRetries) { + System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд...")); + Thread.sleep(5000); + + Path librariesDir = instance.getPath().resolve("libraries"); + if (Files.exists(librariesDir)) { + try (var stream = Files.walk(librariesDir)) { + stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar")) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException e) { /* ignore */ } + }); + } + } + } else { + System.out.println(ZAnsi.brightRed("NeoForge Installer завершился с кодом ошибки: " + exitCode)); + + if (output.toString().contains("timed out")) { + System.out.println(ZAnsi.yellow("\nВозможные решения:")); + System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение")); + System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора")); + System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр")); + System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию NeoForge")); + } + } + + attempt++; + } + + return false; + } + + private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception { + System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек...")); + + Map alternativeUrls = new HashMap<>(); + alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", + "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar"); + alternativeUrls.put("org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar", + "https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar"); + alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar", + "https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar"); + + Path librariesDir = instance.getPath().resolve("libraries"); + + for (Map.Entry entry : alternativeUrls.entrySet()) { + Path target = librariesDir.resolve(entry.getKey()); + if (!Files.exists(target)) { + Files.createDirectories(target.getParent()); + System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName())); + + for (int attempt = 1; attempt <= 3; attempt++) { + try { + downloadFileWithProgress(entry.getValue(), target); + break; + } catch (Exception e) { + if (attempt == 3) throw e; + System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3...")); + Thread.sleep(2000); + } + } + } + } + } +} diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java new file mode 100644 index 0000000..3c76009 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java @@ -0,0 +1,245 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class VersionInstaller { + + private final Path minecraftDir; + private final HttpClient httpClient; + private final ExecutorService executor = Executors.newFixedThreadPool(32); + + public VersionInstaller(Path minecraftDir) { + this.minecraftDir = minecraftDir; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .build(); + } + + public List getAvailableVersions() throws Exception { + String jsonString = downloadString("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"); + JSONObject root = new JSONObject(jsonString); + JSONArray versionsArray = root.getJSONArray("versions"); + + List versions = new ArrayList<>(); + + for (int i = 0; i < versionsArray.length(); i++) { + JSONObject v = versionsArray.getJSONObject(i); + String id = v.getString("id"); + String type = v.getString("type"); + String releaseTimeStr = v.getString("releaseTime").replace("Z", "").replace("+00:00", ""); + String url = v.getString("url"); + + LocalDateTime releaseTime = LocalDateTime.parse(releaseTimeStr); + versions.add(new MinecraftVersion(id, type, releaseTime, url)); + } + + versions.sort((a, b) -> b.getReleaseTime().compareTo(a.getReleaseTime())); + return versions; + } + + public String install(String versionId) throws Exception { + System.out.println(ZAnsi.cyan("Полная установка Minecraft " + versionId + "...")); + Path versionDir = minecraftDir.resolve("versions").resolve(versionId); + Files.createDirectories(versionDir); + + String versionUrl = getVersionUrl(versionId); + if (versionUrl == null) throw new Exception("Версия " + versionId + " не найдена"); + + String versionJson = downloadString(versionUrl); + Files.writeString(versionDir.resolve(versionId + ".json"), versionJson); + + JSONObject versionData = new JSONObject(versionJson); + + // client.jar + downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"), + versionDir.resolve(versionId + ".jar"), "client.jar"); + + // Библиотеки + System.out.println(ZAnsi.cyan("Скачивание библиотек...")); + downloadLibraries(versionData.getJSONArray("libraries")); + + String assetIndex; + if (versionData.has("assetIndex")) { + assetIndex = versionData.getJSONObject("assetIndex").getString("id"); + } else { + assetIndex = versionData.getString("assets"); + } + + System.out.println(ZAnsi.cyan("Asset index: " + assetIndex)); + + // Скачиваем ассеты используя правильный индекс + System.out.println(ZAnsi.cyan("Скачивание ассетов...")); + downloadAssets(versionData, assetIndex); + + System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " полностью установлен!")); + return assetIndex; // ← возвращаем "5" а не "1.20.1" + } + + private void downloadLibraries(JSONArray libraries) throws Exception { + int total = libraries.length(); + int count = 0; + + for (int i = 0; i < total; i++) { + JSONObject lib = libraries.getJSONObject(i); + if (lib.has("downloads") && lib.getJSONObject("downloads").has("artifact")) { + JSONObject art = lib.getJSONObject("downloads").getJSONObject("artifact"); + String url = art.getString("url"); + String path = art.getString("path"); + + Path target = minecraftDir.resolve("libraries").resolve(path); + Files.createDirectories(target.getParent()); + + try { + downloadFile(url, target, "library"); + } catch (Exception e) { + // Пропускаем проблемные библиотеки + } + } + count++; + ProgressBar.show("Библиотеки", count, total, "файлов"); + } + ProgressBar.finish("Библиотеки загружены"); + } + + private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception { + // Находим URL для asset index + JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex"); + String indexUrl = assetIndexInfo.getString("url"); + + Path indexesDir = minecraftDir.resolve("assets/indexes"); + Files.createDirectories(indexesDir); + Path indexPath = indexesDir.resolve(assetIndex + ".json"); // ← используем assetIndex + + System.out.println(ZAnsi.cyan("Скачивание asset index (" + assetIndex + ")...")); + downloadFile(indexUrl, indexPath, "asset index"); + + String jsonContent = Files.readString(indexPath); + JSONObject root = new JSONObject(jsonContent); + JSONObject objects = root.getJSONObject("objects"); + + System.out.println(ZAnsi.cyan("Скачивание " + objects.length() + " объектов ассетов (index: " + assetIndex + ")...")); + + int total = objects.length(); + int[] success = {0}; + int[] failed = {0}; + + List> futures = new ArrayList<>(); + + for (String key : objects.keySet()) { + JSONObject asset = objects.getJSONObject(key); + String hash = asset.getString("hash"); // ← вот это правильный хеш! + + String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; + Path target = minecraftDir.resolve("assets/objects") + .resolve(hash.substring(0, 2)) + .resolve(hash); + + Files.createDirectories(target.getParent()); + + CompletableFuture future = CompletableFuture.runAsync(() -> { + boolean downloaded = false; + for (int attempt = 1; attempt <= 3; attempt++) { + try { + downloadFile(url, target, ""); + synchronized (this) { + success[0]++; + ProgressBar.show("Ассеты", success[0], total, "файлов"); + } + downloaded = true; + break; + } catch (Exception e) { + if (attempt == 3) { + synchronized (this) { + failed[0]++; + } + System.err.println("Не удалось скачать " + hash); + } else { + try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {} + } + } + } + }, executor); + + futures.add(future); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + ProgressBar.finish("Ассеты загружены (" + success[0] + " успешно, " + failed[0] + " пропущено)"); + + if (failed[0] > 0) { + System.out.println(ZAnsi.yellow("Предупреждение: " + failed[0] + " файлов ассетов не удалось скачать.")); + System.out.println(ZAnsi.yellow("Игра запустится, но некоторые текстуры/звуки могут отсутствовать.")); + } + } + + public String getAssetIndexId(String versionId) throws Exception { + String versionUrl = getVersionUrl(versionId); + if (versionUrl == null) throw new Exception("Версия не найдена"); + + String versionJson = downloadString(versionUrl); + JSONObject versionData = new JSONObject(versionJson); + + if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) { + return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1 + } + return versionData.getString("assets"); // fallback (очень старые версии) + } + + private String getVersionUrl(String versionId) throws Exception { + for (MinecraftVersion v : getAvailableVersions()) { + if (v.getId().equals(versionId)) return v.getUrl(); + } + return null; + } + + private String downloadString(String url) throws Exception { + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) throw new IOException("HTTP " + resp.statusCode()); + return resp.body(); + } + + private void downloadFile(String url, Path target, String label) throws Exception { + if (!label.isEmpty()) { + ProgressBar.clearLine(); + System.out.println(ZAnsi.cyan("Скачивание " + label + "...")); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target)); + + if (response.statusCode() != 200) { + if (label.isEmpty()) return; // для ассетов молча + throw new IOException("HTTP " + response.statusCode() + " при скачивании " + label); + } + + if (!label.isEmpty()) { + long size = Files.size(target); + ProgressBar.finish(label + " (" + ProgressBar.formatBytes(size) + ")"); + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java new file mode 100644 index 0000000..0be55be --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java @@ -0,0 +1,426 @@ +package me.sashegdev.zernmc.launcher.minecraft.launch; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import org.json.JSONObject; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class LaunchCommandBuilder { + + private final Instance instance; + + public LaunchCommandBuilder(Instance instance) { + this.instance = instance; + } + + public List build(LaunchOptions options) throws Exception { + System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "...")); + + List command = new ArrayList<>(); + + String javaPath = "java"; + command.add(javaPath); + + command.addAll(getJvmArguments(options)); + + Path nativesDir = instance.getPath().resolve("natives"); + if (!Files.exists(nativesDir)) { + Files.createDirectories(nativesDir); + } + command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); + + VersionManifest manifest = resolveVersionManifest(); + if (manifest != null) { + command.add("-cp"); + command.add(buildClasspathFromManifest(manifest)); + + String mainClass = resolveMainClass(manifest); + command.add(mainClass); + + command.addAll(resolveGameArguments(manifest, options)); + } else { + command.add("-cp"); + command.add(buildVanillaClasspath()); + command.add(getVanillaMainClass()); + command.addAll(getVanillaGameArguments(options)); + } + + return command; + } + + private VersionManifest resolveVersionManifest() { + try { + Path versionJson = findVersionJson(); + if (versionJson != null && Files.exists(versionJson)) { + String content = Files.readString(versionJson); + JSONObject json = new JSONObject(content); + System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName())); + return new VersionManifest(json); + } + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage())); + } + return null; + } + + private Path findVersionJson() { + Path versionsDir = instance.getPath().resolve("versions"); + String loaderType = instance.getLoaderType().toLowerCase(); + String mcVersion = instance.getMinecraftVersion(); + String loaderVersion = instance.getLoaderVersion(); + + if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) { + String[] candidates = { + getVersionId(), + mcVersion + "-" + loaderType + "-" + loaderVersion, + loaderType + "-" + loaderVersion, + mcVersion + "-" + loaderVersion, + mcVersion + }; + for (String candidate : candidates) { + Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json"); + if (Files.exists(jsonPath)) { + return jsonPath; + } + } + + try { + if (Files.exists(versionsDir)) { + try (var stream = Files.list(versionsDir)) { + return stream + .filter(Files::isDirectory) + .filter(dir -> dir.getFileName().toString().contains("forge") || + dir.getFileName().toString().contains("neoforge")) + .filter(dir -> dir.getFileName().toString().contains(mcVersion)) + .findFirst() + .map(dir -> dir.resolve(dir.getFileName().toString() + ".json")) + .filter(Files::exists) + .orElse(null); + } + } + } catch (Exception ignored) {} + } + + Path fallback = versionsDir.resolve(mcVersion).resolve(mcVersion + ".json"); + if (Files.exists(fallback)) { + return fallback; + } + + return null; + } + + private String getVersionId() { + String loaderType = instance.getLoaderType().toLowerCase(); + String mcVersion = instance.getMinecraftVersion(); + String loaderVer = instance.getLoaderVersion(); + + if ("vanilla".equals(loaderType)) { + return mcVersion; + } + else if ("fabric".equals(loaderType)) { + String fabricId = instance.getFabricVersionId(); + if (fabricId != null && !fabricId.isEmpty()) { + return fabricId; + } + return "fabric-loader-" + loaderVer + "-" + mcVersion; + } + else if ("forge".equals(loaderType)) { + return mcVersion + "-forge-" + loaderVer; + } + else if ("neoforge".equals(loaderType)) { + if (mcVersion.equals("1.20.1")) { + return mcVersion + "-neoforge-" + loaderVer; + } + return "neoforge-" + loaderVer; + } + + return mcVersion; + } + + private String resolveMainClass(VersionManifest manifest) { + return manifest.getMainClass(); + } + + private String getVanillaMainClass() { + String loaderType = instance.getLoaderType().toLowerCase(); + if ("fabric".equals(loaderType)) { + return "net.fabricmc.loader.impl.launch.knot.KnotClient"; + } + return "net.minecraft.client.main.Main"; + } + + private List resolveGameArguments(VersionManifest manifest, LaunchOptions options) { + List args = new ArrayList<>(); + Map vars = buildVariableMap(options); + + for (String raw : manifest.getGameArguments()) { + args.add(resolveVariable(raw, vars)); + } + + if (options.getWidth() > 0) { + args.add("--width"); + args.add(String.valueOf(options.getWidth())); + } + if (options.getHeight() > 0) { + args.add("--height"); + args.add(String.valueOf(options.getHeight())); + } + + return args; + } + + private List getVanillaGameArguments(LaunchOptions options) { + List args = new ArrayList<>(); + + args.add("--version"); + args.add(instance.getName()); + args.add("--gameDir"); + args.add(instance.getPath().toAbsolutePath().toString()); + args.add("--assetsDir"); + args.add(instance.getPath().resolve("assets").toAbsolutePath().toString()); + args.add("--assetIndex"); + String assetIndex = instance.getAssetIndex(); + if (assetIndex == null || assetIndex.isEmpty()) { + assetIndex = instance.getMinecraftVersion(); + System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex)); + } else { + System.out.println(ZAnsi.green("Использую asset index: " + assetIndex)); + } + args.add(assetIndex); + args.add("--username"); + args.add(options.getUsername() != null ? options.getUsername() : "Player"); + args.add("--accessToken"); + args.add(options.getAccessToken() != null ? options.getAccessToken() : "0"); + args.add("--uuid"); + args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000"); + args.add("--userType"); + args.add("legacy"); + + return args; + } + + private Map buildVariableMap(LaunchOptions options) { + Map vars = new HashMap<>(); + + Path gameDir = instance.getPath().toAbsolutePath(); + Path assetsDir = gameDir.resolve("assets"); + Path nativesDir = gameDir.resolve("natives"); + Path librariesDir = gameDir.resolve("libraries"); + + vars.put("version_name", instance.getName()); + vars.put("game_directory", gameDir.toString()); + vars.put("assets_root", assetsDir.toString()); + vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion()); + vars.put("auth_player_name", options.getUsername() != null ? options.getUsername() : "Player"); + vars.put("auth_access_token", options.getAccessToken() != null ? options.getAccessToken() : "0"); + vars.put("auth_uuid", options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000"); + vars.put("auth_xuid", "0"); + vars.put("user_type", "legacy"); + vars.put("version_type", "release"); + vars.put("natives_directory", nativesDir.toString()); + vars.put("library_directory", librariesDir.toString()); + vars.put("launcher_name", "ZernMC"); + vars.put("launcher_version", "1.0"); + vars.put("classpath_separator", System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"); + vars.put("resolution_width", String.valueOf(options.getWidth() > 0 ? options.getWidth() : 1920)); + vars.put("resolution_height", String.valueOf(options.getHeight() > 0 ? options.getHeight() : 1080)); + vars.put("game_directory", gameDir.toString()); + + String loaderType = instance.getLoaderType().toLowerCase(); + if ("forge".equals(loaderType)) { + vars.put("forge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : ""); + } else if ("neoforge".equals(loaderType)) { + vars.put("neoforge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : ""); + vars.put("fml.neoForgeVersion", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : ""); + vars.put("fml.neoForgeGroup", "net.neoforged"); + } + + return vars; + } + + private String resolveVariable(String raw, Map vars) { + if (!raw.contains("${")) return raw; + String result = raw; + for (Map.Entry entry : vars.entrySet()) { + result = result.replace("${" + entry.getKey() + "}", entry.getValue()); + } + return result; + } + + private String buildClasspathFromManifest(VersionManifest manifest) throws Exception { + List paths = new ArrayList<>(); + Path librariesDir = instance.getPath().resolve("libraries"); + + for (VersionManifest.Library lib : manifest.getLibraries()) { + Path libPath = librariesDir.resolve(lib.artifactPath); + if (Files.exists(libPath)) { + paths.add(libPath.toAbsolutePath().toString()); + } else { + String mavenPath = mavenToPath(lib.name); + Path fallbackPath = librariesDir.resolve(mavenPath); + if (Files.exists(fallbackPath)) { + paths.add(fallbackPath.toAbsolutePath().toString()); + } else { + System.out.println(ZAnsi.yellow(" Библиотека не найдена: " + lib.name)); + } + } + } + + Path versionJar = findVersionJar(); + if (versionJar != null) { + paths.add(0, versionJar.toAbsolutePath().toString()); + } + + String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; + return String.join(separator, paths); + } + + private String buildVanillaClasspath() throws Exception { + List paths = new ArrayList<>(); + String versionId = getVersionId(); + Path versionJar = instance.getPath() + .resolve("versions") + .resolve(versionId) + .resolve(versionId + ".jar"); + + if (Files.exists(versionJar)) { + paths.add(versionJar.toAbsolutePath().toString()); + } else { + String mcVersion = instance.getMinecraftVersion(); + Path fallbackJar = instance.getPath() + .resolve("versions") + .resolve(mcVersion) + .resolve(mcVersion + ".jar"); + if (Files.exists(fallbackJar)) { + paths.add(fallbackJar.toAbsolutePath().toString()); + } + } + + Path librariesDir = instance.getPath().resolve("libraries"); + if (Files.exists(librariesDir)) { + try (var stream = Files.walk(librariesDir)) { + stream.filter(p -> p.toString().endsWith(".jar")) + .map(p -> p.toAbsolutePath().toString()) + .forEach(paths::add); + } + } + + String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; + return String.join(separator, paths); + } + + private Path findVersionJar() { + String versionId = getVersionId(); + Path versionsDir = instance.getPath().resolve("versions"); + + Path[] candidates = { + versionsDir.resolve(versionId).resolve(versionId + ".jar"), + versionsDir.resolve(instance.getMinecraftVersion()).resolve(instance.getMinecraftVersion() + ".jar") + }; + + for (Path candidate : candidates) { + if (Files.exists(candidate)) { + return candidate; + } + } + + try { + if (Files.exists(versionsDir)) { + try (var stream = Files.list(versionsDir)) { + return stream + .filter(Files::isDirectory) + .filter(dir -> dir.getFileName().toString().contains("forge") || + dir.getFileName().toString().contains("neoforge")) + .filter(dir -> dir.getFileName().toString().contains(instance.getMinecraftVersion())) + .findFirst() + .map(dir -> dir.resolve(dir.getFileName().toString() + ".jar")) + .filter(Files::exists) + .orElse(null); + } + } + } catch (Exception ignored) {} + + return null; + } + + private String mavenToPath(String mavenName) { + String[] parts = mavenName.split(":"); + if (parts.length < 3) return mavenName; + + String group = parts[0].replace('.', '/'); + String artifact = parts[1]; + String version = parts[2]; + + if (parts.length == 4) { + String classifier = parts[3]; + return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + "-" + classifier + ".jar"; + } + + return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + ".jar"; + } + + private List getJvmArguments(LaunchOptions options) { + List jvmArgs = new ArrayList<>(); + + int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096; + jvmArgs.add("-Xmx" + ramMB + "M"); + jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M"); + + jvmArgs.add("-XX:+UseG1GC"); + jvmArgs.add("-XX:+UnlockExperimentalVMOptions"); + jvmArgs.add("-XX:G1NewSizePercent=20"); + jvmArgs.add("-XX:G1ReservePercent=20"); + jvmArgs.add("-XX:MaxGCPauseMillis=50"); + jvmArgs.add("-XX:G1HeapRegionSize=32M"); + + String loaderType = instance.getLoaderType().toLowerCase(); + + if ("fabric".equals(loaderType)) { + jvmArgs.add("--add-modules=ALL-MODULE-PATH"); + jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); + jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED"); + } else if ("forge".equals(loaderType)) { + jvmArgs.add("--add-modules=ALL-MODULE-PATH"); + jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); + jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED"); + } else if ("neoforge".equals(loaderType)) { + jvmArgs.add("--add-modules=ALL-MODULE-PATH"); + jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); + jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); + jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED"); + } + + if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) { + jvmArgs.addAll(options.getExtraJvmArgs()); + } + + return jvmArgs; + } +} diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java new file mode 100644 index 0000000..2fdea9c --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java @@ -0,0 +1,165 @@ +package me.sashegdev.zernmc.launcher.minecraft.launch; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class VersionManifest { + + private final String id; + private final String mainClass; + private final String assetIndexId; + private final List jvmArguments; + private final List gameArguments; + private final List libraries; + + public VersionManifest(JSONObject json) { + this.id = json.getString("id"); + this.mainClass = json.getString("mainClass"); + + if (json.has("assetIndex")) { + JSONObject ai = json.getJSONObject("assetIndex"); + this.assetIndexId = ai.has("id") ? ai.getString("id") : "unknown"; + } else { + this.assetIndexId = "unknown"; + } + + this.jvmArguments = parseArguments(json, "jvm"); + this.gameArguments = parseArguments(json, "game"); + this.libraries = parseLibraries(json); + } + + public String getId() { return id; } + public String getMainClass() { return mainClass; } + public String getAssetIndexId() { return assetIndexId; } + public List getJvmArguments() { return jvmArguments; } + public List getGameArguments() { return gameArguments; } + public List getLibraries() { return libraries; } + + private List parseArguments(JSONObject json, String type) { + List args = new ArrayList<>(); + if (!json.has("arguments")) return args; + + JSONObject arguments = json.getJSONObject("arguments"); + if (!arguments.has(type)) return args; + + JSONArray arr = arguments.getJSONArray(type); + for (int i = 0; i < arr.length(); i++) { + Object item = arr.get(i); + if (item instanceof String) { + args.add((String) item); + } else if (item instanceof JSONObject) { + JSONObject ruleObj = (JSONObject) item; + if (ruleMatches(ruleObj)) { + Object value = ruleObj.get("value"); + if (value instanceof String) { + args.add((String) value); + } else if (value instanceof JSONArray) { + JSONArray valArr = (JSONArray) value; + for (int j = 0; j < valArr.length(); j++) { + args.add(valArr.getString(j)); + } + } + } + } + } + return args; + } + + private boolean ruleMatches(JSONObject ruleObj) { + JSONArray rules = ruleObj.getJSONArray("rules"); + boolean result = false; + for (int i = 0; i < rules.length(); i++) { + JSONObject rule = rules.getJSONObject(i); + String action = rule.getString("action"); + boolean matches = true; + + if (rule.has("os")) { + JSONObject os = rule.getJSONObject("os"); + String osName = System.getProperty("os.name").toLowerCase(); + if (os.has("name")) { + String reqName = os.getString("name").toLowerCase(); + if (reqName.equals("windows") && !osName.contains("win")) matches = false; + else if (reqName.equals("linux") && !osName.contains("linux") && !osName.contains("nix")) matches = false; + else if (reqName.equals("osx") && !osName.contains("mac")) matches = false; + } + if (os.has("arch")) { + String reqArch = os.getString("arch"); + String osArch = System.getProperty("os.arch"); + if (!reqArch.equals(osArch)) matches = false; + } + } + + if (rule.has("features")) { + JSONObject features = rule.getJSONObject("features"); + for (String key : features.keySet()) { + if (key.startsWith("is_demo_user") || key.startsWith("has_custom_resolution")) continue; + matches = false; + } + } + + if ("allow".equals(action) && matches) { + result = true; + } else if ("disallow".equals(action) && matches) { + return false; + } + } + return result; + } + + private List parseLibraries(JSONObject json) { + List libs = new ArrayList<>(); + if (!json.has("libraries")) return libs; + + JSONArray arr = json.getJSONArray("libraries"); + for (int i = 0; i < arr.length(); i++) { + JSONObject libJson = arr.getJSONObject(i); + if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) { + String name = libJson.getString("name"); + String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path"); + Library lib = new Library(name, artifactPath); + + if (libJson.has("natives")) { + JSONObject natives = libJson.getJSONObject("natives"); + for (String key : natives.keySet()) { + String osKey = key.toLowerCase(); + lib.natives.put(osKey, natives.getString(key)); + } + } + + if (libJson.has("rules")) { + JSONObject dummyObj = new JSONObject(); + dummyObj.put("rules", libJson.getJSONArray("rules")); + dummyObj.put("value", ""); + if (ruleMatches(dummyObj)) { + libs.add(lib); + } + } else { + libs.add(lib); + } + } + } + return libs; + } + + public static class Library { + public final String name; + public final String artifactPath; + public final Map natives = new HashMap<>(); + + public Library(String name, String artifactPath) { + this.name = name; + this.artifactPath = artifactPath; + } + + public String getSimpleName() { + return name.substring(name.indexOf(':') + 1); + } + } +} diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java new file mode 100644 index 0000000..ea150b6 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java @@ -0,0 +1,41 @@ +package me.sashegdev.zernmc.launcher.minecraft.model; + +import java.util.ArrayList; +import java.util.List; + +public class LaunchOptions { + private String username = "Player"; + private String uuid = "00000000-0000-0000-0000-000000000000"; + private String accessToken = "token"; + private int maxMemory = 4096; + private boolean fullscreen = false; + private String javaPath = "java"; + private List extraJvmArgs = new ArrayList<>(); + private int width = 854; + private int height = 480; + + // Геттеры и сеттеры + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getUuid() { return uuid; } + public void setUuid(String uuid) { this.uuid = uuid; } + + public String getAccessToken() { return accessToken; } + public void setAccessToken(String accessToken) { this.accessToken = accessToken; } + + public int getMaxMemory() { return maxMemory; } + public void setMaxMemory(int maxMemory) { this.maxMemory = maxMemory; } + + public boolean isFullscreen() { return fullscreen; } + public void setFullscreen(boolean fullscreen) { this.fullscreen = fullscreen; } + + public String getJavaPath() { return javaPath; } + public void setJavaPath(String javaPath) { this.javaPath = javaPath; } + + public List getExtraJvmArgs() { return extraJvmArgs; } + public void setExtraJvmArgs(List extraJvmArgs) { this.extraJvmArgs = extraJvmArgs; } + + public int getWidth() { return width; } + public int getHeight() { return height; } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/MinecraftVersion.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/MinecraftVersion.java new file mode 100644 index 0000000..9e5dcc6 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/MinecraftVersion.java @@ -0,0 +1,27 @@ +package me.sashegdev.zernmc.launcher.minecraft.model; + +import java.time.LocalDateTime; + +public class MinecraftVersion { + private final String id; + private final String type; // release, snapshot, old_beta, old_alpha + private final LocalDateTime releaseTime; + private final String url; + + public MinecraftVersion(String id, String type, LocalDateTime releaseTime, String url) { + this.id = id; + this.type = type; + this.releaseTime = releaseTime; + this.url = url; + } + + public String getId() { return id; } + public String getType() { return type; } + public LocalDateTime getReleaseTime() { return releaseTime; } + public String getUrl() { return url; } + + @Override + public String toString() { + return id + " (" + type + ")"; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/ArrowMenu.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/ArrowMenu.java new file mode 100644 index 0000000..1a1720c --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/ArrowMenu.java @@ -0,0 +1,103 @@ +package me.sashegdev.zernmc.launcher.ui; + +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.jline.utils.InfoCmp; + +import java.io.IOException; +import java.util.List; + +public class ArrowMenu { + + private final String title; + private final List options; + private int selected = 0; + private final Terminal terminal; + + private static final int VISIBLE_ITEMS = 7; // сколько строк показывать в списке + + public ArrowMenu(String title, List options) throws IOException { + this.title = title; + this.options = options; + this.terminal = TerminalBuilder.builder() + .system(true) + .jna(true) + .build(); + } + + public int show() throws IOException { + terminal.enterRawMode(); + terminal.puts(InfoCmp.Capability.clear_screen); + terminal.puts(InfoCmp.Capability.cursor_invisible); + + try { + while (true) { + printPagedMenu(); + int key = terminal.reader().read(); + + if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц' + || key == 'k' || key == 'K' || key == 'л' || key == 'Л') { // Up / Arrow Up + selected = (selected - 1 + options.size()) % options.size(); + } + else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы' + || key == 'j' || key == 'J' || key == 'о' || key == 'О') { // Down / Arrow Down + selected = (selected + 1) % options.size(); + } + else if (key == 13 || key == 10) { // Enter + return selected; + } + else if (key == 27) { // Esc or arrow escape seq + int next = terminal.reader().read(50); + if (next == 91) { // '[' — start of arrow escape sequence + int arrow = terminal.reader().read(50); + if (arrow == 65) { // 'A' — Up arrow + selected = (selected - 1 + options.size()) % options.size(); + } else if (arrow == 66) { // 'B' — Down arrow + selected = (selected + 1) % options.size(); + } + // else — unknown escape seq, ignore + } else { + return -1; // genuine Esc + } + } + } + } finally { + terminal.puts(InfoCmp.Capability.cursor_visible); + terminal.close(); + } + } + + private void printPagedMenu() { + StringBuilder sb = new StringBuilder(); + sb.append("\033[H\033[2J"); + + // Заголовок (фиксированный) + sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n"); + sb.append(ZAnsi.yellow(title)).append("\n\n"); + + // Вычисляем диапазон отображаемых элементов + int start = Math.max(0, selected - (VISIBLE_ITEMS / 2)); + int end = Math.min(options.size(), start + VISIBLE_ITEMS); + + // Если в конце списка — подтягиваем вверх + if (end - start < VISIBLE_ITEMS && start > 0) { + start = Math.max(0, end - VISIBLE_ITEMS); + } + + for (int i = start; i < end; i++) { + String line = options.get(i); + if (i == selected) { + sb.append(ZAnsi.selected(line)).append("\n"); + } else { + sb.append(ZAnsi.white(" " + line)).append("\n"); + } + } + + // Подсказка внизу (фиксированная) + sb.append("\n") + .append(ZAnsi.white("W/S (Ц/Ы) или ↑/↓ - перемещение | Enter - выбрать | Esc - назад")); + + System.out.print(sb); + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/LaunchServer.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/LaunchServer.java new file mode 100644 index 0000000..2bb939f --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/LaunchServer.java @@ -0,0 +1,254 @@ +package me.sashegdev.zernmc.launcher.ui.jcef; + +import com.google.gson.Gson; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.Headers; +import me.sashegdev.zernmc.launcher.api.ApiResponse; +import me.sashegdev.zernmc.launcher.api.LauncherAPI; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; + +public class LaunchServer { + private static final int PORT = 8080; + private final LauncherAPI api; + private final UIBridge bridge; + private HttpServer server; + private final Gson gson = new Gson(); + + public LaunchServer(UIBridge bridge) { + this.api = new LauncherAPI(); + this.bridge = bridge; + } + + public void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0); + + server.createContext("/api/login", this::handleLogin); + server.createContext("/api/account", this::handleAccount); + server.createContext("/api/instances", this::handleInstances); + server.createContext("/api/launch", this::handleLaunch); + server.createContext("/api/install", this::handleInstall); + server.createContext("/api/logs", this::handleLogs); + server.createContext("/api/exit", this::handleExit); + server.createContext("/ui/", this::handleStatic); + + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + + bridge.log("HTTP сервер запущен на порту " + PORT); + } + + public void stop() { + if (server != null) { + server.stop(0); + } + } + + private void handleLogin(HttpExchange exchange) throws IOException { + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + try { + Map body = parseJson(exchange.getRequestBody()); + String username = body.get("username"); + String password = body.get("password"); + + var result = api.login(username, password); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("username", result.getData().getUsername()); + data.put("token", result.getData().getToken()); + sendJson(exchange, ApiResponse.success(data)); + bridge.log("Пользователь вошел: " + username); + } else { + sendJson(exchange, ApiResponse.error(result.getError())); + } + } catch (Exception e) { + sendJson(exchange, ApiResponse.error("Ошибка: " + e.getMessage())); + } + } + + private void handleAccount(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + if (!api.isLoggedIn()) { + sendJson(exchange, ApiResponse.error("Не авторизован")); + return; + } + + try { + Map data = new HashMap<>(); + data.put("username", api.getCurrentUsername()); + data.put("passActive", AuthManager.hasActivePass()); + sendJson(exchange, ApiResponse.success(data)); + } catch (Exception e) { + sendJson(exchange, ApiResponse.error(e.getMessage())); + } + } + + private void handleInstances(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + try { + var result = api.getAllInstances(); + sendJson(exchange, result); + } catch (Exception e) { + sendJson(exchange, ApiResponse.error(e.getMessage())); + } + } + + private void handleLaunch(HttpExchange exchange) throws IOException { + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + if (!api.isLoggedIn()) { + sendJson(exchange, ApiResponse.error("Не авторизован")); + return; + } + + try { + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + + var result = api.launch(name); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("pid", result.getData().getPid()); + data.put("status", result.getData().getStatus()); + sendJson(exchange, ApiResponse.success(data)); + bridge.log("Запущена сборка: " + name); + } else { + sendJson(exchange, ApiResponse.error(result.getError())); + } + } catch (Exception e) { + sendJson(exchange, ApiResponse.error("Ошибка запуска: " + e.getMessage())); + } + } + + private void handleInstall(HttpExchange exchange) throws IOException { + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + if (!api.isLoggedIn()) { + sendJson(exchange, ApiResponse.error("Не авторизован")); + return; + } + + try { + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + String version = body.get("version"); + String loader = body.get("loader"); + + bridge.log("Установка сборки: " + name + " " + version + " " + loader); + + var createResult = api.instances().createInstance(name); + if (!createResult.isSuccess()) { + sendJson(exchange, ApiResponse.error(createResult.getError())); + return; + } + + Instance instance = InstanceManager.getInstance(name); + if (instance != null) { + instance.setMinecraftVersion(version); + instance.setLoaderType(loader); + } + + sendJson(exchange, ApiResponse.success(true)); + bridge.log("Сборка установлена: " + name); + } catch (Exception e) { + sendJson(exchange, ApiResponse.error("Ошибка установки: " + e.getMessage())); + } + } + + private void handleLogs(HttpExchange exchange) throws IOException { + String logs = bridge.getLogs(); + sendJson(exchange, ApiResponse.success(logs)); + } + + private void handleExit(HttpExchange exchange) throws IOException { + bridge.log("Завершение работы..."); + System.exit(0); + } + + private void handleStatic(HttpExchange exchange) throws IOException { + String path = exchange.getRequestURI().getPath(); + if (path.equals("/ui/") || path.equals("/ui")) { + path = "/ui/index.html"; + } + + var resource = getClass().getResource(path); + + if (resource == null) { + exchange.sendResponseHeaders(404, 0); + exchange.close(); + return; + } + + try { + byte[] content = resource.openStream().readAllBytes(); + String contentType = getContentType(path); + + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, content.length); + + OutputStream os = exchange.getResponseBody(); + os.write(content); + os.close(); + } catch (IOException ignored) {} + } + + private String getContentType(String path) { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + return "text/plain"; + } + + @SuppressWarnings("unchecked") + private Map parseJson(InputStream body) { + try { + String json = new String(body.readAllBytes(), StandardCharsets.UTF_8); + return gson.fromJson(json, Map.class); + } catch (Exception e) { + return new HashMap<>(); + } + } + + private void sendJson(HttpExchange exchange, ApiResponse response) { + try { + String json = gson.toJson(response); + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + + OutputStream os = exchange.getResponseBody(); + os.write(bytes); + os.close(); + } catch (IOException ignored) {} + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UIBridge.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UIBridge.java new file mode 100644 index 0000000..9768e80 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UIBridge.java @@ -0,0 +1,11 @@ +package me.sashegdev.zernmc.launcher.ui.jcef; + +public class UIBridge { + public void log(String message) { + System.out.println("[UI] " + message); + } + + public String getLogs() { + return ""; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UILauncher.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UILauncher.java new file mode 100644 index 0000000..5c99b4f --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UILauncher.java @@ -0,0 +1,102 @@ +package me.sashegdev.zernmc.launcher.ui.jcef; + +import me.sashegdev.zernmc.launcher.api.LauncherAPI; + +import java.awt.*; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.URI; + +public class UILauncher { + private static final String APP_TITLE = "ZernMC Launcher"; + private final LauncherAPI api; + private final UIBridge bridge; + private LaunchServer server; + + public UILauncher() { + this.api = new LauncherAPI(); + this.bridge = new UIBridge(); + } + + public void launch() throws Exception { + redirectSystemLogs(); + bridge.log("Запуск UI..."); + + server = new LaunchServer(bridge); + server.start(); + + openBrowser(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + bridge.log("Выключение..."); + if (server != null) server.stop(); + })); + + Thread.currentThread().join(); + } + + private void openBrowser() { + String url = "http://localhost:8080/ui/"; + bridge.log("Открытие браузера: " + url); + + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI.create(url)); + bridge.log("Браузер открыт"); + } else { + bridge.log("Desktop browsing not supported"); + } + } catch (Exception e) { + bridge.log("Ошибка открытия браузера: " + e.getMessage()); + } + } + + private void redirectSystemLogs() { + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + + System.setOut(new PrintStream(new ByteArrayOutputStream() { + @Override + public void write(byte[] b, int off, int len) { + String line = new String(b, off, len).trim(); + if (!line.isEmpty()) { + bridge.log(line); + } + try { + originalOut.write(b, off, len); + } catch (Exception ignored) {} + } + + @Override + public void write(int b) { + try { + originalOut.write(b); + } catch (Exception ignored) {} + } + })); + + System.setErr(new PrintStream(new ByteArrayOutputStream() { + @Override + public void write(byte[] b, int off, int len) { + String line = new String(b, off, len).trim(); + if (!line.isEmpty()) { + bridge.log("[ERROR] " + line); + } + try { + originalErr.write(b, off, len); + } catch (Exception ignored) {} + } + })); + } + + public static void main(String[] args) { + try { + UILauncher launcher = new UILauncher(); + launcher.launch(); + } catch (Exception e) { + System.err.println("UI launch failed: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java new file mode 100644 index 0000000..77dd181 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -0,0 +1,326 @@ +package me.sashegdev.zernmc.launcher.ui.jfx; + +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.scene.web.WebView; +import javafx.scene.web.WebEngine; +import javafx.stage.Stage; +import javafx.concurrent.Worker; +import com.google.gson.Gson; +import me.sashegdev.zernmc.launcher.api.LauncherAPI; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; +import me.sashegdev.zernmc.launcher.utils.Config; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.Headers; + +public class JFXLauncher extends Application { + private static final int PORT = 8080; + private static final String APP_TITLE = "ZernMC Launcher"; + private final LauncherAPI api = new LauncherAPI(); + private final Gson gson = new Gson(); + private HttpServer server; + private StringBuilder logBuffer = new StringBuilder(); + private Stage mainStage; + + public static void main(String[] args) { + launch(args); + } + + @Override + public void start(Stage stage) { + this.mainStage = stage; + + try { + log("Запуск JFX UI..."); + startServer(); + + WebView webView = new WebView(); + WebEngine engine = webView.getEngine(); + engine.setJavaScriptEnabled(true); + + engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { + if (newState == Worker.State.SUCCEEDED) { + log("Страница загружена"); + } + }); + + String url = "http://localhost:" + PORT + "/ui/"; + engine.load(url); + + stage.setTitle(APP_TITLE); + stage.setWidth(1200); + stage.setHeight(800); + stage.setScene(new Scene(webView)); + stage.show(); + + log("Окно отображено"); + + stage.setOnCloseRequest(e -> { + log("Закрытие..."); + stopServer(); + }); + + } catch (Exception e) { + log("Ошибка: " + e.getMessage()); + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private void startServer() throws Exception { + server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0); + + server.createContext("/api/login", this::handleLogin); + server.createContext("/api/account", this::handleAccount); + server.createContext("/api/instances", this::handleInstances); + server.createContext("/api/launch", this::handleLaunch); + server.createContext("/api/install", this::handleInstall); + server.createContext("/api/logs", this::handleLogs); + server.createContext("/api/logs/instance", this::handleInstanceLogs); + server.createContext("/api/exit", this::handleExit); + server.createContext("/ui/", this::handleStatic); + + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + + log("HTTP сервер на порту " + PORT); + } + + private void stopServer() { + if (server != null) server.stop(0); + } + + private void handleLogin(HttpExchange exchange) { + try { + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, Map.of("success", false, "error", "Метод не поддерживается")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String username = body.get("username"); + String password = body.get("password"); + + var result = api.login(username, password); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("username", result.getData().getUsername()); + data.put("token", result.getData().getToken()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Вход: " + username); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleAccount(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Не авторизован")); + return; + } + Map data = new HashMap<>(); + data.put("username", api.getCurrentUsername()); + data.put("passActive", AuthManager.hasActivePass()); + sendJson(exchange, Map.of("success", true, "data", data)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleInstances(HttpExchange exchange) { + try { + var result = api.getAllInstances(); + Map response = new HashMap<>(); + response.put("success", result.isSuccess()); + if (result.isSuccess()) { + response.put("data", result.getData()); + } else { + response.put("error", result.getError()); + } + sendJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLaunch(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Не авторизован")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + + var result = api.launch(name); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("pid", result.getData().getPid()); + data.put("status", result.getData().getStatus()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Запущено: " + name); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleInstall(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Не авторизован")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + String version = body.get("version"); + String loader = body.get("loader"); + + log("Установка: " + name + " " + version + " " + loader); + + var createResult = api.instances().createInstance(name); + if (!createResult.isSuccess()) { + sendJson(exchange, Map.of("success", false, "error", createResult.getError())); + return; + } + + Instance instance = InstanceManager.getInstance(name); + if (instance != null) { + instance.setMinecraftVersion(version); + instance.setLoaderType(loader); + } + + sendJson(exchange, Map.of("success", true, "data", true)); + log("Установлено: " + name); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLogs(HttpExchange exchange) { + sendJson(exchange, Map.of("success", true, "data", logBuffer.toString())); + } + + private void handleInstanceLogs(HttpExchange exchange) { + try { + String query = exchange.getRequestURI().getQuery(); + String instanceName = null; + if (query != null && query.startsWith("name=")) { + instanceName = query.substring(5); + } + + if (instanceName == null) { + sendJson(exchange, Map.of("success", false, "error", "Укажите имя сборки")); + return; + } + + Path instanceDir = me.sashegdev.zernmc.launcher.utils.Config.getInstancesDir().resolve(instanceName); + Path logsDir = instanceDir.resolve("logs"); + + if (!Files.exists(logsDir)) { + sendJson(exchange, Map.of("success", true, "data", "")); + return; + } + + StringBuilder logs = new StringBuilder(); + try (var stream = Files.list(logsDir)) { + stream.filter(f -> f.toString().endsWith(".log")) + .sorted((a, b) -> b.compareTo(a)) + .limit(5) + .forEach(logFile -> { + try { + logs.append("=== ").append(logFile.getFileName()).append(" ===\n"); + logs.append(Files.readString(logFile)); + logs.append("\n"); + } catch (Exception ignored) {} + }); + } + + sendJson(exchange, Map.of("success", true, "data", logs.toString())); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleExit(HttpExchange exchange) { + log("Выход..."); + if (mainStage != null) mainStage.close(); + System.exit(0); + } + + private void handleStatic(HttpExchange exchange) { + try { + String path = exchange.getRequestURI().getPath(); + if (path.equals("/ui/") || path.equals("/ui")) path = "/ui/index.html"; + + var resource = JFXLauncher.class.getResource(path); + if (resource == null) { + exchange.sendResponseHeaders(404, 0); + exchange.close(); + return; + } + + byte[] content = resource.openStream().readAllBytes(); + String ct = getContentType(path); + + exchange.getResponseHeaders().set("Content-Type", ct); + exchange.sendResponseHeaders(200, content.length); + exchange.getResponseBody().write(content); + exchange.close(); + } catch (Exception ignored) {} + } + + private String getContentType(String path) { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + return "text/plain"; + } + + @SuppressWarnings("unchecked") + private Map parseJson(InputStream body) { + try { + return gson.fromJson(new String(body.readAllBytes(), StandardCharsets.UTF_8), Map.class); + } catch (Exception e) { + return new HashMap<>(); + } + } + + private void sendJson(HttpExchange exchange, Map response) { + try { + String json = gson.toJson(response); + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } catch (Exception ignored) {} + } + + private void log(String msg) { + String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n"; + logBuffer.append(entry); + System.out.println("[JFX] " + msg); + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java new file mode 100644 index 0000000..c22793e --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java @@ -0,0 +1,137 @@ +package me.sashegdev.zernmc.launcher.utils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +public class Config { + + private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc"); + private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties"); + + private static final String BUILD_PROFILE = System.getProperty("build.profile", "global"); + + private static final Properties props = new Properties(); + + // Настройки + private static int maxMemory = 4096; // будет перезаписано умной логикой + private static String serverUrl = "http://87.120.187.36:1582"; + private static String lastUsername = "Player"; + + static { + load(); + applySmartRamRecommendation(); + } + + private static void load() { + try { + Files.createDirectories(CONFIG_DIR); + if (Files.exists(CONFIG_FILE)) { + try (var is = Files.newInputStream(CONFIG_FILE)) { + props.load(is); + } + } + + maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096")); + serverUrl = props.getProperty("serverUrl", serverUrl); + lastUsername = props.getProperty("lastUsername", lastUsername); + + } catch (Exception e) { + System.err.println(ZAnsi.brightRed("Не удалось загрузить конфиг: ") + e.getMessage()); + } + } + + public static void save() { + try { + props.setProperty("maxMemory", String.valueOf(maxMemory)); + props.setProperty("serverUrl", serverUrl); + props.setProperty("lastUsername", lastUsername); + + try (var os = Files.newOutputStream(CONFIG_FILE)) { + props.store(os, "ZernMC Launcher Configuration"); + } + } catch (IOException e) { + System.err.println(ZAnsi.brightRed("Не удалось сохранить конфиг: ") + e.getMessage()); + } + } + + /** + * Умная рекомендация RAM: + * - минимум 1.5 GB + * - рекомендуется totalRAM - 30% + * - максимум 70% от доступной RAM + */ + private static void applySmartRamRecommendation() { + long totalRamMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); // в MB + + // Рекомендуемое значение = total - 30% + long recommended = (long) (totalRamMB * 0.70); // 70% от доступной + + // Ограничения + recommended = Math.max(1536, recommended); // минимум 1.5 GB + recommended = Math.min(recommended, totalRamMB - 1024); // оставляем минимум 1 GB системе + + // Если текущее значение сильно отличается от рекомендуемого — корректируем + if (Math.abs(maxMemory - recommended) > 1024) { // разница больше 1 GB + maxMemory = (int) recommended; + save(); // сохраняем умную рекомендацию + System.out.println(ZAnsi.cyan("Автоматически рекомендовано RAM: " + maxMemory + " MB")); + } + } + + // Getters & Setters + public static int getMaxMemory() { + return maxMemory; + } + + public static boolean isZernMCBuild() { + return "zernmc".equalsIgnoreCase(BUILD_PROFILE); + } + + public static boolean isGlobalBuild() { + return !isZernMCBuild(); + } + + public static void setMaxMemory(int memory) { + // Защита от слишком маленьких/больших значений + if (memory < 1024) memory = 1536; + if (memory > 32768) memory = 32768; + + maxMemory = memory; + save(); + } + + public static String getServerUrl() { + return serverUrl; + } + + public static String getLastUsername() { + return lastUsername; + } + + public static void setLastUsername(String username) { + lastUsername = username; + save(); + } + + public static Path getInstancesDir() { + return CONFIG_DIR.resolve("instances"); + } + + public static Path getJreDir() { + return CONFIG_DIR.resolve("jre"); + } + + public static Path getConfigDir() { + return CONFIG_DIR; + } + + /** + * Полезная информация для пользователя + */ + public static String getRamInfo() { + long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); + return "Доступно RAM: " + totalMB + " MB | Рекомендуется: " + maxMemory + " MB"; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ConsoleUtils.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ConsoleUtils.java new file mode 100644 index 0000000..bde04d3 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ConsoleUtils.java @@ -0,0 +1,39 @@ +package me.sashegdev.zernmc.launcher.utils; + +import java.io.IOException; + +public class ConsoleUtils { + + public static void clearScreen() { + System.out.print("\033[H\033[2J"); + System.out.flush(); + } + + public static void pause() { + System.out.print(ZAnsi.white("\nНажмите Enter для продолжения...")); + try { + System.in.read(); + // Очищаем буфер ввода + while (System.in.available() > 0) { + System.in.read(); + } + } catch (IOException ignored) {} + } + + public static void printHeader(String subtitle) { + clearScreen(); + System.out.println(ZAnsi.header("=== ZernMC Launcher ===")); + if (subtitle != null && !subtitle.isEmpty()) { + System.out.println(ZAnsi.yellow(subtitle)); + } + System.out.println(); + } + + public static void printHeader() { + printHeader(null); + } + + public static void separator() { + System.out.println(ZAnsi.white("────────────────────────────────────────────────────────────")); + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java new file mode 100644 index 0000000..988505e --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java @@ -0,0 +1,97 @@ +package me.sashegdev.zernmc.launcher.utils; + +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; + +import java.io.IOException; +import java.util.List; +import java.util.Scanner; + +/** + * Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu + */ +public class Input { + + // Используем UTF-8 явно — это помогает на Windows + private static final Scanner scanner = new Scanner(System.in, "UTF-8"); + + public static String readLine() { + return scanner.nextLine().trim(); + } + + public static String readLine(String prompt) { + flushInput(); // Очищаем буфер + System.out.print(prompt); + return scanner.nextLine().trim(); + } + + public static int readInt(String prompt) { + while (true) { + try { + System.out.print(prompt); + return Integer.parseInt(scanner.nextLine().trim()); + } catch (NumberFormatException e) { + System.out.println(ZAnsi.brightRed("Некорректное число. Попробуйте ещё раз.")); + } + } + } + + public static int readInt(String prompt, int min, int max) { + while (true) { + int value = readInt(prompt); + if (value >= min && value <= max) { + return value; + } + System.out.println(ZAnsi.brightRed("Значение должно быть от " + min + " до " + max + ".")); + } + } + + /** + * Новый confirm через ArrowMenu + * @throws IOException + */ + public static boolean confirm(String question) throws IOException { + ConsoleUtils.clearScreen(); // опционально, можно убрать + + List options = List.of( + "Да", + "Нет" + ); + + ArrowMenu menu = new ArrowMenu(question, options); + int choice = menu.show(); + + return choice == 0; // 0 = "Да" + } + + /** + * Альтернативный confirm без очистки экрана + * @throws IOException + */ + public static boolean confirmInline(String question) throws IOException { + List options = List.of("Да", "Нет"); + ArrowMenu menu = new ArrowMenu(question, options); + int choice = menu.show(); + return choice == 0; + } + + /** + * Закрытие сканнера (вызывать при выходе из программы, если нужно) + */ + public static void close() { + scanner.close(); + } + + + /** + * Очищает буфер ввода от оставшихся символов + */ + public static void flushInput() { + try { + while (System.in.available() > 0) { + System.in.read(); + } + } catch (IOException e) { + // Игнорируем + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ProgressBar.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ProgressBar.java new file mode 100644 index 0000000..d85bb2d --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ProgressBar.java @@ -0,0 +1,81 @@ +package me.sashegdev.zernmc.launcher.utils; + +import java.text.DecimalFormat; + +public class ProgressBar { + + private static final int BAR_LENGTH = 40; + private static final DecimalFormat DF = new DecimalFormat("#.##"); + + /** + * Прогресс по количеству файлов (для библиотек и общего прогресса) + */ + public static void show(String label, long current, long total, String unit) { + if (total <= 0) { + System.out.print("\r" + ZAnsi.cyan(label) + " ..."); + return; + } + double progress = (double) current / total; + int filled = (int) (progress * BAR_LENGTH); + String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled); + int percent = (int) (progress * 100); + + String text = String.format("%s [%s] %3d%% (%d/%d %s)", + ZAnsi.cyan(label), bar, percent, current, total, unit); + + System.out.print("\r" + text); + System.out.flush(); + } + + /** + * Прогресс по байтам для одного файла (реальный прогресс) + */ + public static void showDownload(String label, long downloaded, long totalBytes) { + if (totalBytes <= 0) { + System.out.print("\r" + ZAnsi.cyan(label) + " ..."); + return; + } + + double progress = (double) downloaded / totalBytes; + int filled = (int) (progress * BAR_LENGTH); + String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled); + String percent = DF.format(progress * 100); + + String text = String.format("%s [%s] %6s%% %s / %s", + ZAnsi.cyan(label), + bar, + percent, + formatBytes(downloaded), + formatBytes(totalBytes)); + + System.out.print("\r" + text); + System.out.flush(); + } + + public static void showAnimated(String label, long current, long total, String unit) { + if (total <= 0) { + // Анимация для неизвестного размера + char[] spinner = {'|', '/', '-', '\\'}; + int idx = (int) (current / 1024) % 4; + System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current)); + } else { + show(label, (int) ((current * 100) / total), 100, unit); + } + } + + public static void finish(String message) { + System.out.println("\r" + ZAnsi.brightGreen(message + " завершено ✓")); + System.out.flush(); + } + + public static void clearLine() { + System.out.print("\r" + " ".repeat(110) + "\r"); + System.out.flush(); + } + + public static String formatBytes(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB"; + return DF.format(bytes / (1024.0 * 1024)) + " MB"; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Version.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Version.java new file mode 100644 index 0000000..e72abab --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Version.java @@ -0,0 +1,56 @@ +package me.sashegdev.zernmc.launcher.utils; + +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +public class Version { + + public static String getCurrentVersion() { + try { + // Способ 1: Из манифеста (самый правильный) + Manifest manifest = new Manifest( + Version.class.getClassLoader().getResourceAsStream("META-INF/MANIFEST.MF") + ); + + String version = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION); + if (version != null && !version.isBlank()) { + return version; + } + + // Способ 2: Из Package (запасной) + version = Version.class.getPackage().getImplementationVersion(); + if (version != null && !version.isBlank()) { + return version; + } + + } catch (Exception ignored) { + // если не получилось прочитать манифест — идём дальше + } + + // Финальный fallback + return "1.0.0"; + } + + public static boolean isNewer(String current, String server) { + if (current == null || server == null) return false; + + current = current.replace("-SNAPSHOT", "").trim(); + server = server.replace("-SNAPSHOT", "").trim(); + + if (current.equals(server)) return false; + + String[] cParts = current.split("\\."); + String[] sParts = server.split("\\."); + + int max = Math.max(cParts.length, sParts.length); + + for (int i = 0; i < max; i++) { + int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0; + int s = i < sParts.length ? Integer.parseInt(sParts[i]) : 0; + + if (s > c) return true; + if (s < c) return false; + } + return false; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java new file mode 100644 index 0000000..6575ca2 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java @@ -0,0 +1,182 @@ +package me.sashegdev.zernmc.launcher.utils; + +import org.fusesource.jansi.Ansi; +import org.fusesource.jansi.AnsiConsole; + +public class ZAnsi { + + //поддержка ANSI епта + public static void install() { + AnsiConsole.systemInstall(); + } + + public static void uninstall() { + AnsiConsole.systemUninstall(); + } + + // === Основные цвета === + public static String green(String text) { + return Ansi.ansi().fg(Ansi.Color.GREEN).a(text).reset().toString(); + } + + public static String brightGreen(String text) { + return Ansi.ansi().fgBright(Ansi.Color.GREEN).a(text).reset().toString(); + } + + public static String cyan(String text) { + return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString(); + } + + public static String brightCyan(String text) { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).a(text).reset().toString(); + } + + public static String yellow(String text) { + return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString(); + } + + public static String brightYellow(String text) { + return Ansi.ansi().fgBright(Ansi.Color.YELLOW).a(text).reset().toString(); + } + + public static String red(String text) { + return Ansi.ansi().fg(Ansi.Color.RED).a(text).reset().toString(); + } + + public static String brightRed(String text) { + return Ansi.ansi().fgBright(Ansi.Color.RED).a(text).reset().toString(); + } + + public static String blue(String text) { + return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString(); + } + + public static String brightBlue(String text) { + return Ansi.ansi().fgBright(Ansi.Color.BLUE).a(text).reset().toString(); + } + + public static String magenta(String text) { + return Ansi.ansi().fg(Ansi.Color.MAGENTA).a(text).reset().toString(); + } + + public static String brightMagenta(String text) { + return Ansi.ansi().fgBright(Ansi.Color.MAGENTA).a(text).reset().toString(); + } + + // Пурпурный как brightPurple (используем magenta) + public static String purple(String text) { + return brightMagenta(text); + } + + public static String brightPurple(String text) { + return brightMagenta(text); + } + + public static String white(String text) { + return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString(); + } + + public static String brightWhite(String text) { + return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString(); + } + + public static String black(String text) { + return Ansi.ansi().fg(Ansi.Color.BLACK).a(text).reset().toString(); + } + + // === Фоновые цвета === + public static String bgGreen(String text) { + return Ansi.ansi().bg(Ansi.Color.GREEN).a(text).reset().toString(); + } + + public static String bgRed(String text) { + return Ansi.ansi().bg(Ansi.Color.RED).a(text).reset().toString(); + } + + public static String bgYellow(String text) { + return Ansi.ansi().bg(Ansi.Color.YELLOW).a(text).reset().toString(); + } + + public static String bgBlue(String text) { + return Ansi.ansi().bg(Ansi.Color.BLUE).a(text).reset().toString(); + } + + // === Стили === + public static String bold(String text) { + return Ansi.ansi().bold().a(text).reset().toString(); + } + + public static String reset() { + return Ansi.ansi().reset().toString(); + } + + // === Комбинированные удобные методы === + public static String header(String text) { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString(); + } + + public static String success(String text) { + return Ansi.ansi().fgBright(Ansi.Color.GREEN).bold().a("[✓] " + text).reset().toString(); + } + + public static String error(String text) { + return Ansi.ansi().fgBright(Ansi.Color.RED).bold().a("[✗] " + text).reset().toString(); + } + + public static String warning(String text) { + return Ansi.ansi().fgBright(Ansi.Color.YELLOW).bold().a("[!] " + text).reset().toString(); + } + + public static String info(String text) { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a("[i] " + text).reset().toString(); + } + + public static String selected(String text) { + return Ansi.ansi() + .bgBright(Ansi.Color.WHITE) + .fg(Ansi.Color.BLACK) + .bold() + .a(" > " + text + " ") + .reset() + .toString(); + } + + public static String dim(String text) { + return Ansi.ansi().fgBright(Ansi.Color.BLACK).a(text).reset().toString(); + } + + // === Цветной текст для ролей === + public static String roleUser(String text) { + return white(text); + } + + public static String rolePassHolder(String text) { + return brightGreen(text); + } + + public static String roleModerator(String text) { + return brightBlue(text); + } + + public static String roleElder(String text) { + return brightPurple(text); + } + + public static String roleCreator(String text) { + return brightRed(text); + } + + // === Очистка экрана === + public static String clearScreen() { + return Ansi.ansi().eraseScreen().cursor(1, 1).toString(); + } + + // === Прогресс бар символы === + public static String progressChar() { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).a("█").reset().toString(); + } + + public static String progressEmpty() { + return Ansi.ansi().fg(Ansi.Color.BLACK).a("░").reset().toString(); + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java new file mode 100644 index 0000000..bf4e848 --- /dev/null +++ b/launcher/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -0,0 +1,550 @@ +package me.sashegdev.zernmc.launcher.utils; + +import org.json.JSONArray; +import org.json.JSONObject; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +public class ZHttpClient { + + private static final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + + private static String BASE_URL = "http://87.120.187.36:1582"; + + // Глобальный прокси режим (для обратной совместимости) + private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); + private static final AtomicBoolean proxyTested = new AtomicBoolean(false); + + /** + * Переопределить URL сервера (для тестов). + * Внимание: не потокобезопасно, использовать только в тестах. + */ + public static void setBaseUrl(String url) { + BASE_URL = url; + } + + public static String getBaseUrl() { + return BASE_URL; + } + + // Умное проксирование по сервисам + public enum ServiceType { + ZERN_SERVER("http://87.120.187.36:1582", true), + FABRIC_META("https://meta.fabricmc.net", false), + FABRIC_MAVEN("https://maven.fabricmc.net", false), + MOJANG_META("https://piston-meta.mojang.com", false), + MOJANG_RESOURCES("https://resources.download.minecraft.net", false), + FORGE_MAVEN("https://maven.minecraftforge.net", false), + NEOFORGE_MAVEN("https://maven.neoforged.net", false), + GOOGLE("https://google.com", false), + CLOUDFLARE("https://cloudflare.com", false); + + private final String baseUrl; + private final boolean alwaysDirect; + + ServiceType(String baseUrl, boolean alwaysDirect) { + this.baseUrl = baseUrl; + this.alwaysDirect = alwaysDirect; + } + + public String getBaseUrl() { return baseUrl; } + public boolean isAlwaysDirect() { return alwaysDirect; } + } + + // Статусы сервисов + private static final Map serviceProxyMode = new ConcurrentHashMap<>(); + private static final Map serviceFailCount = new ConcurrentHashMap<>(); + private static final Map serviceLastCheckTime = new ConcurrentHashMap<>(); + private static final Map serviceHealthy = new ConcurrentHashMap<>(); + + private static final int MAX_FAILS_BEFORE_PROXY = 2; + private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута + private static final long CHECK_TIMEOUT_MS = 7000; // 7 секунд на проверку + + // Статистика + private static int directSuccessCount = 0; + private static int proxySuccessCount = 0; + private static int directFailCount = 0; + + static { + for (ServiceType type : ServiceType.values()) { + serviceProxyMode.put(type, false); + serviceFailCount.put(type, 0); + serviceHealthy.put(type, false); + } + } + + /** + * Вызывать один раз при запуске лаунчера + */ + public static void checkAllServicesOnStartup() { + if (proxyTested.get()) return; + + System.out.println(ZAnsi.cyan("Проверка доступности сервисов...")); + + List servicesToCheck = List.of( + ServiceType.ZERN_SERVER, + ServiceType.GOOGLE, + ServiceType.FABRIC_META, + ServiceType.FABRIC_MAVEN, + ServiceType.MOJANG_META, + ServiceType.MOJANG_RESOURCES, + ServiceType.FORGE_MAVEN, + ServiceType.NEOFORGE_MAVEN + ); + + for (ServiceType service : servicesToCheck) { + boolean isHealthy = checkServiceHealth(service); + serviceHealthy.put(service, isHealthy); + + if (service.isAlwaysDirect()) { + System.out.println(isHealthy ? + ZAnsi.green(" " + service.name() + " - OK") : + ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); + } else { + if (isHealthy) { + System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает")); + } else { + System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси")); + serviceProxyMode.put(service, true); + serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); + } + } + } + + if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { + System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!")); + } + + proxyTested.set(true); + startHealthCheckThread(); + printStats(); + } + + /** + * Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки) + */ + public static void forceCheckMojangServices() { + System.out.println(ZAnsi.cyan("Принудительная проверка Mojang сервисов...")); + + for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) { + boolean healthy = checkServiceHealth(service); + serviceHealthy.put(service, healthy); + + if (healthy) { + System.out.println(ZAnsi.green(" " + service.name() + " доступен напрямую")); + serviceProxyMode.put(service, false); + serviceFailCount.put(service, 0); + } else { + System.out.println(ZAnsi.yellow(" " + service.name() + " недоступен → прокси режим активирован")); + serviceProxyMode.put(service, true); + serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); + } + } + } + + private static boolean checkServiceHealth(ServiceType service) { + return checkDirectConnection(service.getBaseUrl()); + } + + /** + * Улучшенная проверка прямого подключения + */ + private static boolean checkDirectConnection(String baseUrl) { + String testUrl = baseUrl; + + if (baseUrl.contains("piston-meta.mojang.com") || baseUrl.contains("launchermeta.mojang.com")) { + testUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + } else if (baseUrl.contains("resources.download.minecraft.net")) { + testUrl = "https://resources.download.minecraft.net/00/0000000000000000000000000000000000000000"; + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(testUrl)) + .timeout(Duration.ofMillis(CHECK_TIMEOUT_MS)) + .GET() + .header("User-Agent", "ZernMC-Launcher/HealthCheck") + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + int code = response.statusCode(); + return code == 200 || code == 404; // 404 для ресурсов — нормально + } catch (Exception e) { + return false; + } + } + + private static void startHealthCheckThread() { + Thread healthThread = new Thread(() -> { + while (true) { + try { + Thread.sleep(HEALTH_CHECK_INTERVAL_MS); + performHealthCheck(); + } catch (InterruptedException e) { + break; + } + } + }); + healthThread.setDaemon(true); + healthThread.start(); + } + + private static void performHealthCheck() { + for (ServiceType service : ServiceType.values()) { + if (service.isAlwaysDirect()) continue; + + boolean isHealthy = checkServiceHealth(service); + serviceHealthy.put(service, isHealthy); + + if (isHealthy && serviceProxyMode.get(service)) { + serviceProxyMode.put(service, false); + serviceFailCount.put(service, 0); + System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение")); + } else if (!isHealthy && !serviceProxyMode.get(service)) { + int fails = serviceFailCount.getOrDefault(service, 0) + 1; + serviceFailCount.put(service, fails); + serviceLastCheckTime.put(service, System.currentTimeMillis()); + + if (fails >= MAX_FAILS_BEFORE_PROXY) { + serviceProxyMode.put(service, true); + System.out.println(ZAnsi.yellow("[NET] " + service.name() + " недоступен, включен прокси режим")); + } + } + } + } + + private static ServiceType detectService(String url) { + if (url.contains("meta.fabricmc.net")) return ServiceType.FABRIC_META; + if (url.contains("maven.fabricmc.net")) return ServiceType.FABRIC_MAVEN; + if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com")) + return ServiceType.MOJANG_META; + if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES; + if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN; + if (url.contains("maven.neoforged.net")) return ServiceType.NEOFORGE_MAVEN; + if (url.contains("google.com")) return ServiceType.GOOGLE; + if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE; + return null; + } + + private static boolean shouldUseProxyForUrl(String url) { + if (useProxyMode.get()) return true; + + ServiceType service = detectService(url); + if (service == null || service.isAlwaysDirect()) return false; + + return serviceProxyMode.getOrDefault(service, false); + } + + private static boolean isConnectionError(Throwable e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + String msg = cause.getMessage() != null ? cause.getMessage().toLowerCase() : ""; + + return cause instanceof java.net.ConnectException || + cause instanceof java.net.UnknownHostException || + cause instanceof java.nio.channels.ClosedChannelException || + msg.contains("connection") || + msg.contains("timeout") || + msg.contains("refused") || + msg.contains("closed"); + } + + private static void markServiceAsBlocked(String url) { + ServiceType service = detectService(url); + if (service == null || service.isAlwaysDirect()) return; + + int fails = serviceFailCount.getOrDefault(service, 0) + 1; + serviceFailCount.put(service, fails); + serviceLastCheckTime.put(service, System.currentTimeMillis()); + + if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) { + serviceProxyMode.put(service, true); + System.out.println(ZAnsi.yellow("[NET] " + service.name() + " заблокирован, переключаемся на прокси")); + } + } + /** + * Универсальный GET с умным прокси + автоматическим fallback + */ + public static String getWithSmartProxy(String url) throws IOException, InterruptedException { + // Попытка прямого подключения + if (!shouldUseProxyForUrl(url)) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(25)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + directSuccessCount++; + return response.body(); + } + + if (response.statusCode() >= 400) { + throw new IOException("HTTP " + response.statusCode()); + } + } catch (Exception e) { + if (isConnectionError(e)) { + directFailCount++; + markServiceAsBlocked(url); + } + // Если ошибка соединения — пробуем через прокси + } + } + + // Через прокси + try { + String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); + String proxyUrl = BASE_URL + "/download?url=" + encodedUrl; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(proxyUrl)) + .timeout(Duration.ofSeconds(40)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("Proxy HTTP " + response.statusCode()); + } + + proxySuccessCount++; + return response.body(); + + } catch (Exception e) { + throw new IOException("Не удалось получить данные ни напрямую, ни через прокси: " + e.getMessage(), e); + } + } + + /** + * Скачивание файла с умным прокси + fallback + */ + public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { + if (!shouldUseProxyForUrl(url)) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(40)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); + + if (response.statusCode() == 200) { + directSuccessCount++; + return; + } + } catch (Exception e) { + if (isConnectionError(e)) { + directFailCount++; + markServiceAsBlocked(url); + } + // fallback на прокси ниже + } + } + + // Скачивание через прокси + String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); + String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(proxyUrl)) + .timeout(Duration.ofMinutes(5)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); + + if (response.statusCode() != 200) { + throw new IOException("Proxy download failed: HTTP " + response.statusCode()); + } + + proxySuccessCount++; + } + + // ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ====================== + + public static String get(String endpoint) throws IOException, InterruptedException { + checkAllServicesOnStartup(); + + if (useProxyMode.get()) { + return proxyGet(endpoint); + } + + try { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + endpoint)) + .timeout(Duration.ofSeconds(15)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET(); + + // ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ ===== + String accessToken = AuthManager.getAccessToken(); + if (accessToken != null && !accessToken.equals("0")) { + requestBuilder.header("Authorization", "Bearer " + accessToken); + } + + HttpRequest request = requestBuilder.build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + return response.body(); + } catch (Exception e) { + directFailCount++; + throw e; + } + } + + private static String proxyGet(String endpoint) throws IOException { + try { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/proxy" + endpoint)) + .timeout(Duration.ofSeconds(30)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET(); + + // ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ ===== + String accessToken = AuthManager.getAccessToken(); + if (accessToken != null && !accessToken.equals("0")) { + requestBuilder.header("Authorization", "Bearer " + accessToken); + } + + HttpRequest request = requestBuilder.build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + proxySuccessCount++; + return response.body(); + } catch (Exception e) { + throw new IOException("Ошибка прокси: " + e.getMessage(), e); + } + } + + // ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ====================== + + public static List getFabricLoaderVersions() throws IOException, InterruptedException { + String url = "https://meta.fabricmc.net/v2/versions/loader"; + return parseFabricVersionsFromJson(getWithSmartProxy(url)); + } + + public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException { + String url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + String response = getWithSmartProxy(url); + return new JSONObject(response); + } + + public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException { + JSONObject manifest = getMojangVersionManifest(); + JSONArray versions = manifest.getJSONArray("versions"); + + for (int i = 0; i < versions.length(); i++) { + JSONObject v = versions.getJSONObject(i); + if (v.getString("id").equals(versionId)) { + return new JSONObject(getWithSmartProxy(v.getString("url"))); + } + } + throw new IOException("Version " + versionId + " not found"); + } + + public static String getForgeVersionsXml() throws IOException, InterruptedException { + String url = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"; + return getWithSmartProxy(url); + } + + public static void downloadFile(String url, Path target) throws Exception { + downloadFileWithSmartProxy(url, target); + } + + public static void downloadAsset(String hash, Path target) throws Exception { + String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; + downloadFileWithSmartProxy(url, target); + } + + public static String downloadString(String url) throws IOException, InterruptedException { + return getWithSmartProxy(url); + } + + private static List parseFabricVersionsFromJson(String json) { + JSONArray array = new JSONArray(json); + List versions = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + if (obj.has("version")) { + versions.add(obj.getString("version")); + } + } + return versions; + } + + // ====================== ВСПОМОГАТЕЛЬНЫЕ ====================== + + public static String getLauncherVersionInfo() throws IOException, InterruptedException { + return get("/launcher/version"); + } + + public static void forceProxyMode() { + useProxyMode.set(true); + System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим")); + } + + public static void disableProxyMode() { + useProxyMode.set(false); + for (ServiceType type : ServiceType.values()) { + if (!type.isAlwaysDirect()) { + serviceProxyMode.put(type, false); + serviceFailCount.put(type, 0); + } + } + System.out.println(ZAnsi.green("Режим прокси выключен")); + } + + public static boolean isProxyMode() { + return useProxyMode.get(); + } + + public static void printStats() { + System.out.println(ZAnsi.cyan("\n=== Статистика сети ===")); + System.out.println(ZAnsi.white("Глобальный прокси: ") + (useProxyMode.get() ? "ВКЛ" : "ВЫКЛ")); + System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount); + System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount); + System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount); + + System.out.println(ZAnsi.cyan("\nСтатус сервисов:")); + for (ServiceType type : ServiceType.values()) { + if (type.isAlwaysDirect()) continue; + String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО"); + String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]"); + System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health); + } + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/resources/ui/index.html b/launcher/launcher/src/main/resources/ui/index.html new file mode 100644 index 0000000..c1fa627 --- /dev/null +++ b/launcher/launcher/src/main/resources/ui/index.html @@ -0,0 +1,102 @@ + + + + + + ZernMC Launcher + + + + + + +
+ +
+ +
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/launcher/launcher/src/main/resources/ui/launcher.js b/launcher/launcher/src/main/resources/ui/launcher.js new file mode 100644 index 0000000..68ce339 --- /dev/null +++ b/launcher/launcher/src/main/resources/ui/launcher.js @@ -0,0 +1,393 @@ +const API_BASE = 'http://localhost:8080/api'; + +let state = { + loggedIn: false, + account: null, + instances: [], + selectedInstance: null +}; + +// ============ API ============ + +async function apiCall(endpoint, options = {}) { + const url = `${API_BASE}${endpoint}`; + const config = { + headers: { 'Content-Type': 'application/json' }, + ...options + }; + + try { + const response = await fetch(url, config); + const data = await response.json(); + return data; + } catch (e) { + log('Ошибка соединения с сервером: ' + e.message, 'error'); + return { success: false, error: e.message }; + } +} + +// ============ Login ============ + +async function login(username, password) { + log('Выполняется вход...', 'info'); + const result = await apiCall('/login', { + method: 'POST', + body: JSON.stringify({ username, password }) + }); + + if (result.success) { + state.loggedIn = true; + state.account = result.data; + log('Вход выполнен: ' + result.data.username, 'success'); + showMainScreen(); + await loadInstances(); + } else { + log('Ошибка входа: ' + result.error, 'error'); + showError(result.error); + } + return result; +} + +function showError(message) { + const el = document.getElementById('login-error'); + el.textContent = message; + el.classList.remove('hidden'); +} + +function hideError() { + document.getElementById('login-error').classList.add('hidden'); +} + +// ============ Account ============ + +async function loadAccountInfo() { + const result = await apiCall('/account'); + if (result.success) { + state.account = result.data; + state.loggedIn = true; + document.getElementById('account-name').textContent = result.data.username; + + const statusEl = document.getElementById('account-status'); + statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE'; + statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive'); + } else { + showLoginScreen(); + } +} + +// ============ Instances ============ + +async function loadInstances() { + log('Загрузка списка сборок...', 'info'); + const result = await apiCall('/instances'); + + if (result.success) { + state.instances = result.data; + renderInstances(); + log('Загружено ' + result.data.length + ' сборок', 'success'); + } else { + log('Ошибка загрузки: ' + result.error, 'error'); + } +} + +function renderInstances() { + const container = document.getElementById('instances-list'); + container.innerHTML = ''; + + state.instances.forEach(inst => { + const card = document.createElement('div'); + card.className = 'instance-card'; + card.dataset.name = inst.name; + card.onclick = () => selectInstance(inst.name); + + let details = ` + ${inst.version || '?'} + ${inst.loaderType || 'vanilla'} + `; + + if (inst.isServerPack) { + details += `v${inst.serverVersion}`; + } + + card.innerHTML = ` +
${inst.name}
+
${details}
+ `; + + container.appendChild(card); + }); +} + +function selectInstance(name) { + state.selectedInstance = state.instances.find(i => i.name === name); + + document.querySelectorAll('.instance-card').forEach(c => { + c.classList.toggle('selected', c.dataset.name === name); + }); + + const btn = document.getElementById('play-btn'); + const inst = state.selectedInstance; + + if (inst) { + document.getElementById('selected-name').textContent = inst.name; + document.getElementById('selected-version').textContent = inst.version || '-'; + document.getElementById('selected-loader').textContent = inst.loaderType || 'vanilla'; + + btn.disabled = false; + btn.textContent = 'Играть'; + btn.classList.remove('update'); + + loadInstanceLogs(inst.name); + } else { + btn.disabled = true; + btn.textContent = 'Выберите сборку'; + } +} + +// ============ Launch ============ + +async function launchInstance() { + if (!state.selectedInstance) return; + + const name = state.selectedInstance.name; + log('Запуск сборки: ' + name, 'info'); + + const result = await apiCall('/launch', { + method: 'POST', + body: JSON.stringify({ name }) + }); + + if (result.success) { + log('Сборка запущена! PID: ' + result.data.pid, 'success'); + } else { + log('Ошибка запуска: ' + result.error, 'error'); + } +} + +// ============ Install ============ + +function openInstallModal() { + document.getElementById('install-modal').classList.remove('hidden'); +} + +function closeInstallModal() { + document.getElementById('install-modal').classList.add('hidden'); +} + +async function installInstance(formData) { + log('Установка сборки...', 'info'); + const result = await apiCall('/install', { + method: 'POST', + body: JSON.stringify(formData) + }); + + if (result.success) { + log('Сборка установлена!', 'success'); + closeInstallModal(); + await loadInstances(); + } else { + log('Ошибка установки: ' + result.error, 'error'); + } + return result; +} + +// ============ Logs ============ + +function log(message, type = 'info') { + const container = document.getElementById('logs-container'); + if (!container) return; + + const line = document.createElement('div'); + line.className = 'log-line ' + type; + line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message; + container.appendChild(line); + container.scrollTop = container.scrollHeight; +} + +async function loadInstanceLogs(instanceName) { + const result = await apiCall('/logs/instance?name=' + encodeURIComponent(instanceName)); + if (result.success && result.data) { + result.data.split('\n').forEach(line => { + if (line.trim()) { + let type = 'info'; + if (line.toLowerCase().includes('error')) type = 'error'; + else if (line.toLowerCase().includes('warn')) type = 'warning'; + else if (line.toLowerCase().includes('info')) type = 'success'; + log(line, type); + } + }); + } +} + +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 () => { + initGridBackground(); + log('Запуск лаунчера...', 'info'); + + await loadAccountInfo(); + + if (!state.loggedIn) { + showLoginScreen(); + } else { + showMainScreen(); + await loadInstances(); + } +}); + +// ============ Form Handlers ============ + +document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + hideError(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + await login(username, password); +}); + +document.getElementById('play-btn').addEventListener('click', async () => { + await launchInstance(); +}); + +document.getElementById('install-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const formData = { + name: document.getElementById('install-name').value, + version: document.getElementById('install-mc-version').value, + loader: document.getElementById('install-loader').value + }; + + await installInstance(formData); +}); + +// Expose functions globally for inline handlers +window.closeInstallModal = closeInstallModal; + +// ============ Grid Background ============ + +function initGridBackground() { + const canvas = document.getElementById('grid-canvas'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + let width, height; + let mouseX = 0, mouseY = 0; + let points = []; + const spacing = 40; + const depthLayers = 3; + + function resize() { + width = canvas.width = window.innerWidth; + height = canvas.height = window.innerHeight; + initPoints(); + } + + function initPoints() { + points = []; + for (let z = 0; z < depthLayers; z++) { + const layer = []; + const scale = 0.6 + z * 0.2; + const cols = Math.ceil(width / spacing / scale) + 1; + const rows = Math.ceil(height / spacing / scale) + 1; + + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + layer.push({ + x: x * spacing * scale, + y: y * spacing * scale, + baseX: x * spacing * scale, + baseY: y * spacing * scale, + depth: z + }); + } + } + points.push(layer); + } + } + + function draw() { + ctx.clearRect(0, 0, width, height); + + const centerX = width / 2; + const centerY = height / 2; + + for (let z = 0; z < depthLayers; z++) { + const layer = points[z]; + const alpha = 0.15 + z * 0.1; + const scale = 0.6 + z * 0.2; + + ctx.strokeStyle = z === depthLayers - 1 + ? `rgba(59, 130, 246, ${alpha})` // Blue - primary + : `rgba(245, 158, 11, ${alpha * 0.5})`; // Orange - secondary + + ctx.lineWidth = 1; + + for (const p of layer) { + const dx = (mouseX - centerX) * 0.02 * scale * (z + 1); + const dy = (mouseY - centerY) * 0.02 * scale * (z + 1); + + const px = p.baseX + dx; + const py = p.baseY + dy; + + // Horizontal line + if ((p.baseX + dx) > 0 && (p.baseX + dx) < width - spacing * scale) { + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(px + spacing * scale, py); + ctx.stroke(); + } + + // Vertical line + if ((p.baseY + dy) > 0 && (p.baseY + dy) < height - spacing * scale) { + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(px, py + spacing * scale); + ctx.stroke(); + } + } + } + + requestAnimationFrame(draw); + } + + canvas.addEventListener('mousemove', (e) => { + mouseX = e.clientX; + mouseY = e.clientY; + }); + + canvas.addEventListener('mouseleave', () => { + mouseX = width / 2; + mouseY = height / 2; + }); + + window.addEventListener('resize', resize); + resize(); + draw(); +} \ No newline at end of file diff --git a/launcher/launcher/src/main/resources/ui/style.css b/launcher/launcher/src/main/resources/ui/style.css new file mode 100644 index 0000000..1d38d20 --- /dev/null +++ b/launcher/launcher/src/main/resources/ui/style.css @@ -0,0 +1,447 @@ +:root { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --accent: #f59e0b; + --accent-secondary: #3b82f6; + --accent-hover: #fbbf24; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --success: #22c55e; + --warning: #f59e0b; + --error: #ef4444; + --border: #475569; + --shadow: rgba(0, 0, 0, 0.4); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + overflow: hidden; +} + +#app { + height: 100vh; + display: flex; + flex-direction: column; + position: relative; + z-index: 1; +} + +#grid-canvas { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; +} + +/* Screens */ +.screen { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} + +.hidden { + display: none !important; +} + +/* Login Screen */ +#login-screen { + justify-content: center; + align-items: center; + background: transparent; +} + +.login-container { + background: rgba(30, 41, 59, 0.95); + backdrop-filter: blur(10px); + padding: 3rem; + border-radius: 16px; + box-shadow: 0 25px 50px var(--shadow); + width: 100%; + max-width: 400px; + border: 1px solid var(--border); +} + +.logo { + font-size: 2.5rem; + text-align: center; + color: var(--accent); + margin-bottom: 0.5rem; +} + +.subtitle { + text-align: center; + color: var(--text-secondary); + margin-bottom: 2rem; +} + +#login-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +input, select { + background: var(--bg-primary); + border: 1px solid var(--border); + color: var(--text-primary); + padding: 0.875rem 1rem; + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.2s; +} + +input:focus, select:focus { + outline: none; + border-color: var(--accent); +} + +input::placeholder { + color: var(--text-secondary); +} + +.btn-primary { + background: var(--accent); + color: white; + border: none; + padding: 0.875rem 1rem; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-primary:disabled { + background: var(--text-secondary); + cursor: not-allowed; +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); + padding: 0.875rem 1rem; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + transition: background 0.2s; +} + +.btn-secondary:hover { + background: var(--bg-secondary); +} + +.error { + color: var(--error); + text-align: center; + margin-top: 1rem; + padding: 0.75rem; + border-radius: 8px; + background: rgba(239, 68, 68, 0.1); +} + +/* Main Screen */ +#main-screen { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header */ +.header { + background: var(--bg-secondary); + padding: 1rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.header .logo { + font-size: 1.5rem; + margin: 0; +} + +.account-info { + display: flex; + align-items: center; + gap: 1rem; +} + +#account-name { + font-weight: 600; +} + +.badge { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 500; +} + +.badge.active { + background: rgba(74, 222, 128, 0.2); + color: var(--success); +} + +.badge.inactive { + background: rgba(239, 68, 68, 0.2); + color: var(--error); +} + +/* Main Content */ +.main-content { + flex: 1; + display: grid; + grid-template-columns: 280px 1fr; + gap: 1px; + background: var(--border); + overflow: hidden; +} + +/* Sidebar */ +.sidebar { + background: var(--bg-secondary); + padding: 1rem; + overflow-y: auto; +} + +.sidebar h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 1rem; +} + +.instances-container { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.instance-card { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.instance-card:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +.instance-card.selected { + border-color: var(--accent); + background: rgba(233, 69, 96, 0.1); +} + +.instance-name { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.instance-details { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.instance-version, .instance-loader { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: var(--bg-tertiary); +} + +.instance-server-version { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: rgba(251, 191, 36, 0.2); + color: var(--warning); +} + +/* Logs Panel */ +.logs-panel { + background: var(--bg-primary); + padding: 1rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.logs-panel h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 1rem; +} + +#logs-container { + flex: 1; + background: #0d0d1a; + border-radius: 8px; + padding: 1rem; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.875rem; + overflow-y: auto; + line-height: 1.6; +} + +.log-line { + margin-bottom: 0.25rem; + white-space: pre-wrap; + word-break: break-all; +} + +.log-line.info { color: var(--text-primary); } +.log-line.success { color: var(--success); } +.log-line.warning { color: var(--warning); } +.log-line.error { color: var(--error); } + +/* Footer */ +.footer { + background: var(--bg-secondary); + padding: 1rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--border); +} + +.instance-info { + display: flex; + gap: 1rem; + align-items: center; +} + +.instance-info span { + padding: 0.5rem 1rem; + background: var(--bg-primary); + border-radius: 6px; + font-size: 0.875rem; +} + +#selected-name { + font-weight: 600; + color: var(--accent); +} + +.btn-play { + background: var(--success); + color: #0a0a0a; + border: none; + padding: 0.875rem 2rem; + border-radius: 8px; + font-size: 1.125rem; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.btn-play:hover:not(:disabled) { + transform: scale(1.05); + box-shadow: 0 0 20px rgba(74, 222, 128, 0.4); +} + +.btn-play:disabled { + background: var(--text-secondary); + cursor: not-allowed; +} + +.btn-play.update { + background: var(--warning); +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 100; +} + +.modal-content { + background: var(--bg-secondary); + padding: 2rem; + border-radius: 16px; + width: 100%; + max-width: 450px; + border: 1px solid var(--border); +} + +.modal-content h2 { + margin-bottom: 1.5rem; +} + +#install-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +#install-form label { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +#install-form select, #install-form input { + width: 100%; +} + +.modal-buttons { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border); +} \ No newline at end of file diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/VersionTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/VersionTest.java new file mode 100644 index 0000000..2ad04b0 --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/VersionTest.java @@ -0,0 +1,54 @@ +package me.sashegdev.zernmc.launcher; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class VersionTest { + + @Test + void testVersionParsing() { + assertEquals("1.0.8", parseVersion("{\"version\":\"1.0.8\"}")); + assertEquals("1.0.2", parseVersion("version:1.0.2")); + assertEquals("unknown", parseVersion("invalid")); + assertEquals("2.0.0", parseVersion("\"version\":\"2.0.0\"")); + } + + @Test + void testVersionComparison() { + assertTrue(isNewer("1.0.8", "1.0.7")); + assertTrue(isNewer("1.0.8", "1.0.2")); + assertTrue(isNewer("2.0.0", "1.0.8")); + assertFalse(isNewer("1.0.8", "1.0.8")); + assertFalse(isNewer("1.0.7", "1.0.8")); + assertTrue(isNewer("1.0.10", "1.0.9")); + } + + private String parseVersion(String line) { + if (line != null && line.contains("version")) { + int start = line.indexOf("\"version\":\""); + if (start >= 0) { + start += 11; + int end = line.indexOf("\"", start); + if (end > start) { + return line.substring(start, end); + } + } + } + return "unknown"; + } + + private boolean isNewer(String server, String current) { + try { + String[] sa = server.split("\\."); + String[] ca = current.split("\\."); + for (int i = 0; i < Math.min(sa.length, ca.length); i++) { + int sv = Integer.parseInt(sa[i]); + int cv = Integer.parseInt(ca[i]); + if (sv > cv) return true; + if (sv < cv) return false; + } + return sa.length > ca.length; + } catch (Exception ignored) {} + return false; + } +} \ No newline at end of file diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerParsingTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerParsingTest.java new file mode 100644 index 0000000..c585785 --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerParsingTest.java @@ -0,0 +1,90 @@ +package me.sashegdev.zernmc.launcher.auth; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for AuthManager error extraction and response parsing. + * Tests the contract between server error responses and Java client parsing. + */ +class AuthManagerParsingTest { + + @Test + void extractError_simpleStringDetail() { + // Server: raise HTTPException(401, "Неверное имя пользователя или пароль") + String body = "{\"detail\":\"Неверное имя пользователя или пароль\"}"; + String error = extractError(body); + assertEquals("Неверное имя пользователя или пароль", error); + } + + @Test + void extractError_validationErrorArray() { + // FastAPI 422: {"detail": [{"loc": ["body", "username"], "msg": "...", "type": "..."}]} + String body = "{" + + "\"detail\":[" + + "{\"loc\":[\"body\",\"username\"],\"msg\":\"String should have at least 3 characters\",\"type\":\"string_too_short\"}" + + "]" + + "}"; + String error = extractError(body); + assertEquals("String should have at least 3 characters", error); + } + + @Test + void extractError_multipleValidationErrors_returnsFirst() { + String body = "{" + + "\"detail\":[" + + "{\"loc\":[\"body\",\"username\"],\"msg\":\"Username error\",\"type\":\"value_error\"}," + + "{\"loc\":[\"body\",\"password\"],\"msg\":\"Password error\",\"type\":\"value_error\"}" + + "]" + + "}"; + String error = extractError(body); + assertEquals("Username error", error); + } + + @Test + void extractError_plainTextBody() { + // Non-JSON error body + String body = "Internal Server Error"; + String error = extractError(body); + assertEquals("Internal Server Error", error); + } + + @Test + void extractError_longBody_truncated() { + String longBody = "A".repeat(300); + String error = extractError(longBody); + assertEquals(203, error.length()); // 200 + "..." + assertTrue(error.endsWith("...")); + } + + @Test + void extractError_emptyDetail() { + String body = "{\"detail\":\"\"}"; + String error = extractError(body); + assertEquals("", error); + } + + @Test + void extractError_noDetailField_returnsBody() { + String body = "{\"error\":\"something went wrong\"}"; + String error = extractError(body); + assertEquals("{\"error\":\"something went wrong\"}", error); + } + + /** + * Replicates AuthManager.extractError() logic for testing. + * If this passes, the real method in AuthManager works correctly. + */ + private static String extractError(String body) { + try { + com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(body).getAsJsonObject(); + if (json.has("detail")) { + if (json.get("detail").isJsonArray()) { + return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString(); + } + return json.get("detail").getAsString(); + } + } catch (Exception ignored) {} + return body.length() > 200 ? body.substring(0, 200) + "..." : body; + } +} diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ServerIntegrationTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ServerIntegrationTest.java new file mode 100644 index 0000000..d5a0890 --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ServerIntegrationTest.java @@ -0,0 +1,469 @@ +package me.sashegdev.zernmc.launcher.integration; + +import org.junit.jupiter.api.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +/** + * Integration tests: real Java client ↔ real Python server. + * + * These tests: + * 1. Start the FastAPI test server via Python subprocess + * 2. Use actual Java HTTP client code to make requests + * 3. Verify JSON parsing and response handling + * + * Requires: Python 3, pytest, and the server/.venv to be available. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ServerIntegrationTest { + + private static Process serverProcess; + private static String serverBaseUrl; + private static Path testDir; + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + @BeforeAll + static void startTestServer() throws Exception { + // Create temp directory for test data + testDir = Files.createTempDirectory("zern_integration_test_"); + + // Find the server directory + String serverDir = findServerDir(); + if (serverDir == null) { + System.out.println("WARNING: Server directory not found, skipping integration tests"); + serverBaseUrl = null; + return; + } + + // Start the test server on a random port + int port = findFreePort(); + serverBaseUrl = "http://127.0.0.1:" + port; + + System.out.println("Starting test server on " + serverBaseUrl); + System.out.println("Server directory: " + serverDir); + + // Find Python executable (prefer venv python) + String pythonPath = findPythonPath(serverDir); + if (pythonPath == null) { + System.out.println("WARNING: Python not found, skipping integration tests"); + serverBaseUrl = null; + return; + } + + // Create a Python startup script that properly sets up paths + String startupScript = + "import sys, os, tempfile\n" + + "from pathlib import Path\n" + + "sys.path.insert(0, '" + serverDir + "')\n" + + "os.chdir('" + serverDir + "')\n" + + "import auth\n" + + "db_dir = tempfile.mkdtemp()\n" + + "auth.AUTH_DB = Path(db_dir) / 'auth.db'\n" + + "auth.SECRET_KEY = Path(db_dir) / '.secret_key'\n" + + "auth.init_db()\n" + + "import uvicorn\n" + + "import main\n" + + "uvicorn.run(main.app, host='127.0.0.1', port=" + port + ", log_level='error')\n"; + + ProcessBuilder pb = new ProcessBuilder(pythonPath, "-c", startupScript); + pb.directory(new File(serverDir)); + pb.redirectErrorStream(true); + + try { + serverProcess = pb.start(); + System.out.println("Server process started, PID: " + serverProcess.pid()); + } catch (IOException e) { + System.out.println("WARNING: Could not start server process: " + e.getMessage()); + System.out.println("Skipping integration tests"); + serverBaseUrl = null; + return; + } + + // Wait for server to start + Thread.sleep(4000); + + // Verify server is running + try { + URL url = new URL(serverBaseUrl + "/health"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(5000); + conn.connect(); + if (conn.getResponseCode() != 200) { + System.out.println("WARNING: Server health check failed: " + conn.getResponseCode()); + System.out.println("Skipping integration tests"); + serverBaseUrl = null; + if (serverProcess != null) serverProcess.destroy(); + conn.disconnect(); + return; + } + conn.disconnect(); + System.out.println("Test server started successfully"); + } catch (Exception e) { + System.out.println("WARNING: Server failed to start: " + e.getMessage()); + System.out.println("Skipping integration tests"); + serverBaseUrl = null; + if (serverProcess != null) serverProcess.destroy(); + } + } + + @AfterAll + static void stopTestServer() { + if (serverProcess != null) { + serverProcess.destroy(); + try { + serverProcess.waitFor(5000, java.util.concurrent.TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) {} + } + // Cleanup temp dir + if (testDir != null) { + try { + Files.walk(testDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(path -> { + try { Files.delete(path); } catch (IOException ignored) {} + }); + } catch (IOException ignored) {} + } + } + + @BeforeEach + void setUp() { + if (serverBaseUrl != null) { + ZHttpClient.setBaseUrl(serverBaseUrl); + } + } + + // ===== Auth flow tests ===== + + @Test + @Order(1) + void testRegister() throws Exception { + assumeServerRunning(); + + String response = httpPost("/auth/register", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertTrue(json.has("access_token")); + assertTrue(json.has("refresh_token")); + assertTrue(json.has("expires_in")); + assertTrue(json.has("uuid")); + assertEquals("integration_test_user", json.get("username").getAsString()); + assertTrue(json.has("role")); + } + + @Test + @Order(2) + void testLogin() throws Exception { + assumeServerRunning(); + + String response = httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertTrue(json.has("access_token")); + assertTrue(json.has("refresh_token")); + assertEquals("integration_test_user", json.get("username").getAsString()); + assertTrue(json.has("role")); + assertTrue(json.has("uuid")); + } + + @Test + @Order(3) + void testDuplicateRegistration() throws Exception { + assumeServerRunning(); + + try { + httpPost("/auth/register", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"AnotherPassword123\"" + + "}"); + fail("Should have thrown IOException for duplicate registration"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("409") || e.getMessage().contains("409"), + "Expected 409 conflict, got: " + e.getMessage()); + } + } + + @Test + @Order(4) + void testLoginWrongPassword() throws Exception { + assumeServerRunning(); + + try { + httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"WrongPassword\"" + + "}"); + fail("Should have thrown IOException for wrong password"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("401"), + "Expected 401, got: " + e.getMessage()); + } + } + + @Test + @Order(5) + void testGetAdminMe() throws Exception { + assumeServerRunning(); + + // Login to get token + String loginResp = httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject(); + String token = loginJson.get("access_token").getAsString(); + + // Get user info + String response = httpGet("/admin/me", token); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + + assertTrue(json.has("id")); + assertEquals("integration_test_user", json.get("username").getAsString()); + assertTrue(json.has("uuid")); + assertTrue(json.has("role")); + assertTrue(json.has("role_name")); + assertTrue(json.has("has_pass")); + assertTrue(json.has("permissions")); + } + + @Test + @Order(6) + void testValidateToken() throws Exception { + assumeServerRunning(); + + String loginResp = httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject(); + String token = loginJson.get("access_token").getAsString(); + String uuid = loginJson.get("uuid").getAsString(); + + // Validate + String response = httpPost("/auth/validate", + "{\"username\":\"integration_test_user\",\"uuid\":\"" + uuid + "\"}", + token); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + + assertTrue(json.has("valid")); + assertTrue(json.get("valid").getAsBoolean()); + assertEquals("integration_test_user", json.get("username").getAsString()); + } + + @Test + @Order(7) + void testRefreshToken() throws Exception { + assumeServerRunning(); + + String loginResp = httpPost("/auth/login", "{" + + "\"username\":\"integration_test_user\"," + + "\"password\":\"IntegrationTest123\"" + + "}"); + JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject(); + String refreshToken = loginJson.get("refresh_token").getAsString(); + + // Refresh + String response = httpPost("/auth/refresh", + "{\"refresh_token\":\"" + refreshToken + "\"}"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + + assertTrue(json.has("access_token")); + assertTrue(json.has("refresh_token")); + assertTrue(json.has("expires_in")); + assertEquals("integration_test_user", json.get("username").getAsString()); + } + + // ===== Pack endpoint tests ===== + + @Test + @Order(8) + void testPacksNoAuth() throws Exception { + assumeServerRunning(); + + try { + httpGet("/packs"); + fail("Should have thrown IOException for unauthenticated access"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("401") || e.getMessage().contains("403")); + } + } + + @Test + @Order(9) + void testPackManifestPublic() throws Exception { + assumeServerRunning(); + + // /pack/{name} is public + try { + String response = httpGet("/pack/nonexistent-pack"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + fail("Should have thrown IOException for non-existent pack"); + } catch (IOException e) { + assertTrue(e.getMessage().contains("404"), + "Expected 404, got: " + e.getMessage()); + } + } + + @Test + @Order(10) + void testLauncherVersion() throws Exception { + assumeServerRunning(); + + String response = httpGet("/launcher/version"); + JsonObject json = JsonParser.parseString(response).getAsJsonObject(); + assertTrue(json.has("version") || json.has("latest")); + } + + // ===== Helper methods ===== + + private static void assumeServerRunning() { + org.junit.jupiter.api.Assumptions.assumeTrue( + serverBaseUrl != null && serverProcess != null && serverProcess.isAlive(), + "Test server is not running" + ); + } + + private static String httpPost(String endpoint, String body) throws IOException { + return httpPost(endpoint, body, null); + } + + private static String httpPost(String endpoint, String body, String token) throws IOException { + URL url = new URL(serverBaseUrl + endpoint); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "application/json"); + if (token != null) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setDoOutput(true); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + byte[] input = body.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(input.length); + try (var os = conn.getOutputStream()) { + os.write(input); + } + + int code = conn.getResponseCode(); + String response = readResponse(conn, code); + + if (code >= 400) { + throw new IOException("HTTP " + code + ": " + response); + } + + conn.disconnect(); + return response; + } + + private static String httpGet(String endpoint) throws IOException { + return httpGet(endpoint, null); + } + + private static String httpGet(String endpoint, String token) throws IOException { + URL url = new URL(serverBaseUrl + endpoint); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + if (token != null) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + int code = conn.getResponseCode(); + String response = readResponse(conn, code); + + if (code >= 400) { + throw new IOException("HTTP " + code + ": " + response); + } + + conn.disconnect(); + return response; + } + + private static String readResponse(HttpURLConnection conn, int code) throws IOException { + var is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream(); + if (is == null) { + return ""; + } + try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) { + return scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; + } + } + + private static String findPythonPath(String serverDir) { + String[] paths = { + serverDir + "/.venv/bin/python3", + serverDir + "/.venv/bin/python", + "python3", + "python" + }; + for (String path : paths) { + File f = new File(path); + if (f.exists() && f.canExecute()) { + return path; + } + // Try which command + try { + Process p = new ProcessBuilder(path, "--version").start(); + int exit = p.waitFor(); + if (exit == 0) return path; + } catch (Exception ignored) {} + } + return null; + } + + private static String findServerDir() { + String[] paths = { + "../server", + "server", + System.getenv("SERVER_DIR") + }; + for (String path : paths) { + if (path != null && new File(path).exists() && new File(path, "main.py").exists()) { + return path; + } + } + return null; + } + + private static int findFreePort() throws IOException { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private static String readProcessOutput() throws IOException { + if (serverProcess == null) return ""; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(serverProcess.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + return sb.toString(); + } + } +} diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloaderParsingTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloaderParsingTest.java new file mode 100644 index 0000000..8f5c91c --- /dev/null +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloaderParsingTest.java @@ -0,0 +1,287 @@ +package me.sashegdev.zernmc.launcher.minecraft; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for PackDownloader JSON parsing. + * Tests that the Java client correctly parses server JSON responses. + */ +class PackDownloaderParsingTest { + + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + // ===== /packs response parsing ===== + + @Test + void parsePacksResponse_singlePack() { + String body = "{" + + "\"packs\":[" + + "{" + + "\"name\":\"test-modpack\"," + + "\"version\":3," + + "\"files_count\":15," + + "\"updated_at\":\"2024-01-15T10:30:00\"," + + "\"minecraft_version\":\"1.20.4\"," + + "\"loader_type\":\"fabric\"," + + "\"loader_version\":\"0.15.6\"" + + "}" + + "]" + + "}"; + + List packs = parsePacksResponse(body); + assertEquals(1, packs.size()); + + ServerPack pack = packs.get(0); + assertEquals("test-modpack", pack.getName()); + assertEquals(3, pack.getVersion()); + assertEquals(15, pack.getFilesCount()); + assertEquals("1.20.4", pack.getMinecraftVersion()); + assertEquals("fabric", pack.getLoaderType()); + assertEquals("0.15.6", pack.getLoaderVersion()); + assertNotNull(pack.getUpdatedAt()); + } + + @Test + void parsePacksResponse_multiplePacks() { + String body = "{" + + "\"packs\":[" + + "{\"name\":\"survival\",\"version\":1,\"files_count\":5,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," + + "{\"name\":\"pvp\",\"version\":10,\"files_count\":50,\"minecraft_version\":\"1.20.4\",\"loader_type\":\"fabric\",\"loader_version\":\"0.15.6\",\"updated_at\":\"2024-02-01T00:00:00\"}" + + "]" + + "}"; + + List packs = parsePacksResponse(body); + assertEquals(2, packs.size()); + assertEquals("survival", packs.get(0).getName()); + assertEquals("pvp", packs.get(1).getName()); + } + + @Test + void parsePacksResponse_skipsErroredPacks() { + String body = "{" + + "\"packs\":[" + + "{\"name\":\"good-pack\",\"version\":1,\"files_count\":1,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," + + "{\"name\":\"bad-pack\",\"error\":\"scan failed\"}," + + "{\"name\":\"not-scanned\",\"status\":\"not_scanned\"}" + + "]" + + "}"; + + List packs = parsePacksResponse(body); + assertEquals(1, packs.size()); + assertEquals("good-pack", packs.get(0).getName()); + } + + @Test + void parsePacksResponse_missingFields_defaults() { + String body = "{" + + "\"packs\":[" + + "{\"name\":\"minimal-pack\"}" + + "]" + + "}"; + + List packs = parsePacksResponse(body); + assertEquals(1, packs.size()); + + ServerPack pack = packs.get(0); + assertEquals("minimal-pack", pack.getName()); + assertEquals(0, pack.getVersion()); // default + assertEquals("unknown", pack.getMinecraftVersion()); // default + assertEquals("vanilla", pack.getLoaderType()); // default + assertEquals("", pack.getLoaderVersion()); // default + assertEquals(0, pack.getFilesCount()); // default + assertNull(pack.getUpdatedAt()); // default + } + + @Test + void parsePacksResponse_emptyList() { + String body = "{\"packs\":[]}"; + List packs = parsePacksResponse(body); + assertTrue(packs.isEmpty()); + } + + // ===== PackManifest parsing ===== + + @Test + void parsePackManifest_withFiles() { + String body = "{" + + "\"pack_name\":\"my-pack\"," + + "\"version\":5," + + "\"minecraft_version\":\"1.20.4\"," + + "\"loader_type\":\"fabric\"," + + "\"loader_version\":\"0.15.6\"," + + "\"asset_index\":\"1.20.4\"," + + "\"files\":{" + + "\"mods/sodium.jar\":{\"path\":\"mods/sodium.jar\",\"url\":\"/pack/my-pack/file/mods/sodium.jar\",\"size\":1024000,\"hash\":\"abc123\"}," + + "\"mods/fabric-api.jar\":{\"path\":\"mods/fabric-api.jar\",\"url\":\"/pack/my-pack/file/mods/fabric-api.jar\",\"size\":2048000,\"hash\":\"def456\"}" + + "}" + + "}"; + + PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class); + + assertEquals("my-pack", manifest.getPackName()); + assertEquals(5, manifest.getVersion()); + assertEquals("1.20.4", manifest.getMinecraftVersion()); + assertEquals("fabric", manifest.getLoaderType()); + assertEquals("0.15.6", manifest.getLoaderVersion()); + assertEquals("1.20.4", manifest.getAssetIndex()); + assertFalse(manifest.isEmpty()); + assertEquals(2, manifest.getFiles().size()); + } + + @Test + void parsePackManifest_nullAssetIndex_defaultsToMinecraftVersion() { + String body = "{" + + "\"pack_name\":\"no-asset\"," + + "\"version\":1," + + "\"minecraft_version\":\"1.19.4\"," + + "\"loader_type\":\"vanilla\"," + + "\"loader_version\":null" + + "}"; + + PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class); + assertEquals("1.19.4", manifest.getAssetIndex()); // defaults to minecraft_version + } + + @Test + void parsePackManifest_noFiles_isEmpty() { + String body = "{" + + "\"pack_name\":\"empty-pack\"," + + "\"version\":1," + + "\"minecraft_version\":\"1.20.1\"," + + "\"loader_type\":\"vanilla\"," + + "\"loader_version\":null" + + "}"; + + PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class); + assertTrue(manifest.isEmpty()); + } + + // ===== DiffResponse parsing ===== + + @Test + void parseDiffResponse_allFields() { + String body = "{" + + "\"version\":6," + + "\"to_download\":[" + + "{\"path\":\"mods/new-mod.jar\",\"url\":\"/pack/test/file/mods/new-mod.jar\",\"size\":512000,\"hash\":\"aaa111\"}" + + "]," + + "\"to_delete\":[\"mods/old-mod.jar\"]," + + "\"to_update\":[\"mods/updated-mod.jar\"]" + + "}"; + + PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class); + + assertEquals(6, diff.getVersion()); + assertEquals(1, diff.getToDownload().size()); + assertEquals(1, diff.getToDelete().size()); + assertEquals(1, diff.getToUpdate().size()); + + PackDownloader.FileInfo fileInfo = diff.getToDownload().get(0); + assertEquals("mods/new-mod.jar", fileInfo.getPath()); + assertEquals("/pack/test/file/mods/new-mod.jar", fileInfo.getUrl()); + assertEquals(512000, fileInfo.getSize()); + assertEquals("aaa111", fileInfo.getHash()); + } + + @Test + void parseDiffResponse_emptyArrays() { + String body = "{" + + "\"version\":1," + + "\"to_download\":[]," + + "\"to_delete\":[]," + + "\"to_update\":[]" + + "}"; + + PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class); + assertTrue(diff.getToDownload().isEmpty()); + assertTrue(diff.getToDelete().isEmpty()); + assertTrue(diff.getToUpdate().isEmpty()); + } + + @Test + void parseDiffResponse_nullArrays_returnsEmpty() { + String body = "{\"version\":1}"; + + PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class); + assertNotNull(diff.getToDownload()); + assertNotNull(diff.getToDelete()); + assertNotNull(diff.getToUpdate()); + assertTrue(diff.getToDownload().isEmpty()); + assertTrue(diff.getToDelete().isEmpty()); + } + + // ===== ServerPack toString ===== + + @Test + void serverPack_toString_withDate() { + java.time.LocalDateTime date = java.time.LocalDateTime.of(2024, 3, 15, 12, 0); + ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", date, 25); + + String str = pack.toString(); + assertTrue(str.contains("my-pack")); + assertTrue(str.contains("1.20.4")); + assertTrue(str.contains("fabric")); + assertTrue(str.contains("25 файлов")); + assertTrue(str.contains("15.03.2024")); + } + + @Test + void serverPack_toString_withoutDate() { + ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", null, 25); + + String str = pack.toString(); + assertTrue(str.contains("my-pack")); + assertTrue(str.contains("25 файлов")); + assertFalse(str.contains("обновлен")); + } + + // ===== Helper: replicates PackDownloader.parsePacksResponse() ===== + + private static List parsePacksResponse(String responseBody) { + JsonObject root = com.google.gson.JsonParser.parseString(responseBody).getAsJsonObject(); + JsonArray packsArray = root.getAsJsonArray("packs"); + List result = new ArrayList<>(); + + for (var elem : packsArray) { + JsonObject pack = elem.getAsJsonObject(); + + if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) { + continue; + } + + try { + String name = pack.get("name").getAsString(); + int version = pack.has("version") ? pack.get("version").getAsInt() : 0; + String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown"; + String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla"; + String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() + ? pack.get("loader_version").getAsString() : ""; + int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0; + + java.time.LocalDateTime updatedAt = null; + if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) { + try { + updatedAt = java.time.LocalDateTime.parse(pack.get("updated_at").getAsString(), + java.time.format.DateTimeFormatter.ISO_DATE_TIME); + } catch (Exception ignored) {} + } + + result.add(new ServerPack(name, version, minecraftVersion, loaderType, + loaderVersion, updatedAt, filesCount)); + } catch (Exception e) { + System.err.println("Ошибка парсинга пака: " + e.getMessage()); + } + } + + return result; + } +} diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java new file mode 100644 index 0000000..a586d21 --- /dev/null +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java @@ -0,0 +1,184 @@ +package me.sashegdev.zernmc.launcher; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; + +public class Bootstrap { + private static final String VERSION_FILE = "build.version"; + private static final String JAR_NAME = "bin/ZernMCLauncher.jar"; + private static final String BASE_URL = "http://87.120.187.36:1582/launcher/download?type=jar"; + + private static Path baseDir; + private static Path logDir; + + public static void main(String[] args) throws Exception { + baseDir = Paths.get("").toAbsolutePath(); + logDir = baseDir.resolve("logs"); + Files.createDirectories(logDir); + + log("=== ZernMC Launcher ==="); + + // Если передан аргумент --launcher, запускаем UI напрямую + if (args.length > 0 && args[0].equals("--launcher")) { + launchUI(); + return; + } + + String currentVersion = readCurrentVersion(); + String serverVersion = getServerVersion(); + + log("Локальная: " + currentVersion); + log("Сервер: " + serverVersion); + + if (isNewer(serverVersion, currentVersion)) { + log("Доступно обновление!"); + downloadUpdate(); + } else { + log("Актуально"); + } + + launchGame(); + } + + private static void log(String msg) { + String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg; + System.out.println(entry); + try { + Files.writeString(logDir.resolve("launcher.log"), entry + "\n", + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (Exception ignored) {} + } + + private static String readCurrentVersion() { + Path f = baseDir.resolve(VERSION_FILE); + try { + if (Files.exists(f)) return Files.readString(f).trim(); + } catch (Exception ignored) {} + return "0.0.0"; + } + + private static String getServerVersion() { + try { + URL url = new URL(BASE_URL.replace("download?type=jar", "version")); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + if (conn.getResponseCode() == 200) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line = br.readLine(); + if (line != null && line.contains("version")) { + return line.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1"); + } + } + } + } catch (Exception ignored) {} + return "unknown"; + } + + private static boolean isNewer(String server, String current) { + try { + String[] sa = server.split("\\."); + String[] ca = current.split("\\."); + for (int i = 0; i < Math.min(sa.length, ca.length); i++) { + int sv = Integer.parseInt(sa[i]); + int cv = Integer.parseInt(ca[i]); + if (sv > cv) return true; + if (sv < cv) return false; + } + return sa.length > ca.length; + } catch (Exception ignored) {} + return false; + } + + private static void downloadUpdate() throws Exception { + URL url = new URL(BASE_URL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + + if (conn.getResponseCode() == 200) { + Path tmp = baseDir.resolve(JAR_NAME + ".new"); + try (InputStream in = conn.getInputStream(); + OutputStream out = new FileOutputStream(tmp.toFile())) { + byte[] buf = new byte[8192]; + int len; + long total = 0; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + total += len; + System.out.print("\rСкачано: " + (total/1024/1024) + " MB"); + } + } + log("Скачано"); + + Path jarFile = baseDir.resolve(JAR_NAME); + Path backup = baseDir.resolve(JAR_NAME + ".old"); + + if (Files.exists(jarFile)) Files.move(jarFile, backup); + Files.move(tmp, jarFile); + if (Files.exists(backup)) Files.delete(backup); + + String newVersion = getServerVersion(); + Files.writeString(baseDir.resolve(VERSION_FILE), newVersion); + log("Обновлено до v" + newVersion); + } + } + + private static void launchUI() throws Exception { + // Запускаем Main с CLI аргументом + me.sashegdev.zernmc.launcher.Main.main(new String[]{"--cli"}); + } + + private static void launchGame() throws Exception { + // Сначала ищем в lib/jre21-custom/bin/java + Path javaBin = baseDir.resolve("lib").resolve("jre21-custom").resolve("bin").resolve("java"); + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + javaBin = javaBin.resolveSibling("java.exe"); + } + + // Потом в bin/ (альтернатива) + if (!Files.exists(javaBin)) { + javaBin = baseDir.resolve("bin").resolve("java"); + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + javaBin = javaBin.resolveSibling("java.exe"); + } + } + + // Системная java как запасной вариант + if (!Files.exists(javaBin)) { + javaBin = Paths.get(System.getProperty("java.home"), "bin", "java"); + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + javaBin = javaBin.resolveSibling("java.exe"); + } + } + + log("Java: " + javaBin); + log("baseDir: " + baseDir); + log("exists baseDir: " + Files.exists(baseDir)); + log("baseDir list: " + Arrays.toString(baseDir.toFile().list())); + log("exists java: " + Files.exists(javaBin)); + log("Запуск..."); + + Path jarPath = baseDir.resolve(JAR_NAME); + log("jarPath: " + jarPath); + log("jarPath exists: " + Files.exists(jarPath)); + log("jarPath abs: " + jarPath.toAbsolutePath()); + log("jarPath str: " + jarPath.toAbsolutePath().toString()); + + ProcessBuilder pb = new ProcessBuilder( + javaBin.toAbsolutePath().toString(), + "-jar", + jarPath.toAbsolutePath().toString(), + "--launcher" + ); + pb.directory(baseDir.toFile()); + pb.inheritIO(); + Process p = pb.start(); + int code = p.waitFor(); + log("Завершено: " + code); + System.exit(code); + } +} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/LaunchServer.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/LaunchServer.java new file mode 100644 index 0000000..2bb939f --- /dev/null +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/LaunchServer.java @@ -0,0 +1,254 @@ +package me.sashegdev.zernmc.launcher.ui.jcef; + +import com.google.gson.Gson; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.Headers; +import me.sashegdev.zernmc.launcher.api.ApiResponse; +import me.sashegdev.zernmc.launcher.api.LauncherAPI; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; + +public class LaunchServer { + private static final int PORT = 8080; + private final LauncherAPI api; + private final UIBridge bridge; + private HttpServer server; + private final Gson gson = new Gson(); + + public LaunchServer(UIBridge bridge) { + this.api = new LauncherAPI(); + this.bridge = bridge; + } + + public void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0); + + server.createContext("/api/login", this::handleLogin); + server.createContext("/api/account", this::handleAccount); + server.createContext("/api/instances", this::handleInstances); + server.createContext("/api/launch", this::handleLaunch); + server.createContext("/api/install", this::handleInstall); + server.createContext("/api/logs", this::handleLogs); + server.createContext("/api/exit", this::handleExit); + server.createContext("/ui/", this::handleStatic); + + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + + bridge.log("HTTP сервер запущен на порту " + PORT); + } + + public void stop() { + if (server != null) { + server.stop(0); + } + } + + private void handleLogin(HttpExchange exchange) throws IOException { + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + try { + Map body = parseJson(exchange.getRequestBody()); + String username = body.get("username"); + String password = body.get("password"); + + var result = api.login(username, password); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("username", result.getData().getUsername()); + data.put("token", result.getData().getToken()); + sendJson(exchange, ApiResponse.success(data)); + bridge.log("Пользователь вошел: " + username); + } else { + sendJson(exchange, ApiResponse.error(result.getError())); + } + } catch (Exception e) { + sendJson(exchange, ApiResponse.error("Ошибка: " + e.getMessage())); + } + } + + private void handleAccount(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + if (!api.isLoggedIn()) { + sendJson(exchange, ApiResponse.error("Не авторизован")); + return; + } + + try { + Map data = new HashMap<>(); + data.put("username", api.getCurrentUsername()); + data.put("passActive", AuthManager.hasActivePass()); + sendJson(exchange, ApiResponse.success(data)); + } catch (Exception e) { + sendJson(exchange, ApiResponse.error(e.getMessage())); + } + } + + private void handleInstances(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + try { + var result = api.getAllInstances(); + sendJson(exchange, result); + } catch (Exception e) { + sendJson(exchange, ApiResponse.error(e.getMessage())); + } + } + + private void handleLaunch(HttpExchange exchange) throws IOException { + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + if (!api.isLoggedIn()) { + sendJson(exchange, ApiResponse.error("Не авторизован")); + return; + } + + try { + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + + var result = api.launch(name); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("pid", result.getData().getPid()); + data.put("status", result.getData().getStatus()); + sendJson(exchange, ApiResponse.success(data)); + bridge.log("Запущена сборка: " + name); + } else { + sendJson(exchange, ApiResponse.error(result.getError())); + } + } catch (Exception e) { + sendJson(exchange, ApiResponse.error("Ошибка запуска: " + e.getMessage())); + } + } + + private void handleInstall(HttpExchange exchange) throws IOException { + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, ApiResponse.error("Метод не поддерживается")); + return; + } + + if (!api.isLoggedIn()) { + sendJson(exchange, ApiResponse.error("Не авторизован")); + return; + } + + try { + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + String version = body.get("version"); + String loader = body.get("loader"); + + bridge.log("Установка сборки: " + name + " " + version + " " + loader); + + var createResult = api.instances().createInstance(name); + if (!createResult.isSuccess()) { + sendJson(exchange, ApiResponse.error(createResult.getError())); + return; + } + + Instance instance = InstanceManager.getInstance(name); + if (instance != null) { + instance.setMinecraftVersion(version); + instance.setLoaderType(loader); + } + + sendJson(exchange, ApiResponse.success(true)); + bridge.log("Сборка установлена: " + name); + } catch (Exception e) { + sendJson(exchange, ApiResponse.error("Ошибка установки: " + e.getMessage())); + } + } + + private void handleLogs(HttpExchange exchange) throws IOException { + String logs = bridge.getLogs(); + sendJson(exchange, ApiResponse.success(logs)); + } + + private void handleExit(HttpExchange exchange) throws IOException { + bridge.log("Завершение работы..."); + System.exit(0); + } + + private void handleStatic(HttpExchange exchange) throws IOException { + String path = exchange.getRequestURI().getPath(); + if (path.equals("/ui/") || path.equals("/ui")) { + path = "/ui/index.html"; + } + + var resource = getClass().getResource(path); + + if (resource == null) { + exchange.sendResponseHeaders(404, 0); + exchange.close(); + return; + } + + try { + byte[] content = resource.openStream().readAllBytes(); + String contentType = getContentType(path); + + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, content.length); + + OutputStream os = exchange.getResponseBody(); + os.write(content); + os.close(); + } catch (IOException ignored) {} + } + + private String getContentType(String path) { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + return "text/plain"; + } + + @SuppressWarnings("unchecked") + private Map parseJson(InputStream body) { + try { + String json = new String(body.readAllBytes(), StandardCharsets.UTF_8); + return gson.fromJson(json, Map.class); + } catch (Exception e) { + return new HashMap<>(); + } + } + + private void sendJson(HttpExchange exchange, ApiResponse response) { + try { + String json = gson.toJson(response); + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + + OutputStream os = exchange.getResponseBody(); + os.write(bytes); + os.close(); + } catch (IOException ignored) {} + } +} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UIBridge.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UIBridge.java new file mode 100644 index 0000000..9768e80 --- /dev/null +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UIBridge.java @@ -0,0 +1,11 @@ +package me.sashegdev.zernmc.launcher.ui.jcef; + +public class UIBridge { + public void log(String message) { + System.out.println("[UI] " + message); + } + + public String getLogs() { + return ""; + } +} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UILauncher.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UILauncher.java new file mode 100644 index 0000000..5c99b4f --- /dev/null +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jcef/UILauncher.java @@ -0,0 +1,102 @@ +package me.sashegdev.zernmc.launcher.ui.jcef; + +import me.sashegdev.zernmc.launcher.api.LauncherAPI; + +import java.awt.*; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.URI; + +public class UILauncher { + private static final String APP_TITLE = "ZernMC Launcher"; + private final LauncherAPI api; + private final UIBridge bridge; + private LaunchServer server; + + public UILauncher() { + this.api = new LauncherAPI(); + this.bridge = new UIBridge(); + } + + public void launch() throws Exception { + redirectSystemLogs(); + bridge.log("Запуск UI..."); + + server = new LaunchServer(bridge); + server.start(); + + openBrowser(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + bridge.log("Выключение..."); + if (server != null) server.stop(); + })); + + Thread.currentThread().join(); + } + + private void openBrowser() { + String url = "http://localhost:8080/ui/"; + bridge.log("Открытие браузера: " + url); + + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI.create(url)); + bridge.log("Браузер открыт"); + } else { + bridge.log("Desktop browsing not supported"); + } + } catch (Exception e) { + bridge.log("Ошибка открытия браузера: " + e.getMessage()); + } + } + + private void redirectSystemLogs() { + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + + System.setOut(new PrintStream(new ByteArrayOutputStream() { + @Override + public void write(byte[] b, int off, int len) { + String line = new String(b, off, len).trim(); + if (!line.isEmpty()) { + bridge.log(line); + } + try { + originalOut.write(b, off, len); + } catch (Exception ignored) {} + } + + @Override + public void write(int b) { + try { + originalOut.write(b); + } catch (Exception ignored) {} + } + })); + + System.setErr(new PrintStream(new ByteArrayOutputStream() { + @Override + public void write(byte[] b, int off, int len) { + String line = new String(b, off, len).trim(); + if (!line.isEmpty()) { + bridge.log("[ERROR] " + line); + } + try { + originalErr.write(b, off, len); + } catch (Exception ignored) {} + } + })); + } + + public static void main(String[] args) { + try { + UILauncher launcher = new UILauncher(); + launcher.launch(); + } catch (Exception e) { + System.err.println("UI launch failed: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } +} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java new file mode 100644 index 0000000..1b60d4b --- /dev/null +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -0,0 +1,281 @@ +package me.sashegdev.zernmc.launcher.ui.jfx; + +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.scene.web.WebView; +import javafx.scene.web.WebEngine; +import javafx.stage.Stage; +import javafx.concurrent.Worker; +import com.google.gson.Gson; +import me.sashegdev.zernmc.launcher.api.LauncherAPI; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.Headers; + +public class JFXLauncher extends Application { + private static final int PORT = 8080; + private static final String APP_TITLE = "ZernMC Launcher"; + private final LauncherAPI api = new LauncherAPI(); + private final Gson gson = new Gson(); + private HttpServer server; + private StringBuilder logBuffer = new StringBuilder(); + private Stage mainStage; + + public static void main(String[] args) { + launch(args); + } + + @Override + public void start(Stage stage) { + this.mainStage = stage; + + try { + log("Запуск JFX UI..."); + startServer(); + + WebView webView = new WebView(); + WebEngine engine = webView.getEngine(); + engine.setJavaScriptEnabled(true); + + engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { + if (newState == Worker.State.SUCCEEDED) { + log("Страница загружена"); + } + }); + + String url = "http://localhost:" + PORT + "/ui/"; + engine.load(url); + + stage.setTitle(APP_TITLE); + stage.setWidth(1200); + stage.setHeight(800); + stage.setScene(new Scene(webView)); + stage.show(); + + log("Окно отображено"); + + stage.setOnCloseRequest(e -> { + log("Закрытие..."); + stopServer(); + }); + + } catch (Exception e) { + log("Ошибка: " + e.getMessage()); + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private void startServer() throws Exception { + server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0); + + server.createContext("/api/login", this::handleLogin); + server.createContext("/api/account", this::handleAccount); + server.createContext("/api/instances", this::handleInstances); + server.createContext("/api/launch", this::handleLaunch); + server.createContext("/api/install", this::handleInstall); + server.createContext("/api/logs", this::handleLogs); + server.createContext("/api/exit", this::handleExit); + server.createContext("/ui/", this::handleStatic); + + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + + log("HTTP сервер на порту " + PORT); + } + + private void stopServer() { + if (server != null) server.stop(0); + } + + private void handleLogin(HttpExchange exchange) { + try { + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, Map.of("success", false, "error", "Метод не поддерживается")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String username = body.get("username"); + String password = body.get("password"); + + var result = api.login(username, password); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("username", result.getData().getUsername()); + data.put("token", result.getData().getToken()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Вход: " + username); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleAccount(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Не авторизован")); + return; + } + Map data = new HashMap<>(); + data.put("username", api.getCurrentUsername()); + data.put("passActive", AuthManager.hasActivePass()); + sendJson(exchange, Map.of("success", true, "data", data)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleInstances(HttpExchange exchange) { + try { + var result = api.getAllInstances(); + Map response = new HashMap<>(); + response.put("success", result.isSuccess()); + if (result.isSuccess()) { + response.put("data", result.getData()); + } else { + response.put("error", result.getError()); + } + sendJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLaunch(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Не авторизован")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + + var result = api.launch(name); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("pid", result.getData().getPid()); + data.put("status", result.getData().getStatus()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Запущено: " + name); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleInstall(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Не авторизован")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + String version = body.get("version"); + String loader = body.get("loader"); + + log("Установка: " + name + " " + version + " " + loader); + + var createResult = api.instances().createInstance(name); + if (!createResult.isSuccess()) { + sendJson(exchange, Map.of("success", false, "error", createResult.getError())); + return; + } + + Instance instance = InstanceManager.getInstance(name); + if (instance != null) { + instance.setMinecraftVersion(version); + instance.setLoaderType(loader); + } + + sendJson(exchange, Map.of("success", true, "data", true)); + log("Установлено: " + name); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLogs(HttpExchange exchange) { + sendJson(exchange, Map.of("success", true, "data", logBuffer.toString())); + } + + private void handleExit(HttpExchange exchange) { + log("Выход..."); + if (mainStage != null) mainStage.close(); + System.exit(0); + } + + private void handleStatic(HttpExchange exchange) { + try { + String path = exchange.getRequestURI().getPath(); + if (path.equals("/ui/") || path.equals("/ui")) path = "/ui/index.html"; + + var resource = JFXLauncher.class.getResource(path); + if (resource == null) { + exchange.sendResponseHeaders(404, 0); + exchange.close(); + return; + } + + byte[] content = resource.openStream().readAllBytes(); + String ct = getContentType(path); + + exchange.getResponseHeaders().set("Content-Type", ct); + exchange.sendResponseHeaders(200, content.length); + exchange.getResponseBody().write(content); + exchange.close(); + } catch (Exception ignored) {} + } + + private String getContentType(String path) { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + return "text/plain"; + } + + @SuppressWarnings("unchecked") + private Map parseJson(InputStream body) { + try { + return gson.fromJson(new String(body.readAllBytes(), StandardCharsets.UTF_8), Map.class); + } catch (Exception e) { + return new HashMap<>(); + } + } + + private void sendJson(HttpExchange exchange, Map response) { + try { + String json = gson.toJson(response); + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } catch (Exception ignored) {} + } + + private void log(String msg) { + String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n"; + logBuffer.append(entry); + System.out.println("[JFX] " + msg); + } +} \ No newline at end of file diff --git a/launcher/src/main/resources/ui/index.html b/launcher/src/main/resources/ui/index.html new file mode 100644 index 0000000..daf5136 --- /dev/null +++ b/launcher/src/main/resources/ui/index.html @@ -0,0 +1,99 @@ + + + + + + ZernMC Launcher + + + +
+ +
+ +
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/launcher/src/main/resources/ui/launcher.js b/launcher/src/main/resources/ui/launcher.js new file mode 100644 index 0000000..e4236d7 --- /dev/null +++ b/launcher/src/main/resources/ui/launcher.js @@ -0,0 +1,273 @@ +const API_BASE = 'http://localhost:8080/api'; + +let state = { + loggedIn: false, + account: null, + instances: [], + selectedInstance: null +}; + +// ============ API ============ + +async function apiCall(endpoint, options = {}) { + const url = `${API_BASE}${endpoint}`; + const config = { + headers: { 'Content-Type': 'application/json' }, + ...options + }; + + try { + const response = await fetch(url, config); + const data = await response.json(); + return data; + } catch (e) { + log('Ошибка соединения с сервером: ' + e.message, 'error'); + return { success: false, error: e.message }; + } +} + +// ============ Login ============ + +async function login(username, password) { + log('Выполняется вход...', 'info'); + const result = await apiCall('/login', { + method: 'POST', + body: JSON.stringify({ username, password }) + }); + + if (result.success) { + state.loggedIn = true; + state.account = result.data; + log('Вход выполнен: ' + result.data.username, 'success'); + showMainScreen(); + await loadInstances(); + } else { + log('Ошибка входа: ' + result.error, 'error'); + showError(result.error); + } + return result; +} + +function showError(message) { + const el = document.getElementById('login-error'); + el.textContent = message; + el.classList.remove('hidden'); +} + +function hideError() { + document.getElementById('login-error').classList.add('hidden'); +} + +// ============ Account ============ + +async function loadAccountInfo() { + const result = await apiCall('/account'); + if (result.success) { + state.account = result.data; + state.loggedIn = true; + document.getElementById('account-name').textContent = result.data.username; + + const statusEl = document.getElementById('account-status'); + statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE'; + statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive'); + } else { + showLoginScreen(); + } +} + +// ============ Instances ============ + +async function loadInstances() { + log('Загрузка списка сборок...', 'info'); + const result = await apiCall('/instances'); + + if (result.success) { + state.instances = result.data; + renderInstances(); + log('Загружено ' + result.data.length + ' сборок', 'success'); + } else { + log('Ошибка загрузки: ' + result.error, 'error'); + } +} + +function renderInstances() { + const container = document.getElementById('instances-list'); + container.innerHTML = ''; + + state.instances.forEach(inst => { + const card = document.createElement('div'); + card.className = 'instance-card'; + card.dataset.name = inst.name; + card.onclick = () => selectInstance(inst.name); + + let details = ` + ${inst.version || '?'} + ${inst.loaderType || 'vanilla'} + `; + + if (inst.isServerPack) { + details += `v${inst.serverVersion}`; + } + + card.innerHTML = ` +
${inst.name}
+
${details}
+ `; + + container.appendChild(card); + }); +} + +function selectInstance(name) { + state.selectedInstance = state.instances.find(i => i.name === name); + + document.querySelectorAll('.instance-card').forEach(c => { + c.classList.toggle('selected', c.dataset.name === name); + }); + + const btn = document.getElementById('play-btn'); + const inst = state.selectedInstance; + + if (inst) { + document.getElementById('selected-name').textContent = inst.name; + document.getElementById('selected-version').textContent = inst.version || '-'; + document.getElementById('selected-loader').textContent = inst.loaderType || 'vanilla'; + + btn.disabled = false; + btn.textContent = 'Играть'; + btn.classList.remove('update'); + } else { + btn.disabled = true; + btn.textContent = 'Выберите сборку'; + } +} + +// ============ Launch ============ + +async function launchInstance() { + if (!state.selectedInstance) return; + + const name = state.selectedInstance.name; + log('Запуск сборки: ' + name, 'info'); + + const result = await apiCall('/launch', { + method: 'POST', + body: JSON.stringify({ name }) + }); + + if (result.success) { + log('Сборка запущена! PID: ' + result.data.pid, 'success'); + } else { + log('Ошибка запуска: ' + result.error, 'error'); + } +} + +// ============ Install ============ + +function openInstallModal() { + document.getElementById('install-modal').classList.remove('hidden'); +} + +function closeInstallModal() { + document.getElementById('install-modal').classList.add('hidden'); +} + +async function installInstance(formData) { + log('Установка сборки...', 'info'); + const result = await apiCall('/install', { + method: 'POST', + body: JSON.stringify(formData) + }); + + if (result.success) { + log('Сборка установлена!', 'success'); + closeInstallModal(); + await loadInstances(); + } else { + log('Ошибка установки: ' + result.error, 'error'); + } + return result; +} + +// ============ Logs ============ + +function log(message, type = 'info') { + const container = document.getElementById('logs-container'); + if (!container) return; + + const line = document.createElement('div'); + line.className = 'log-line ' + type; + line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message; + container.appendChild(line); + container.scrollTop = container.scrollHeight; +} + +function clearLogs() { + document.getElementById('logs-container').innerHTML = ''; +} + +// ============ Screens ============ + +function showLoginScreen() { + document.getElementById('login-screen').classList.remove('hidden'); + document.getElementById('main-screen').classList.add('hidden'); + clearError(); +} + +function showMainScreen() { + document.getElementById('login-screen').classList.add('hidden'); + document.getElementById('main-screen').classList.remove('hidden'); + + if (state.account) { + document.getElementById('account-name').textContent = state.account.username; + const statusEl = document.getElementById('account-status'); + statusEl.textContent = state.account.passActive ? 'PRO' : 'FREE'; + statusEl.className = 'badge ' + (state.account.passActive ? 'active' : 'inactive'); + } +} + +// ============ Init ============ + +document.addEventListener('DOMContentLoaded', async () => { + log('Запуск лаунчера...', 'info'); + + await loadAccountInfo(); + + if (!state.loggedIn) { + showLoginScreen(); + } else { + showMainScreen(); + await loadInstances(); + } +}); + +// ============ Form Handlers ============ + +document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + hideError(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + await login(username, password); +}); + +document.getElementById('play-btn').addEventListener('click', async () => { + await launchInstance(); +}); + +document.getElementById('install-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const formData = { + name: document.getElementById('install-name').value, + version: document.getElementById('install-mc-version').value, + loader: document.getElementById('install-loader').value + }; + + await installInstance(formData); +}); + +// Expose functions globally for inline handlers +window.closeInstallModal = closeInstallModal; \ No newline at end of file diff --git a/launcher/src/main/resources/ui/style.css b/launcher/src/main/resources/ui/style.css new file mode 100644 index 0000000..40b3776 --- /dev/null +++ b/launcher/src/main/resources/ui/style.css @@ -0,0 +1,433 @@ +:root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-tertiary: #0f3460; + --accent: #e94560; + --accent-hover: #ff6b6b; + --text-primary: #eaeaea; + --text-secondary: #a0a0a0; + --success: #4ade80; + --warning: #fbbf24; + --error: #ef4444; + --border: #2d2d4a; + --shadow: rgba(0, 0, 0, 0.3); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + overflow: hidden; +} + +#app { + height: 100vh; + display: flex; + flex-direction: column; +} + +/* Screens */ +.screen { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} + +.hidden { + display: none !important; +} + +/* Login Screen */ +#login-screen { + justify-content: center; + align-items: center; + background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); +} + +.login-container { + background: var(--bg-secondary); + padding: 3rem; + border-radius: 16px; + box-shadow: 0 25px 50px var(--shadow); + width: 100%; + max-width: 400px; + border: 1px solid var(--border); +} + +.logo { + font-size: 2.5rem; + text-align: center; + color: var(--accent); + margin-bottom: 0.5rem; +} + +.subtitle { + text-align: center; + color: var(--text-secondary); + margin-bottom: 2rem; +} + +#login-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +input, select { + background: var(--bg-primary); + border: 1px solid var(--border); + color: var(--text-primary); + padding: 0.875rem 1rem; + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.2s; +} + +input:focus, select:focus { + outline: none; + border-color: var(--accent); +} + +input::placeholder { + color: var(--text-secondary); +} + +.btn-primary { + background: var(--accent); + color: white; + border: none; + padding: 0.875rem 1rem; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-primary:disabled { + background: var(--text-secondary); + cursor: not-allowed; +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); + padding: 0.875rem 1rem; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + transition: background 0.2s; +} + +.btn-secondary:hover { + background: var(--bg-secondary); +} + +.error { + color: var(--error); + text-align: center; + margin-top: 1rem; + padding: 0.75rem; + border-radius: 8px; + background: rgba(239, 68, 68, 0.1); +} + +/* Main Screen */ +#main-screen { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header */ +.header { + background: var(--bg-secondary); + padding: 1rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.header .logo { + font-size: 1.5rem; + margin: 0; +} + +.account-info { + display: flex; + align-items: center; + gap: 1rem; +} + +#account-name { + font-weight: 600; +} + +.badge { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 500; +} + +.badge.active { + background: rgba(74, 222, 128, 0.2); + color: var(--success); +} + +.badge.inactive { + background: rgba(239, 68, 68, 0.2); + color: var(--error); +} + +/* Main Content */ +.main-content { + flex: 1; + display: grid; + grid-template-columns: 280px 1fr; + gap: 1px; + background: var(--border); + overflow: hidden; +} + +/* Sidebar */ +.sidebar { + background: var(--bg-secondary); + padding: 1rem; + overflow-y: auto; +} + +.sidebar h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 1rem; +} + +.instances-container { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.instance-card { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.instance-card:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +.instance-card.selected { + border-color: var(--accent); + background: rgba(233, 69, 96, 0.1); +} + +.instance-name { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.instance-details { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.instance-version, .instance-loader { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: var(--bg-tertiary); +} + +.instance-server-version { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: rgba(251, 191, 36, 0.2); + color: var(--warning); +} + +/* Logs Panel */ +.logs-panel { + background: var(--bg-primary); + padding: 1rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.logs-panel h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 1rem; +} + +#logs-container { + flex: 1; + background: #0d0d1a; + border-radius: 8px; + padding: 1rem; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.875rem; + overflow-y: auto; + line-height: 1.6; +} + +.log-line { + margin-bottom: 0.25rem; + white-space: pre-wrap; + word-break: break-all; +} + +.log-line.info { color: var(--text-primary); } +.log-line.success { color: var(--success); } +.log-line.warning { color: var(--warning); } +.log-line.error { color: var(--error); } + +/* Footer */ +.footer { + background: var(--bg-secondary); + padding: 1rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--border); +} + +.instance-info { + display: flex; + gap: 1rem; + align-items: center; +} + +.instance-info span { + padding: 0.5rem 1rem; + background: var(--bg-primary); + border-radius: 6px; + font-size: 0.875rem; +} + +#selected-name { + font-weight: 600; + color: var(--accent); +} + +.btn-play { + background: var(--success); + color: #0a0a0a; + border: none; + padding: 0.875rem 2rem; + border-radius: 8px; + font-size: 1.125rem; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.btn-play:hover:not(:disabled) { + transform: scale(1.05); + box-shadow: 0 0 20px rgba(74, 222, 128, 0.4); +} + +.btn-play:disabled { + background: var(--text-secondary); + cursor: not-allowed; +} + +.btn-play.update { + background: var(--warning); +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 100; +} + +.modal-content { + background: var(--bg-secondary); + padding: 2rem; + border-radius: 16px; + width: 100%; + max-width: 450px; + border: 1px solid var(--border); +} + +.modal-content h2 { + margin-bottom: 1.5rem; +} + +#install-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +#install-form label { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +#install-form select, #install-form input { + width: 100%; +} + +.modal-buttons { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border); +} \ No newline at end of file