diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5cf1396
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+logs/
+__pycache__/
+./.venv/
+launcher/target
+server/builds
+server/packs
+server/data
+jre
+.vscode
\ No newline at end of file
diff --git a/launcher/dependency-reduced-pom.xml b/launcher/dependency-reduced-pom.xml
new file mode 100644
index 0000000..7cb45e3
--- /dev/null
+++ b/launcher/dependency-reduced-pom.xml
@@ -0,0 +1,90 @@
+
+
+ 4.0.0
+ me.sashegdev
+ ZernMCLauncher
+ 1.0.2
+
+
+
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+
+ shade
+
+
+ ../server/builds/ZernMCLauncher.jar
+
+
+ ${mainClass}
+
+
+
+
+
+
+
+ com.akathist.maven.plugins.launch4j
+ launch4j-maven-plugin
+ 2.5.0
+
+
+ l4j
+ package
+
+ launch4j
+
+
+ ../server/builds/ZernMCLauncher.exe
+ ../server/builds/ZernMCLauncher.jar
+ console
+ false
+
+ jre21
+ 21
+
+
+ 1.0.0.0
+ 1.0.0
+ ZernMC Launcher
+ 1.0.0.0
+ 1.0.0
+ ZernMC Launcher
+
+
+
+
+
+
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+ run
+
+
+
+ ${project.version}
+
+
+
+
+
+
+
+
+
+
+
+
+ 21
+ me.sashegdev.zernmc.launcher.Main
+ 21
+ UTF-8
+
+
diff --git a/launcher/pom.xml b/launcher/pom.xml
new file mode 100644
index 0000000..54cb25c
--- /dev/null
+++ b/launcher/pom.xml
@@ -0,0 +1,158 @@
+
+
+
+ 4.0.0
+ me.sashegdev
+ ZernMCLauncher
+ 1.0.2
+ jar
+
+
+ 21
+ 21
+ UTF-8
+ me.sashegdev.zernmc.launcher.Main
+
+
+
+
+
+ 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
+ 20231013
+
+
+
+
+ org.fusesource.jansi
+ jansi
+ 2.4.1
+
+
+
+
+ me.tongfei
+ progressbar
+ 0.9.5
+
+
+
+
+ commons-io
+ commons-io
+ 2.15.1
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+
+ shade
+
+
+ ../server/builds/ZernMCLauncher.jar
+
+
+ ${mainClass}
+
+
+
+
+
+
+
+
+
+ com.akathist.maven.plugins.launch4j
+ launch4j-maven-plugin
+ 2.5.0
+
+
+ l4j
+ package
+
+ launch4j
+
+
+ ../server/builds/ZernMCLauncher.exe
+ ../server/builds/ZernMCLauncher.jar
+ console
+ false
+
+ jre21
+ 21
+
+
+ 1.0.0.0
+ 1.0.0
+ ZernMC Launcher
+ 1.0.0.0
+ 1.0.0
+ ZernMC Launcher
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+ run
+
+
+ ${project.version}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java
new file mode 100644
index 0000000..bb894f7
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java
@@ -0,0 +1,157 @@
+package me.sashegdev.zernmc.launcher;
+
+import me.sashegdev.zernmc.launcher.menu.*;
+import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
+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();
+
+ public static void main(String[] args) {
+ System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
+ ZAnsi.install();
+
+ System.out.print("\033[H\033[2J");
+ System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
+
+ checkAndAutoUpdateLauncher();
+
+ 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)"));
+
+ // Заменяем текущий jar
+ 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 {
+ while (true) {
+ List options = List.of(
+ "Запустить игру",
+ "Проверка обновлений",
+ "Настройки",
+ "Проверка подключения к серверам Zern",
+ "Выход"
+ );
+
+ ArrowMenu menu = new ArrowMenu("Главное меню", options);
+ int choice = menu.show();
+
+ if (choice == -1 || choice == 4) {
+ System.out.print("\033[H\033[2J");
+ System.out.println(ZAnsi.yellow("До свидания!"));
+ break;
+ }
+
+ switch (choice) {
+ case 0 -> new LaunchMenu().show();
+ 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/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java
new file mode 100644
index 0000000..2deec3b
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java
@@ -0,0 +1,348 @@
+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.MinecraftLib;
+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.ConsoleUtils;
+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 LaunchMenu {
+
+ public void show() 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) break;
+ if (choice == options.size() - 1) break; // Назад
+
+ if (choice == instances.size()) {
+ installNewPack();
+ continue;
+ }
+
+ Instance selected = instances.get(choice);
+ manageInstance(selected);
+ }
+ }
+
+ private void installNewPack() throws IOException {
+ ConsoleUtils.clearScreen();
+ System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
+
+ try {
+ 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(mcVersion);
+ return;
+ }
+
+ String loaderType = selectedLoader.contains("Fabric") ? "fabric" : "forge";
+
+ String loaderVersion;
+ if (loaderType.equals("fabric")) {
+ loaderVersion = askFabricLoaderVersion();
+ } else {
+ loaderVersion = askForgeVersion(mcVersion);
+ }
+
+ if (loaderVersion == null) return;
+
+ String packName = askPackName();
+ if (packName == null) return;
+
+ InstanceManager.createInstanceFolder(packName);
+ Instance newInstance = InstanceManager.getInstance(packName);
+
+ MinecraftLib lib = new MinecraftLib(newInstance);
+
+ boolean success = loaderType.equals("fabric")
+ ? lib.installFabric(mcVersion, loaderVersion)
+ : lib.installForge(mcVersion, loaderVersion);
+
+ if (success) {
+ System.out.println(ZAnsi.brightGreen("\nСборка '" + packName + "' успешно установлена!"));
+ }
+
+ } catch (Exception e) {
+ System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage()));
+ }
+
+ ConsoleUtils.pause();
+ }
+
+ // ====================== Вспомогательные методы ======================
+
+ private List buildLoaderOptions(String mcVersion) {
+ List options = new ArrayList<>();
+
+ if (isFabricSupported(mcVersion)) {
+ options.add("Fabric");
+ }
+ if (isForgeSupported(mcVersion)) {
+ options.add("Forge");
+ }
+ options.add("Vanilla");
+ options.add("Назад");
+
+ return options;
+ }
+
+ private boolean isFabricSupported(String version) {
+ // Fabric стабильно работает с 1.14+
+ return version.matches("^1\\.(1[4-9]|[2-9]\\d).*");
+ }
+
+ private boolean isForgeSupported(String version) {
+ // Forge поддерживает примерно до 1.21.4 на текущий момент
+ // Для версий 1.22+ и экспериментальных — отключаем
+ 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 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() : "")));
+
+ List options = new ArrayList<>();
+ options.add("Запустить сборку");
+ options.add("Изменить версию лоадера");
+ options.add("Удалить сборку");
+ options.add("Назад");
+
+ ArrowMenu menu = new ArrowMenu("Действия", options);
+ int choice = menu.show();
+
+ if (choice == -1 || choice == 3) return; // Esc или Назад
+
+ switch (choice) {
+ case 0 -> launchExistingInstance(instance);
+ case 1 -> changeLoaderVersion(instance);
+ case 2 -> deleteInstance(instance);
+ }
+ }
+ }
+
+ 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 {
+ 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 {
+ success = lib.installForge(mcVersion, newLoaderVersion);
+ }
+
+ if (success) {
+ System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!"));
+ }
+ } catch (Exception e) {
+ System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage()));
+ }
+
+ ConsoleUtils.pause();
+ }
+
+ private void deleteInstance(Instance instance) throws IOException {
+ System.out.println(ZAnsi.brightRed("Вы действительно хотите удалить сборку '" + instance.getName() + "'?"));
+ System.out.print(ZAnsi.white("Введите 'да' для подтверждения: "));
+ String confirm = new java.util.Scanner(System.in).nextLine().trim();
+
+ if ("да".equalsIgnoreCase(confirm)) {
+ InstanceManager.getInstance(instance.getName());
+ System.out.println(ZAnsi.brightGreen("Сборка удалена."));
+ } else {
+ System.out.println(ZAnsi.yellow("Отменено."));
+ }
+ ConsoleUtils.pause();
+ }
+
+ private String askFabricLoaderVersion() throws Exception {
+ System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader..."));
+ List versions = ZHttpClient.getFabricLoaderVersions();
+
+ List options = versions.stream()
+ .limit(30) // увеличил до 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 + "..."));
+
+ // Получаем все версии Forge из Maven
+ List allForgeVersions = getAllForgeVersions();
+
+ // Фильтруем только те, которые подходят под нашу версию Minecraft
+ List compatibleVersions = allForgeVersions.stream()
+ .filter(v -> v.startsWith(mcVersion + "-"))
+ .map(v -> v.substring(mcVersion.length() + 1)) // убираем "1.20.1-"
+ .collect(Collectors.toList());
+
+ if (compatibleVersions.isEmpty()) {
+ System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion));
+ ConsoleUtils.pause();
+ return null;
+ }
+
+ List options = compatibleVersions.stream()
+ .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 String askPackName() {
+ System.out.print(ZAnsi.white("\nВведите название новой сборки: "));
+ String name = new java.util.Scanner(System.in).nextLine().trim();
+ if (name.isEmpty()) {
+ System.out.println(ZAnsi.yellow("Отменено."));
+ return null;
+ }
+ return name;
+ }
+
+ private void createVanillaInstance(String mcVersion) throws Exception {
+ String packName = askPackName();
+ if (packName == null) 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("\nVanilla сборка '" + packName + "' успешно создана!"));
+ }
+ }
+
+ private void launchExistingInstance(Instance instance) {
+ ConsoleUtils.clearScreen();
+ System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
+
+ MinecraftLib lib = new MinecraftLib(instance);
+ LaunchOptions options = new LaunchOptions();
+
+ try {
+ lib.launch(options);
+ } catch (Exception e) {
+ System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage()));
+ }
+
+ ConsoleUtils.pause();
+ }
+
+ private List getAllForgeVersions() throws Exception {
+ String metadataUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml";
+
+ String xml = ZHttpClient.downloadString(metadataUrl); // добавь этот метод в ZHttpClient, если его нет
+
+ // Парсим простым способом (без 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;
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java
new file mode 100644
index 0000000..68bd045
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/ServerCheckMenu.java
@@ -0,0 +1,103 @@
+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 {
+ List options = List.of(
+ "Проверить подключение к ZernMC серверу",
+ "Проверить доступ к Mojang (Minecraft)",
+ "Проверить доступ к Fabric Meta",
+ "Назад в главное меню"
+ );
+
+ 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();
+ }
+
+ ConsoleUtils.pause();
+ }
+
+ private void checkZernServer() {
+ System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
+ try {
+ String response = ZHttpClient.get("/health");
+ System.out.println(ZAnsi.brightGreen("Сервер успешно подключён!"));
+ System.out.println("Ответ: " + response);
+ } catch (Exception e) {
+ System.out.println(ZAnsi.brightRed("Не удалось подключиться к ZernMC серверу"));
+ System.out.println("Ошибка: " + e.getMessage());
+ }
+ }
+
+ private void checkMojang() {
+ System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
+ try {
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(8))
+ .build();
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("https://launchermeta.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("Mojang доступен"));
+ } else {
+ System.out.println(ZAnsi.brightRed("Mojang вернул код " + response.statusCode()));
+ }
+ } catch (Exception e) {
+ System.out.println(ZAnsi.brightRed("Нет доступа к Mojang"));
+ System.out.println("Ошибка: " + e.getMessage());
+ }
+ }
+
+ private void checkFabric() {
+ System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
+ try {
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(8))
+ .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("Fabric Meta доступен"));
+ } else {
+ System.out.println(ZAnsi.brightRed("Fabric Meta вернул код " + response.statusCode()));
+ }
+ } catch (Exception e) {
+ System.out.println(ZAnsi.brightRed("Нет доступа к Fabric Meta"));
+ System.out.println("Ошибка: " + e.getMessage());
+ }
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/SettingsMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/SettingsMenu.java
new file mode 100644
index 0000000..7c16845
--- /dev/null
+++ b/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/src/main/java/me/sashegdev/zernmc/launcher/menu/UpdateMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/UpdateMenu.java
new file mode 100644
index 0000000..5e4f802
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/UpdateMenu.java
@@ -0,0 +1,36 @@
+package me.sashegdev.zernmc.launcher.menu;
+
+import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
+import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
+
+import java.io.IOException;
+import java.util.List;
+
+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) {
+ System.out.println("Проверка обновлений сборки...");
+ System.out.println("Дифф обновлений пока в заглушке (сборки ещё не загружены)");
+ System.out.println(" Эндпоинт: POST /pack/{pack_name}/diff");
+ } else {
+ System.out.println("Проверка обновлений лаунчера...");
+ System.out.println("Версия лаунчера актуальна (заглушка)");
+ }
+
+ ConsoleUtils.pause();
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Installer.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Installer.java
new file mode 100644
index 0000000..8b009ff
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Installer.java
@@ -0,0 +1,19 @@
+package me.sashegdev.zernmc.launcher.minecraft;
+
+import me.sashegdev.zernmc.launcher.utils.ZAnsi;
+
+public class Installer {
+
+ public static boolean installPack(String packName, Instance instance) {
+ System.out.println(ZAnsi.cyan("Начинается установка сборки: " + packName));
+
+ // TODO:
+ // 1. Получить манифест пака (/pack/{packName})
+ // 2. Запустить diff
+ // 3. Скачать недостающие файлы
+ // 4. Установить Minecraft + Loader (через MinecraftLib)
+
+ System.out.println(ZAnsi.yellow("Установка пока в разработке..."));
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java
new file mode 100644
index 0000000..a2de872
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java
@@ -0,0 +1,110 @@
+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;
+
+public class Instance {
+
+ private final String name;
+ private final Path path;
+
+ private String minecraftVersion;
+ private String loaderType; // vanilla, fabric, forge
+ private String loaderVersion;
+
+ 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();
+ }
+
+ @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("]");
+ } 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;
+ } catch (Exception e) {
+ // игнорируем, если файл повреждён
+ }
+ }
+
+ private void saveMetadata() {
+ Path metaFile = path.resolve("instance.json");
+ InstanceMeta meta = new InstanceMeta(minecraftVersion, loaderType, loaderVersion);
+
+ try {
+ Files.writeString(metaFile, GSON.toJson(meta));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ // Внутренний класс для сериализации
+ private static class InstanceMeta {
+ String minecraftVersion;
+ String loaderType;
+ String loaderVersion;
+
+ public InstanceMeta(String minecraftVersion, String loaderType, String loaderVersion) {
+ this.minecraftVersion = minecraftVersion;
+ this.loaderType = loaderType;
+ this.loaderVersion = loaderVersion;
+ }
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/InstanceManager.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/InstanceManager.java
new file mode 100644
index 0000000..8baa8a4
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/InstanceManager.java
@@ -0,0 +1,43 @@
+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 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/src/main/java/me/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java
new file mode 100644
index 0000000..fdb9b12
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java
@@ -0,0 +1,146 @@
+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.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.util.List;
+
+/**
+ * Главный фасад MinecraftLib — точка входа для всей логики установки и запуска
+ */
+public class MinecraftLib {
+
+ private final Instance instance;
+
+ public MinecraftLib(Instance instance) {
+ this.instance = instance;
+ }
+
+ // ====================== УСТАНОВКА ======================
+
+ /**
+ * Установить vanilla версию Minecraft
+ */
+ public boolean installMinecraft(String versionId) throws Exception {
+ VersionInstaller installer = new VersionInstaller(instance.getPath());
+ boolean success = installer.install(versionId);
+
+ if (success) {
+ instance.setMinecraftVersion(versionId);
+ instance.setLoaderType("vanilla");
+ }
+ return success;
+ }
+
+ public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception {
+ ForgeInstaller installer = new ForgeInstaller(instance);
+ return installer.install(minecraftVersion, forgeVersion);
+ }
+
+ /**
+ * Установить Fabric для выбранной версии Minecraft
+ */
+ 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)) {
+ System.out.println(ZAnsi.yellow("Forge пока не поддерживается"));
+ return false;
+ }
+
+ // 3. В будущем здесь будет diff и скачивание модов
+
+ System.out.println(ZAnsi.brightGreen("Базовая установка сборки завершена!"));
+ return true;
+ }
+
+ // ====================== ЗАПУСК ======================
+
+ /**
+ * Сгенерировать команду запуска (пока заглушка)
+ */
+ public List buildLaunchCommand(LaunchOptions options) throws Exception {
+ System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "..."));
+
+ // TODO: Полная реализация LaunchCommandBuilder (перенос из MLL)
+
+ System.out.println(ZAnsi.yellow("Генерация команды запуска пока в разработке"));
+ return List.of("java", "-jar", "placeholder.jar", "--version", instance.getName());
+ }
+
+ /**
+ * Запустить сборку
+ */
+ public void launch(LaunchOptions options) throws Exception {
+ System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
+
+ LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
+ List command = builder.build(options);
+
+ System.out.println(ZAnsi.cyan("Команда запуска (" + command.size() + " аргументов):"));
+ for (String arg : command) {
+ System.out.println(" " + arg);
+ }
+
+ // === Реальный запуск ===
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.directory(instance.getPath().toFile());
+
+ // Важно: перенаправляем вывод Minecraft в консоль лаунчера
+ 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(); // очищаем TUI перед запуском игры
+
+ Process process = pb.start();
+
+ // Ждём завершения игры
+ int exitCode = process.waitFor();
+
+ System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
+ }
+
+ // ====================== ГЕТТЕРЫ ======================
+
+ public Instance getInstance() {
+ return instance;
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java
new file mode 100644
index 0000000..4da0be8
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java
@@ -0,0 +1,134 @@
+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.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();
+
+ // Шаг 1: Установка vanilla версии (если ещё не установлена)
+ VersionInstaller versionInstaller = new VersionInstaller(instancePath);
+ boolean mcOk = versionInstaller.install(minecraftVersion);
+ if (!mcOk) {
+ System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion));
+ return false;
+ }
+
+ // Шаг 2: Скачивание и запуск Fabric Installer
+ 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");
+
+ ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%");
+ downloadFile(installerUrl, installerJar);
+ ProgressBar.finish("Fabric Installer скачан");
+
+ // Шаг 3: Запуск Fabric Installer
+ System.out.println(ZAnsi.cyan("Запуск Fabric Installer..."));
+
+ ProcessBuilder pb = new ProcessBuilder(
+ "java", "-jar", installerJar.toAbsolutePath().toString(),
+ "client",
+ "-dir", instancePath.toAbsolutePath().toString(),
+ "-mcversion", minecraftVersion,
+ "-loader", loaderVersion,
+ "-noprofile",
+ "-snapshot"
+ );
+
+ 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;
+ }
+
+ // Шаг 4: Проверка, что Fabric версия появилась
+ String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
+ Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId);
+
+ if (Files.exists(fabricVersionDir)) {
+ System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!"));
+ System.out.println("Версия: " + fabricVersionId);
+ return true;
+ } else {
+ System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена."));
+ return false;
+ }
+ }
+
+ 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().startsWith("0."))
+ .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 {
+ String url = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml";
+ String xml = downloadString(url);
+
+ int start = xml.indexOf("") + 8;
+ int end = xml.indexOf("", start);
+ return xml.substring(start, end).trim();
+ }
+
+ private String downloadString(String url) throws Exception {
+ HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
+ HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if (resp.statusCode() != 200) throw new IOException("HTTP " + resp.statusCode());
+ return resp.body();
+ }
+
+ private void downloadFile(String url, Path target) throws Exception {
+ HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
+ httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java
new file mode 100644
index 0000000..16c3dfd
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java
@@ -0,0 +1,115 @@
+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.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.nio.file.StandardOpenOption;
+
+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
+ System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
+ VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
+ boolean vanillaSuccess = vanillaInstaller.install(mcVersion);
+
+ if (!vanillaSuccess) {
+ System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
+ return false;
+ }
+
+ // Шаг 2: Создаём launcher_profiles.json (критично для Forge)
+ 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");
+
+ ProgressBar.show("Скачивание Forge Installer", 0, 100, "%");
+ downloadFile(installerUrl, installerJar);
+ ProgressBar.finish("Forge Installer скачан (" + ProgressBar.formatBytes(Files.size(installerJar)) + ")");
+
+ System.out.println(ZAnsi.cyan("Запуск Forge Installer..."));
+
+ ProcessBuilder pb = new ProcessBuilder(
+ "java",
+ "-jar",
+ installerJar.toAbsolutePath().toString(),
+ "--installClient"
+ );
+
+ pb.directory(instance.getPath().toFile());
+ 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("Forge Installer завершился с ошибкой (код " + exitCode + ")"));
+ return false;
+ }
+
+ System.out.println(ZAnsi.brightGreen("Forge " + forgeVersion + " успешно установлен!"));
+
+ instance.setMinecraftVersion(mcVersion);
+ instance.setLoaderType("forge");
+ instance.setLoaderVersion(forgeVersion);
+
+ return true;
+ }
+
+ /**
+ * Создаёт минимальный launcher_profiles.json — Forge без него отказывается работать
+ */
+ 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 downloadFile(String url, Path target) throws Exception {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
+
+ if (response.statusCode() != 200) {
+ throw new IOException("Не удалось скачать Forge installer (HTTP " + response.statusCode() + ")");
+ }
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java
new file mode 100644
index 0000000..73c3d65
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java
@@ -0,0 +1,206 @@
+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;
+
+public class VersionInstaller {
+
+ private final Path minecraftDir;
+ private final HttpClient httpClient;
+
+ public VersionInstaller(Path minecraftDir) {
+ this.minecraftDir = minecraftDir;
+ this.httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(30))
+ .build();
+ }
+
+ // getAvailableVersions() оставляем как было (с исправлением времени)
+
+ 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 boolean 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"));
+
+ // Ассеты
+ if (versionData.has("assetIndex")) {
+ System.out.println(ZAnsi.cyan("Скачивание ассетов..."));
+ downloadAssets(versionData);
+ }
+
+ System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " полностью установлен!"));
+ return true;
+ }
+
+ 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++;
+ if (count % 20 == 0) ProgressBar.show("Библиотеки", count, total, "");
+ }
+ ProgressBar.finish("Библиотеки загружены");
+ }
+
+ private void downloadAssets(JSONObject versionData) throws Exception {
+ JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex");
+ String indexUrl = assetIndexInfo.getString("url");
+ String indexId = versionData.getString("assets");
+
+ Path indexPath = minecraftDir.resolve("assets/indexes").resolve(indexId + ".json");
+ Files.createDirectories(indexPath.getParent());
+ downloadFile(indexUrl, indexPath, "asset index");
+
+ String assetsJson = new String(Files.readAllBytes(indexPath));
+ JSONObject objects = new JSONObject(assetsJson).getJSONObject("objects");
+
+ System.out.println(ZAnsi.cyan("Скачивание " + objects.length() + " объектов ассетов..."));
+
+ int count = 0;
+ int failed = 0;
+
+ for (String hash : objects.keySet()) {
+ 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());
+
+ try {
+ downloadFile(url, target, ""); // пустой label = ассет
+ count++;
+ } catch (Exception e) {
+ failed++;
+ }
+
+ ProgressBar.show("Ассеты", count, objects.length(), "файлов");
+ }
+
+ ProgressBar.finish("Ассеты загружены (" + count + " успешно, " + failed + " пропущено)");
+ }
+
+
+
+ // === Вспомогательные методы ===
+
+ 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 + "..."));
+ }
+
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
+
+ if (response.statusCode() != 200) {
+ // Для ассетов 404 — это нормально, просто пропускаем
+ if (label.isEmpty()) {
+ return; // тихий пропуск для ассетов
+ }
+ throw new IOException("HTTP " + response.statusCode());
+ }
+
+ if (!label.isEmpty()) {
+ long size = Files.size(target);
+ ProgressBar.finish(label + " (" + ProgressBar.formatBytes(size) + ")");
+ }
+
+ } catch (Exception e) {
+ if (!label.isEmpty()) {
+ // Для важных файлов (client.jar, библиотеки, index) — ошибка
+ throw e;
+ }
+ // Для ассетов — просто пропускаем молча
+ }
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java
new file mode 100644
index 0000000..912aa93
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java
@@ -0,0 +1,162 @@
+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 java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Генерирует полную команду запуска Minecraft (Vanilla / Fabric / Forge)
+ */
+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<>();
+
+ // 1. Путь к Java
+ String javaPath = getJavaPath();
+ command.add(javaPath);
+
+ // 2. JVM аргументы
+ command.addAll(getJvmArguments(options));
+
+ // 3. Natives
+ Path nativesDir = instance.getPath().resolve("natives");
+ command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
+
+ // 4. Classpath
+ String classpath = buildClasspath();
+ command.add("-cp");
+ command.add(classpath);
+
+ // 5. Главный класс
+ String mainClass = getMainClass();
+ command.add(mainClass);
+
+ // 6. Аргументы Minecraft
+ command.addAll(getMinecraftArguments(options));
+
+ return command;
+ }
+
+ private String getJavaPath() {
+ // Пока берём системную java. Позже можно добавить выбор из ~/.zernmc/jre/
+ return "java";
+ }
+
+ private List getJvmArguments(LaunchOptions options) {
+ List jvmArgs = new ArrayList<>();
+
+ // Выделенная память
+ int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 2048;
+ 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");
+
+ // Дополнительные JVM аргументы из настроек пользователя
+ if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
+ jvmArgs.addAll(options.getExtraJvmArgs());
+ }
+
+ return jvmArgs;
+ }
+
+ private String buildClasspath() throws Exception {
+ List paths = new ArrayList<>();
+
+ String versionId = getVersionId();
+ Path versionsDir = instance.getPath().resolve("versions");
+
+ // 1. Основной jar версии (fabric-loader-...-1.20.1.jar)
+ paths.add(versionsDir.resolve(versionId).resolve(versionId + ".jar").toAbsolutePath().toString());
+
+ // 2. Все библиотеки
+ 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);
+ }
+ }
+
+ // Для Windows используем ";" вместо ":"
+ String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
+ return String.join(separator, paths);
+ }
+
+ private String getMainClass() {
+ String loaderType = instance.getLoaderType().toLowerCase();
+
+ if ("fabric".equals(loaderType)) {
+ String loaderVer = instance.getLoaderVersion();
+ if (loaderVer != null && loaderVer.startsWith("0.9")) {
+ return "net.fabricmc.loader.impl.launch.knot.KnotClient";
+ } else {
+ // Для более новых версий Fabric (0.14+)
+ return "net.fabricmc.loader.impl.launch.knot.KnotClient";
+ }
+ }
+ else if ("forge".equals(loaderType)) {
+ return "net.minecraftforge.client.loading.ClientModLoader";
+ }
+ else {
+ return "net.minecraft.client.main.Main"; // Vanilla
+ }
+ }
+
+ private List getMinecraftArguments(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());
+
+ if (options.getUsername() != null) {
+ args.add("--username");
+ args.add(options.getUsername());
+ } else {
+ args.add("--username");
+ args.add("Player");
+ }
+
+ // Можно добавить --width, --height, --server и т.д. позже
+
+ return args;
+ }
+
+ private String getVersionId() {
+ if ("vanilla".equalsIgnoreCase(instance.getLoaderType())) {
+ return instance.getMinecraftVersion();
+ } else {
+ // Для Fabric/Forge версия выглядит как fabric-loader-... или forge-...
+ return instance.getMinecraftVersion() + "-" + instance.getLoaderType() + "-" + instance.getLoaderVersion();
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java
new file mode 100644
index 0000000..13677a3
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/LaunchOptions.java
@@ -0,0 +1,36 @@
+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<>();
+
+ // Геттеры и сеттеры
+ 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; }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/MinecraftVersion.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/model/MinecraftVersion.java
new file mode 100644
index 0000000..9e5dcc6
--- /dev/null
+++ b/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/src/main/java/me/sashegdev/zernmc/launcher/ui/ArrowMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/ArrowMenu.java
new file mode 100644
index 0000000..2d948b0
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/ui/ArrowMenu.java
@@ -0,0 +1,90 @@
+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 == 'Ц') { // Up
+ selected = (selected - 1 + options.size()) % options.size();
+ }
+ else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы') { // Down
+ selected = (selected + 1) % options.size();
+ }
+ else if (key == 13 || key == 10) { // Enter
+ return selected;
+ }
+ else if (key == 27) { // Esc
+ return -1;
+ }
+ }
+ } 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/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java
new file mode 100644
index 0000000..9c31311
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java
@@ -0,0 +1,123 @@
+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 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 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 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/src/main/java/me/sashegdev/zernmc/launcher/utils/ConsoleUtils.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ConsoleUtils.java
new file mode 100644
index 0000000..bde04d3
--- /dev/null
+++ b/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/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java
new file mode 100644
index 0000000..4ac8c30
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java
@@ -0,0 +1,38 @@
+package me.sashegdev.zernmc.launcher.utils;
+
+import java.util.Scanner;
+
+public class Input {
+
+ private static final Scanner scanner = new Scanner(System.in);
+
+ public static String readLine() {
+ return scanner.nextLine().trim();
+ }
+
+ public static String readLine(String prompt) {
+ 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));
+ }
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ProgressBar.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ProgressBar.java
new file mode 100644
index 0000000..3f35a24
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ProgressBar.java
@@ -0,0 +1,70 @@
+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 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/src/main/java/me/sashegdev/zernmc/launcher/utils/Version.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Version.java
new file mode 100644
index 0000000..ffb4534
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Version.java
@@ -0,0 +1,37 @@
+package me.sashegdev.zernmc.launcher.utils;
+
+public class Version {
+
+ public static String getCurrentVersion() {
+ String version = Version.class.getPackage().getImplementationVersion();
+ return (version != null && !version.isBlank()) ? version : "1.0.0";
+ }
+
+ /**
+ * Универсальное сравнение версий
+ * Возвращает true, если serverVersion новее currentVersion
+ */
+ public static boolean isNewer(String current, String server) {
+ if (current == null || server == null) return false;
+
+ // Убираем -SNAPSHOT для сравнения
+ current = current.replace("-SNAPSHOT", "").trim();
+ server = server.replace("-SNAPSHOT", "").trim();
+
+ if (current.equals(server)) return false;
+
+ String[] currentParts = current.split("\\.");
+ String[] serverParts = server.split("\\.");
+
+ int maxLength = Math.max(currentParts.length, serverParts.length);
+
+ for (int i = 0; i < maxLength; i++) {
+ int c = i < currentParts.length ? Integer.parseInt(currentParts[i]) : 0;
+ int s = i < serverParts.length ? Integer.parseInt(serverParts[i]) : 0;
+
+ if (s > c) return true;
+ if (s < c) return false;
+ }
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java
new file mode 100644
index 0000000..86e7b9c
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java
@@ -0,0 +1,80 @@
+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 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 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 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 selected(String text) {
+ return Ansi.ansi()
+ .bgBright(Ansi.Color.WHITE)
+ .fgBlack()
+ .a(" > " + text + " ")
+ .reset()
+ .toString();
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java
new file mode 100644
index 0000000..cb46b57
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java
@@ -0,0 +1,125 @@
+package me.sashegdev.zernmc.launcher.utils;
+
+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.ArrayList;
+import java.util.List;
+
+public class ZHttpClient {
+
+ private static final java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+
+ private static final String BASE_URL = "http://87.120.187.36:1582";
+
+ public static String get(String endpoint) throws IOException, InterruptedException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(BASE_URL + endpoint))
+ .timeout(Duration.ofSeconds(15))
+ .GET()
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("HTTP " + response.statusCode());
+ }
+
+ return response.body();
+ }
+
+ public static String getBaseUrl() {
+ return BASE_URL;
+ }
+
+ public static String getLauncherVersion() throws IOException, InterruptedException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(BASE_URL + "/launcher/version"))
+ .timeout(Duration.ofSeconds(10))
+ .GET()
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("HTTP " + response.statusCode());
+ }
+
+ return response.body();
+ }
+ public static String getLauncherVersionInfo() throws IOException, InterruptedException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(BASE_URL + "/launcher/version"))
+ .timeout(Duration.ofSeconds(10))
+ .GET()
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("HTTP " + response.statusCode());
+ }
+
+ return response.body();
+ }
+ /**
+ * Получить список всех доступных версий Fabric Loader
+ */
+ public static List getFabricLoaderVersions() throws IOException, InterruptedException {
+ String url = "https://meta.fabricmc.net/v2/versions/loader";
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .GET()
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("HTTP " + response.statusCode());
+ }
+
+ // Парсим JSON массив
+ org.json.JSONArray array = new org.json.JSONArray(response.body());
+ List versions = new ArrayList<>();
+
+ for (int i = 0; i < array.length(); i++) {
+ org.json.JSONObject obj = array.getJSONObject(i);
+ if (obj.has("version")) {
+ versions.add(obj.getString("version"));
+ }
+ }
+
+ versions.sort((a, b) -> {
+ // Правильная семантическая сортировка версий
+ String[] partsA = a.split("\\+")[0].split("\\.");
+ String[] partsB = b.split("\\+")[0].split("\\.");
+ for (int i = 0; i < Math.min(partsA.length, partsB.length); i++) {
+ int cmp = Integer.compare(Integer.parseInt(partsB[i]), Integer.parseInt(partsA[i]));
+ if (cmp != 0) return cmp;
+ }
+ return b.compareTo(a); // fallback
+ });
+
+ return versions;
+ }
+
+ public static String downloadString(String url) throws IOException, InterruptedException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .GET()
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("HTTP " + response.statusCode());
+ }
+ return response.body();
+ }
+}
\ No newline at end of file