diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java index c7ed1e3..836bc1f 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java @@ -66,10 +66,8 @@ public class Main { } private static void startCLI() throws IOException { - // Проверка всех сервисов при старте - ZHttpClient.checkAllServicesOnStartup(); + ZHttpClient.checkAllServicesOnStartup(true); - // === АВТОРИЗАЦИЯ (используем новый API) === System.out.println(ZAnsi.cyan("Проверка авторизации...")); var sessionResponse = api.checkSession(); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java index adbed6e..555a2d0 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java @@ -3,23 +3,14 @@ package me.sashegdev.zernmc.launcher.api; import me.sashegdev.zernmc.launcher.api.auth.AuthService; import me.sashegdev.zernmc.launcher.api.instance.InstanceService; import me.sashegdev.zernmc.launcher.api.launch.LaunchService; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; +import java.util.Map; -/** - * Центральный фасад для внутреннего API лаунчера. - * Используется как единая точка входа для UI и других компонентов. - */ public class LauncherAPI { - private static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "http://87.120.187.36:1582"); - private final AuthService authService; private final InstanceService instanceService; private final LaunchService launchService; @@ -82,115 +73,101 @@ public class LauncherAPI { public ApiResponse> getMCVersions() { try { - URL url = new URL(LAUNCHER_SERVER + "/launcher/minecraft-versions"); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setConnectTimeout(5000); - conn.setReadTimeout(10000); - if (conn.getResponseCode() == 200) { - try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = br.readLine()) != null) sb.append(line); - org.json.JSONArray arr = new org.json.JSONArray(sb.toString()); - List versions = new java.util.ArrayList<>(); - for (int i = 0; i < arr.length(); i++) { - versions.add(arr.getString(i)); - } - return ApiResponse.success(versions); - } + org.json.JSONObject manifest = ZHttpClient.getMojangVersionManifest(); + org.json.JSONArray versions = manifest.getJSONArray("versions"); + List mcVersions = new ArrayList<>(); + for (int i = 0; i < versions.length(); i++) { + mcVersions.add(versions.getJSONObject(i).getString("id")); } + return ApiResponse.success(mcVersions); } catch (Exception e) { - System.out.println("[API] MC versions fetch failed, using fallback: " + e.getMessage()); + System.out.println("[API] MC versions fetch failed: " + e.getMessage()); } - return ApiResponse.success(java.util.List.of("1.21", "1.20.6", "1.20.4", "1.20.1", "1.19.4", "1.18.2", "1.17.1", "1.16.5")); + return ApiResponse.error("Не удалось загрузить версии Minecraft"); } public ApiResponse> getLoaderVersions(String mcVersion, String loader) { try { - URL url = new URL(LAUNCHER_SERVER + "/launcher/loader-versions?mc=" + mcVersion + "&loader=" + loader.toLowerCase()); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setConnectTimeout(5000); - conn.setReadTimeout(10000); - if (conn.getResponseCode() == 200) { - try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = br.readLine()) != null) sb.append(line); - org.json.JSONArray arr = new org.json.JSONArray(sb.toString()); - List versions = new java.util.ArrayList<>(); - for (int i = 0; i < arr.length(); i++) { - versions.add(arr.getString(i)); + List versions = new ArrayList<>(); + + switch (loader.toLowerCase()) { + case "fabric": + versions = ZHttpClient.getFabricLoaderVersions(); + break; + case "forge": + String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"); + int idx = 0; + while ((idx = xml.indexOf("", idx)) != -1) { + int start = idx + 9; + int end = xml.indexOf("", start); + if (end == -1) break; + String fullVersion = xml.substring(start, end).trim(); + if (fullVersion.startsWith(mcVersion + "-")) { + versions.add(fullVersion.substring(mcVersion.length() + 1)); + } + idx = end; } - return ApiResponse.success(versions); - } + versions.sort((a, b) -> b.compareTo(a)); + break; + case "neoforge": + String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml"); + int neoidx = 0; + while ((neoidx = neoforgeXml.indexOf("", neoidx)) != -1) { + int start = neoidx + 9; + int end = neoforgeXml.indexOf("", start); + if (end == -1) break; + String fullVersion = neoforgeXml.substring(start, end).trim(); + if (isNeoForgeCompatible(fullVersion, mcVersion)) { + versions.add(fullVersion); + } + neoidx = end; + } + versions.sort((a, b) -> b.compareTo(a)); + break; + default: + break; } + return ApiResponse.success(versions); } catch (Exception e) { - System.out.println("[API] Loader versions fetch failed, using fallback: " + e.getMessage()); + System.out.println("[API] Loader versions fetch failed: " + e.getMessage()); + return ApiResponse.error("Не удалось загрузить версии лоадера"); } - - java.util.List versions = new java.util.ArrayList<>(); - switch (loader.toLowerCase()) { - case "fabric": - versions.add("0.15.11"); - versions.add("0.15.9"); - versions.add("0.15.8"); - versions.add("0.14.21"); - break; - case "forge": - versions.add("47.1.0"); - versions.add("46.0.1"); - versions.add("45.0.2"); - break; - case "neoforge": - versions.add("1.21-rc.2"); - versions.add("1.20.4-rc.4"); - break; - default: - break; + } + + private boolean isNeoForgeCompatible(String version, String mcVersion) { + if (mcVersion.startsWith("1.21")) { + return version.contains("1.21") && !version.contains("1.20"); + } else if (mcVersion.startsWith("1.20") && !mcVersion.equals("1.20")) { + return version.contains("1.20.4") || version.contains("1.20.5") || version.contains("1.20.6"); } - return ApiResponse.success(versions); + return false; } - public ApiResponse>> getZernMCPacks() { + public ApiResponse>> getZernMCPacks() { try { String token = authService.getCurrentToken(); if (token == null) { return ApiResponse.error("Не авторизован"); } - URL url = new URL(LAUNCHER_SERVER + "/packs"); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setConnectTimeout(5000); - conn.setReadTimeout(10000); - conn.setRequestProperty("Authorization", "Bearer " + token); - - if (conn.getResponseCode() == 200) { - try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = br.readLine()) != null) sb.append(line); - org.json.JSONArray arr = new org.json.JSONArray(sb.toString()); - List> packs = new java.util.ArrayList<>(); - for (int i = 0; i < arr.length(); i++) { - org.json.JSONObject pack = arr.getJSONObject(i); - java.util.Map packInfo = new java.util.HashMap<>(); - packInfo.put("name", pack.optString("name", "")); - packInfo.put("displayName", pack.optString("displayName", pack.optString("name", ""))); - packInfo.put("version", pack.optString("version", "")); - packInfo.put("mcVersion", pack.optString("mcVersion", "")); - packInfo.put("loader", pack.optString("loader", "vanilla")); - packInfo.put("description", pack.optString("description", "")); - packs.add(packInfo); - } - return ApiResponse.success(packs); - } - } else if (conn.getResponseCode() == 403) { - return ApiResponse.error("Требуется проходка"); + String response = ZHttpClient.get("/packs"); + org.json.JSONArray arr = new org.json.JSONArray(response); + List> packs = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) { + org.json.JSONObject pack = arr.getJSONObject(i); + Map packInfo = new java.util.HashMap<>(); + packInfo.put("name", pack.optString("name", "")); + packInfo.put("displayName", pack.optString("displayName", pack.optString("name", ""))); + packInfo.put("version", pack.optString("version", "")); + packInfo.put("mcVersion", pack.optString("mcVersion", "")); + packInfo.put("loader", pack.optString("loader", "vanilla")); + packInfo.put("description", pack.optString("description", "")); + packs.add(packInfo); } + return ApiResponse.success(packs); } catch (Exception e) { System.out.println("[API] Packs fetch failed: " + e.getMessage()); return ApiResponse.error("Ошибка загрузки сборок: " + e.getMessage()); } - return ApiResponse.success(java.util.List.of()); } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java index 919e22f..fe77cd5 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java @@ -12,9 +12,24 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; public class LaunchService { + + private static final ConcurrentHashMap runningProcesses = new ConcurrentHashMap<>(); + + static { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("[LAUNCH] Shutting down all running processes..."); + runningProcesses.values().forEach(p -> { + try { + p.destroy(); + } catch (Exception ignored) {} + }); + })); + } public ApiResponse prepareLaunch(String instanceName) { try { @@ -49,7 +64,6 @@ public class LaunchService { LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchOptions options = new LaunchOptions(); - // Set auth info options.setUsername(AuthManager.getUsername()); options.setAccessToken(AuthManager.getAccessToken()); options.setUuid(AuthManager.getUuid()); @@ -61,27 +75,60 @@ public class LaunchService { ProcessBuilder processBuilder = new ProcessBuilder(command); processBuilder.directory(instance.getPath().toFile()); processBuilder.redirectErrorStream(true); - // Не перехватываем вывод игры - пусть выводится напрямую в консоль - // Инициализируем лог файл для игры, но не дублируем вывод + Path logsDir = instance.getPath().resolve("logs"); java.nio.file.Files.createDirectories(logsDir); Path gameLog = logsDir.resolve("game.log"); - processBuilder.redirectOutput(ProcessBuilder.Redirect.to(gameLog.toFile())); - + Process process = processBuilder.start(); - System.out.println("[LAUNCH] Process started, pid=" + process.pid()); + long pid = process.pid(); + + runningProcesses.put(pid, process); + System.out.println("[LAUNCH] Process started, pid=" + pid); - ProcessInfo info = new ProcessInfo( - instanceName, - process.pid(), - "RUNNING" - ); + java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true); + + Thread logReader = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line; + JFXLauncher.appendGameLog(line); + try { + logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + logFileOut.flush(); + } catch (Exception ignored) {} + } + } catch (Exception e) { + JFXLauncher.appendGameLog("[Ошибка чтения логов: " + e.getMessage() + "]"); + } finally { + try { logFileOut.close(); } catch (Exception ignored) {} + } + }, "GameLogReader-" + instanceName); + logReader.setDaemon(true); + logReader.start(); + + process.onExit().thenRun(() -> { + runningProcesses.remove(pid); + JFXLauncher.appendGameLog("[Minecraft завершился с кодом: " + process.exitValue() + "]"); + }); + + ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING"); return ApiResponse.success(info); } catch (Exception e) { return ApiResponse.error("Ошибка запуска: " + e.getMessage()); } } + public static void killAllProcesses() { + runningProcesses.values().forEach(p -> { + try { + p.destroyForcibly(); + } catch (Exception ignored) {} + }); + runningProcesses.clear(); + } + public ApiResponse isReady(String instanceName) { try { Instance instance = InstanceManager.getInstance(instanceName); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index d075e58..3568cfb 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -1,6 +1,7 @@ package me.sashegdev.zernmc.launcher.ui.jfx; import javafx.application.Application; +import javafx.application.Platform; import javafx.scene.Scene; import javafx.scene.web.WebView; import javafx.scene.web.WebEngine; @@ -8,6 +9,7 @@ import javafx.stage.Stage; import javafx.concurrent.Worker; import com.google.gson.Gson; import me.sashegdev.zernmc.launcher.api.LauncherAPI; +import me.sashegdev.zernmc.launcher.api.launch.LaunchService; import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; @@ -43,11 +45,43 @@ public class JFXLauncher extends Application { private final LauncherAPI api = new LauncherAPI(); private final Gson gson = new Gson(); private HttpServer server; - private StringBuilder logBuffer = new StringBuilder(); + private static StringBuilder launcherLogBuffer = new StringBuilder(); private static StringBuilder gameLogBuffer = new StringBuilder(); private static Path gameLogFile; private Stage mainStage; - + + private static final java.util.concurrent.CopyOnWriteArrayList logConsumers = new java.util.concurrent.CopyOnWriteArrayList<>(); + private static final java.util.concurrent.CopyOnWriteArrayList gameLogConsumers = new java.util.concurrent.CopyOnWriteArrayList<>(); + + public interface LogConsumer { + void onLog(String line); + } + + public static void addLogConsumer(LogConsumer consumer) { + logConsumers.add(consumer); + } + + public static void removeLogConsumer(LogConsumer consumer) { + logConsumers.remove(consumer); + } + + public static void addGameLogConsumer(LogConsumer consumer) { + gameLogConsumers.add(consumer); + } + + public static void removeGameLogConsumer(LogConsumer consumer) { + gameLogConsumers.remove(consumer); + } + + public static void appendLauncherLog(String log) { + synchronized (launcherLogBuffer) { + launcherLogBuffer.append(log).append("\n"); + } + for (LogConsumer consumer : logConsumers) { + try { consumer.onLog(log); } catch (Exception ignored) {} + } + } + public static void appendGameLog(String log) { synchronized (gameLogBuffer) { gameLogBuffer.append(log).append("\n"); @@ -60,8 +94,11 @@ public class JFXLauncher extends Application { } catch (Exception ignored) {} } } + for (LogConsumer consumer : gameLogConsumers) { + try { consumer.onLog(log); } catch (Exception ignored) {} + } } - + public static void initGameLog(Path instanceDir) { synchronized (gameLogBuffer) { gameLogBuffer.setLength(0); @@ -74,14 +111,30 @@ public class JFXLauncher extends Application { StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } catch (Exception ignored) {} } + + public static void clearGameLog() { + synchronized (gameLogBuffer) { + gameLogBuffer.setLength(0); + } + } public static String getGameLogs() { synchronized (gameLogBuffer) { return gameLogBuffer.toString(); } } - + + public static String getLauncherLogs() { + synchronized (launcherLogBuffer) { + return launcherLogBuffer.toString(); + } + } + public static void main(String[] args) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("[JFX] Shutdown hook triggered"); + LaunchService.killAllProcesses(); + })); launch(args); } @@ -239,7 +292,8 @@ public class JFXLauncher extends Application { stage.setOnCloseRequest(e -> { log("Закрытие..."); - stopServer(); + LaunchService.killAllProcesses(); + if (server != null) server.stop(1); }); } catch (Exception e) { @@ -260,9 +314,11 @@ public class JFXLauncher extends Application { server.createContext("/api/logs", this::handleLogs); server.createContext("/api/logs/stream", this::handleLogsStream); server.createContext("/api/game-logs", this::handleGameLogs); + server.createContext("/api/game-logs/stream", this::handleGameLogsStream); server.createContext("/api/mc-versions", this::handleMCVersions); server.createContext("/api/loader-versions", this::handleLoaderVersions); server.createContext("/api/packs", this::handlePacks); + server.createContext("/api/shutdown", this::handleShutdown); server.createContext("/api/exit", this::handleExit); server.createContext("/assets/", this::handleStatic); @@ -394,7 +450,7 @@ public class JFXLauncher extends Application { } private void handleLogs(HttpExchange exchange) { - sendJson(exchange, Map.of("success", true, "data", logBuffer.toString())); + sendJson(exchange, Map.of("success", true, "data", getLauncherLogs())); } private void handleLogsStream(HttpExchange exchange) { @@ -404,46 +460,75 @@ public class JFXLauncher extends Application { exchange.getResponseHeaders().set("Connection", "keep-alive"); exchange.sendResponseHeaders(200, 0); - String lastLog = ""; - int sameCount = 0; final OutputStream os = exchange.getResponseBody(); + int[] lastLength = {getLauncherLogs().length()}; - Thread.currentThread().setName("LogStream-" + System.currentTimeMillis()); - - for (int i = 0; !Thread.currentThread().isInterrupted() && i < 3000; i++) { - String currentLog = logBuffer.toString(); - if (!currentLog.equals(lastLog)) { - String newContent = currentLog.substring(lastLog.length()); - if (!newContent.isEmpty()) { - for (String line : newContent.split("\n")) { - if (!line.trim().isEmpty()) { - String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n"; - os.write(data.getBytes(StandardCharsets.UTF_8)); - os.flush(); - } - } - } - lastLog = currentLog; - sameCount = 0; - } else { - sameCount++; - if (sameCount > 50) { - os.write("data: \n\n".getBytes(StandardCharsets.UTF_8)); + LogConsumer consumer = new LogConsumer() { + @Override + public synchronized void onLog(String line) { + try { + String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n"; + os.write(data.getBytes(StandardCharsets.UTF_8)); os.flush(); - sameCount = 0; + } catch (Exception e) { + removeLogConsumer(this); } } - Thread.sleep(100); + }; + + addLogConsumer(consumer); + + while (!Thread.currentThread().isInterrupted()) { + Thread.sleep(10000); } } catch (Exception ignored) {} finally { + removeLogConsumer(consumer); try { exchange.getResponseBody().close(); } catch (Exception ignored) {} } } + private LogConsumer consumer = null; + private void handleGameLogs(HttpExchange exchange) { sendJson(exchange, Map.of("success", true, "data", getGameLogs())); } + private void handleGameLogsStream(HttpExchange exchange) { + try { + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.getResponseHeaders().set("Cache-Control", "no-cache"); + exchange.getResponseHeaders().set("Connection", "keep-alive"); + exchange.sendResponseHeaders(200, 0); + + final OutputStream os = exchange.getResponseBody(); + + consumer = new LogConsumer() { + @Override + public synchronized void onLog(String line) { + try { + String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n"; + os.write(data.getBytes(StandardCharsets.UTF_8)); + os.flush(); + } catch (Exception e) { + removeGameLogConsumer(this); + } + } + }; + + addGameLogConsumer(consumer); + + while (!Thread.currentThread().isInterrupted()) { + Thread.sleep(10000); + } + } catch (Exception ignored) {} finally { + if (consumer != null) { + removeGameLogConsumer(consumer); + } + consumer = null; + try { exchange.getResponseBody().close(); } catch (Exception ignored) {} + } + } + private void handleMCVersions(HttpExchange exchange) { try { var versions = api.getMCVersions(); @@ -500,9 +585,19 @@ public class JFXLauncher extends Application { return params; } + private void handleShutdown(HttpExchange exchange) { + log("Shutdown request received..."); + LaunchService.killAllProcesses(); + if (server != null) server.stop(1); + Platform.exit(); + System.exit(0); + } + private void handleExit(HttpExchange exchange) { log("Выход..."); + LaunchService.killAllProcesses(); if (mainStage != null) mainStage.close(); + Platform.exit(); System.exit(0); } @@ -563,7 +658,12 @@ public class JFXLauncher extends Application { private void log(String msg) { String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n"; - logBuffer.append(entry); + synchronized (launcherLogBuffer) { + launcherLogBuffer.append(entry); + } System.out.println("[JFX] " + msg); + for (LogConsumer consumer : logConsumers) { + try { consumer.onLog(entry); } catch (Exception ignored) {} + } } } \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java index bf4e848..d3ee948 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -96,10 +96,12 @@ public class ZHttpClient { * Вызывать один раз при запуске лаунчера */ public static void checkAllServicesOnStartup() { + checkAllServicesOnStartup(false); + } + + public static void checkAllServicesOnStartup(boolean verbose) { if (proxyTested.get()) return; - System.out.println(ZAnsi.cyan("Проверка доступности сервисов...")); - List servicesToCheck = List.of( ServiceType.ZERN_SERVER, ServiceType.GOOGLE, @@ -116,14 +118,20 @@ public class ZHttpClient { serviceHealthy.put(service, isHealthy); if (service.isAlwaysDirect()) { - System.out.println(isHealthy ? - ZAnsi.green(" " + service.name() + " - OK") : - ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); + if (verbose) { + System.out.println(isHealthy ? + ZAnsi.green(" " + service.name() + " - OK") : + ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); + } } else { if (isHealthy) { - System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает")); + if (verbose) { + System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает")); + } } else { - System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси")); + if (verbose) { + System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси")); + } serviceProxyMode.put(service, true); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); } @@ -131,12 +139,16 @@ public class ZHttpClient { } if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { - System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!")); + if (verbose) { + System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!")); + } } proxyTested.set(true); - startHealthCheckThread(); - printStats(); + if (verbose) { + startHealthCheckThread(); + printStats(); + } } /** diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html index 7be66c5..d3dbd11 100644 --- a/launcher/launcher/src/resources/ui/index.html +++ b/launcher/launcher/src/resources/ui/index.html @@ -99,6 +99,12 @@ + @@ -159,24 +165,38 @@
- +
+ + +
- +
+ + +
diff --git a/launcher/launcher/src/resources/ui/launcher.js b/launcher/launcher/src/resources/ui/launcher.js index d6a12d1..01425cf 100644 --- a/launcher/launcher/src/resources/ui/launcher.js +++ b/launcher/launcher/src/resources/ui/launcher.js @@ -1,5 +1,6 @@ const API_BASE = '/api'; let consoleEventSource = null; +let gameLogEventSource = null; class LauncherApp { constructor() { @@ -17,9 +18,114 @@ class LauncherApp { async init() { this.bindEvents(); this.initGridAnimation(); + this.initDropdowns(); await this.checkAuth(); } + initDropdowns() { + document.querySelectorAll('.custom-dropdown').forEach(dropdown => { + const trigger = dropdown.querySelector('.dropdown-trigger'); + const list = dropdown.querySelector('.dropdown-list'); + let selectedValue = null; + + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelectorAll('.custom-dropdown .dropdown-list.open').forEach(d => { + if (d !== list) d.classList.remove('open'); + }); + list.classList.toggle('open'); + trigger.classList.toggle('active'); + }); + + list.addEventListener('click', (e) => { + const item = e.target.closest('.dropdown-item'); + if (item) { + const value = item.dataset.value; + const text = item.textContent; + + list.querySelectorAll('.dropdown-item').forEach(i => i.classList.remove('selected')); + item.classList.add('selected'); + + trigger.querySelector('.dropdown-value').textContent = text; + trigger.dataset.value = value; + list.classList.remove('open'); + trigger.classList.remove('active'); + + dropdown.dispatchEvent(new CustomEvent('change', { detail: { value, text } })); + } + }); + }); + + document.addEventListener('click', () => { + document.querySelectorAll('.custom-dropdown .dropdown-list.open').forEach(list => { + list.classList.remove('open'); + }); + document.querySelectorAll('.custom-dropdown .dropdown-trigger.active').forEach(trigger => { + trigger.classList.remove('active'); + }); + }); + } + + populateDropdown(id, items) { + const dropdown = document.getElementById(id); + if (!dropdown) return; + + const list = dropdown.querySelector('.dropdown-list'); + list.innerHTML = ''; + + items.forEach(item => { + const div = document.createElement('div'); + div.className = 'dropdown-item'; + div.dataset.value = item.value || item; + div.textContent = item.label || item; + list.appendChild(div); + }); + } + + selectDropdownItem(id, value) { + const dropdown = document.getElementById(id); + if (!dropdown) return; + + const items = dropdown.querySelectorAll('.dropdown-item'); + items.forEach(item => { + if (item.dataset.value === value) { + item.click(); + } + }); + } + + getDropdownValue(id) { + const dropdown = document.getElementById(id); + if (!dropdown) return null; + return dropdown.querySelector('.dropdown-trigger').dataset.value; + } + + startGameLogStream() { + if (gameLogEventSource) { + gameLogEventSource.close(); + } + + gameLogEventSource = new EventSource(API_BASE + '/game-logs/stream'); + + gameLogEventSource.onmessage = (event) => { + if (event.data && event.data.trim()) { + this.addLog(event.data, this.getLogType(event.data)); + } + }; + + gameLogEventSource.onerror = () => { + gameLogEventSource.close(); + setTimeout(() => this.startGameLogStream(), 5000); + }; + } + + stopGameLogStream() { + if (gameLogEventSource) { + gameLogEventSource.close(); + gameLogEventSource = null; + } + } + startConsoleLogStream() { if (consoleEventSource) { consoleEventSource.close(); @@ -86,8 +192,8 @@ class LauncherApp { this.clearLogs(); }); - document.getElementById('loader-select').addEventListener('change', (e) => { - this.onLoaderChange(e.target.value); + document.getElementById('loader-dropdown').addEventListener('change', (e) => { + this.onLoaderChange(e.detail.value); }); document.getElementById('install-zernmc-btn').addEventListener('click', () => { @@ -172,7 +278,9 @@ class LauncherApp { this.account = result.data; this.username = result.data.username; this.showMainScreen(); + this.renderCurrentInstance(); this.startConsoleLogStream(); + this.startGameLogStream(); await this.loadInstances(); } else { this.showLoginScreen(); @@ -212,15 +320,35 @@ class LauncherApp { this.account = result.data; this.username = result.data.username; this.showMainScreen(); + this.renderCurrentInstance(); this.startConsoleLogStream(); + this.startGameLogStream(); await this.loadInstances(); } else { this.showError(result.error || 'Ошибка входа'); } } + renderCurrentInstance() { + if (!this.account) return; + + document.getElementById('username-display').textContent = this.account.username; + const statusEl = document.getElementById('account-status'); + statusEl.textContent = this.account.passActive ? 'PRO' : 'FREE'; + statusEl.className = 'badge ' + (this.account.passActive ? 'active' : 'inactive'); + + const roleEl = document.getElementById('account-role'); + if (this.account.roleName) { + roleEl.textContent = this.account.roleName; + roleEl.style.display = 'inline-block'; + } else { + roleEl.style.display = 'none'; + } + } + async handleLogout() { this.stopConsoleLogStream(); + this.stopGameLogStream(); this.username = null; this.account = null; this.currentInstance = null; @@ -229,6 +357,11 @@ class LauncherApp { this.addLog('Вы вышли из аккаунта', 'info'); } + async shutdownLauncher() { + const result = await this.request('/shutdown', { method: 'POST' }); + window.close(); + } + showError(message) { const errorEl = document.getElementById('login-error'); errorEl.textContent = message; @@ -331,42 +464,20 @@ class LauncherApp { } renderCurrentInstance(instance) { - const container = document.getElementById('current-instance'); - let version = instance.version || 'Vanilla'; - if (instance.isServerPack) { - version = `v${instance.serverVersion}`; + if (!this.account) return; + + document.getElementById('username-display').textContent = this.account.username; + const statusEl = document.getElementById('account-status'); + statusEl.textContent = this.account.passActive ? 'PRO' : 'FREE'; + statusEl.className = 'badge ' + (this.account.passActive ? 'active' : 'inactive'); + + const roleEl = document.getElementById('account-role'); + if (this.account.roleName) { + roleEl.textContent = this.account.roleName; + roleEl.style.display = 'inline-block'; + } else { + roleEl.style.display = 'none'; } - container.innerHTML = ` -
- ${this.escapeHtml(instance.name)} - ${this.escapeHtml(version)} -
- `; - - if (this.account) { - document.getElementById('username-display').textContent = this.account.username; - const statusEl = document.getElementById('account-status'); - statusEl.textContent = this.account.passActive ? 'PRO' : 'FREE'; - statusEl.className = 'badge ' + (this.account.passActive ? 'active' : 'inactive'); - - const roleEl = document.getElementById('account-role'); - if (this.account.roleName) { - roleEl.textContent = this.account.roleName; - roleEl.style.display = 'inline-block'; - } else { - roleEl.style.display = 'none'; - } - } - } - - renderNoInstance() { - const container = document.getElementById('current-instance'); - container.innerHTML = ` -
- Нет сборки - Нажмите скачать -
- `; } async launchInstance() { @@ -394,36 +505,32 @@ class LauncherApp { } async loadDownloadModalData() { - const mcSelect = document.getElementById('mc-version-select'); - mcSelect.innerHTML = ''; + this.populateDropdown('mc-version-dropdown', [{ value: '', label: 'Загрузка...' }]); const mcResult = await this.request('/mc-versions'); if (mcResult.success && mcResult.data) { - mcSelect.innerHTML = ''; - mcResult.data.forEach(v => { - const opt = document.createElement('option'); - opt.value = v; - opt.textContent = v; - mcSelect.appendChild(opt); - }); + const items = mcResult.data.map(v => ({ value: v, label: v })); + this.populateDropdown('mc-version-dropdown', items); + } else { + this.populateDropdown('mc-version-dropdown', [{ value: '', label: 'Ошибка загрузки' }]); } - const packSelect = document.getElementById('zernmc-pack-select'); - packSelect.innerHTML = ''; + const zernmcSelect = document.getElementById('zernmc-pack-select'); + zernmcSelect.innerHTML = ''; const packResult = await this.request('/packs'); if (packResult.success && packResult.data && packResult.data.length > 0) { - packSelect.innerHTML = ''; + zernmcSelect.innerHTML = ''; packResult.data.forEach(p => { const opt = document.createElement('option'); opt.value = p.name; opt.textContent = p.displayName + ' (' + p.version + ')'; - packSelect.appendChild(opt); + zernmcSelect.appendChild(opt); }); } else if (packResult.error && packResult.error.includes('проходка')) { - packSelect.innerHTML = ''; + zernmcSelect.innerHTML = ''; } else { - packSelect.innerHTML = ''; + zernmcSelect.innerHTML = ''; } const zernmcTab = document.querySelector('[data-tab="zernmc"]'); @@ -491,9 +598,9 @@ class LauncherApp { } async installVanilla() { - const mcVersion = document.getElementById('mc-version-select').value; - const loader = document.getElementById('loader-select').value; - const loaderVersion = document.getElementById('loader-version-select').value; + const mcVersion = this.getDropdownValue('mc-version-dropdown'); + const loader = this.getDropdownValue('loader-dropdown'); + const loaderVersion = this.getDropdownValue('loader-version-dropdown'); const instanceName = document.getElementById('vanilla-instance-name').value; if (!mcVersion) { @@ -536,24 +643,21 @@ class LauncherApp { async onLoaderChange(loader) { const loaderVersionGroup = document.getElementById('loader-version-group'); - const loaderVersionSelect = document.getElementById('loader-version-select'); - const mcVersion = document.getElementById('mc-version-select').value; + const mcVersion = this.getDropdownValue('mc-version-dropdown'); if (loader === 'vanilla') { loaderVersionGroup.classList.add('hidden'); } else { loaderVersionGroup.classList.remove('hidden'); + this.populateDropdown('loader-version-dropdown', [{ value: '', label: 'Загрузка...' }]); + if (mcVersion) { - loaderVersionSelect.innerHTML = ''; const result = await this.request('/loader-versions?mc=' + mcVersion + '&loader=' + loader); if (result.success && result.data) { - loaderVersionSelect.innerHTML = ''; - result.data.forEach(v => { - const opt = document.createElement('option'); - opt.value = v; - opt.textContent = v; - loaderVersionSelect.appendChild(opt); - }); + const items = result.data.map(v => ({ value: v, label: v })); + this.populateDropdown('loader-version-dropdown', items); + } else { + this.populateDropdown('loader-version-dropdown', [{ value: '', label: 'Ошибка загрузки' }]); } } } diff --git a/launcher/launcher/src/resources/ui/style.css b/launcher/launcher/src/resources/ui/style.css index ac8d7e8..70f509a 100644 --- a/launcher/launcher/src/resources/ui/style.css +++ b/launcher/launcher/src/resources/ui/style.css @@ -490,6 +490,10 @@ body { color: var(--error); } +.btn-logout#close-btn:hover { + background: rgba(239, 68, 68, 0.2); +} + /* Main Content - Logs */ .main-content { display: flex; @@ -899,4 +903,110 @@ body { ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); +} + +/* ==================== CUSTOM DROPDOWN ==================== */ +.custom-dropdown { + position: relative; + width: 100%; +} + +.dropdown-trigger { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition-fast); +} + +.dropdown-trigger:hover { + border-color: var(--accent-primary); +} + +.dropdown-trigger.active { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.dropdown-value { + font-size: 14px; + color: var(--text-primary); +} + +.dropdown-arrow { + font-size: 10px; + color: var(--text-muted); + transition: transform var(--transition-fast); +} + +.dropdown-trigger.active .dropdown-arrow { + transform: rotate(180deg); +} + +.dropdown-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + max-height: 240px; + overflow-y: auto; + z-index: 100; + display: none; + box-shadow: var(--shadow-card); +} + +.dropdown-list.open { + display: block; + animation: slideUp var(--transition-fast) forwards; +} + +.dropdown-item { + padding: 12px 14px; + font-size: 14px; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition-fast); + border-bottom: 1px solid var(--border-color); +} + +.dropdown-item:last-child { + border-bottom: none; +} + +.dropdown-item:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.dropdown-item.selected { + background: rgba(233, 69, 96, 0.15); + color: var(--accent-primary); +} + +.dropdown-search { + padding: 10px; + border-bottom: 1px solid var(--border-color); +} + +.dropdown-search input { + width: 100%; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 14px; +} + +.dropdown-search input:focus { + outline: none; + border-color: var(--accent-primary); } \ No newline at end of file diff --git a/server/main.py b/server/main.py index 0840c65..1673982 100644 --- a/server/main.py +++ b/server/main.py @@ -822,20 +822,13 @@ async def activate_pass_page(): # ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ====================== @app.get("/packs") -async def list_packs(request: Request): - """List all available packs - requires auth or master key for mirrors""" - # Check for master key - master_key = request.headers.get("X-Master-Key") - if master_key == MASTER_KEY: - # Master key - allow access - pass - else: - # Normal auth required - current_user = await get_current_user(request) - if not current_user: - raise HTTPException(401, "Authentication required") - if not has_permission(current_user["role"], Permissions.VIEW_PACKS): - raise HTTPException(403, "Requires active pass") +async def list_packs( + request: Request, + current_user: dict = Depends(get_current_user) +): + """List all available packs - requires auth""" + if not has_permission(current_user["role"], Permissions.VIEW_PACKS): + raise HTTPException(403, "Requires active pass") packs = []