попытка оптимизации и ДЖЛЫВОСШФРСЖДЛВОФЖДЛОВСМДЖЛФ ИНТЕРФЕЙС ФИКСЕСССС БЛЯЯЯ

This commit is contained in:
SashegDev
2026-05-10 01:24:47 +00:00
parent 389280f7f1
commit 2c670b1103
9 changed files with 605 additions and 244 deletions
@@ -66,10 +66,8 @@ public class Main {
} }
private static void startCLI() throws IOException { private static void startCLI() throws IOException {
// Проверка всех сервисов при старте ZHttpClient.checkAllServicesOnStartup(true);
ZHttpClient.checkAllServicesOnStartup();
// === АВТОРИЗАЦИЯ (используем новый API) ===
System.out.println(ZAnsi.cyan("Проверка авторизации...")); System.out.println(ZAnsi.cyan("Проверка авторизации..."));
var sessionResponse = api.checkSession(); var sessionResponse = api.checkSession();
@@ -3,23 +3,14 @@ package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.auth.AuthService; import me.sashegdev.zernmc.launcher.api.auth.AuthService;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService; import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService; import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.BufferedReader; import java.util.ArrayList;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.Map;
/**
* Центральный фасад для внутреннего API лаунчера.
* Используется как единая точка входа для UI и других компонентов.
*/
public class LauncherAPI { 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 AuthService authService;
private final InstanceService instanceService; private final InstanceService instanceService;
private final LaunchService launchService; private final LaunchService launchService;
@@ -82,115 +73,101 @@ public class LauncherAPI {
public ApiResponse<List<String>> getMCVersions() { public ApiResponse<List<String>> getMCVersions() {
try { try {
URL url = new URL(LAUNCHER_SERVER + "/launcher/minecraft-versions"); org.json.JSONObject manifest = ZHttpClient.getMojangVersionManifest();
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); org.json.JSONArray versions = manifest.getJSONArray("versions");
conn.setConnectTimeout(5000); List<String> mcVersions = new ArrayList<>();
conn.setReadTimeout(10000); for (int i = 0; i < versions.length(); i++) {
if (conn.getResponseCode() == 200) { mcVersions.add(versions.getJSONObject(i).getString("id"));
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<String> versions = new java.util.ArrayList<>();
for (int i = 0; i < arr.length(); i++) {
versions.add(arr.getString(i));
}
return ApiResponse.success(versions);
}
} }
return ApiResponse.success(mcVersions);
} catch (Exception e) { } 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<List<String>> getLoaderVersions(String mcVersion, String loader) { public ApiResponse<List<String>> getLoaderVersions(String mcVersion, String loader) {
try { try {
URL url = new URL(LAUNCHER_SERVER + "/launcher/loader-versions?mc=" + mcVersion + "&loader=" + loader.toLowerCase()); List<String> versions = new ArrayList<>();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000); switch (loader.toLowerCase()) {
conn.setReadTimeout(10000); case "fabric":
if (conn.getResponseCode() == 200) { versions = ZHttpClient.getFabricLoaderVersions();
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { break;
StringBuilder sb = new StringBuilder(); case "forge":
String line; String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
while ((line = br.readLine()) != null) sb.append(line); int idx = 0;
org.json.JSONArray arr = new org.json.JSONArray(sb.toString()); while ((idx = xml.indexOf("<version>", idx)) != -1) {
List<String> versions = new java.util.ArrayList<>(); int start = idx + 9;
for (int i = 0; i < arr.length(); i++) { int end = xml.indexOf("</version>", start);
versions.add(arr.getString(i)); 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("<version>", neoidx)) != -1) {
int start = neoidx + 9;
int end = neoforgeXml.indexOf("</version>", start);
if (end == -1) break;
String fullVersion = neoforgeXml.substring(start, end).trim();
if (isNeoForgeCompatible(fullVersion, mcVersion)) {
versions.add(fullVersion);
}
neoidx = end;
}
versions.sort((a, b) -> b.compareTo(a));
break;
default:
break;
} }
return ApiResponse.success(versions);
} catch (Exception e) { } 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<String> versions = new java.util.ArrayList<>();
switch (loader.toLowerCase()) { private boolean isNeoForgeCompatible(String version, String mcVersion) {
case "fabric": if (mcVersion.startsWith("1.21")) {
versions.add("0.15.11"); return version.contains("1.21") && !version.contains("1.20");
versions.add("0.15.9"); } else if (mcVersion.startsWith("1.20") && !mcVersion.equals("1.20")) {
versions.add("0.15.8"); return version.contains("1.20.4") || version.contains("1.20.5") || version.contains("1.20.6");
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;
} }
return ApiResponse.success(versions); return false;
} }
public ApiResponse<List<java.util.Map<String, String>>> getZernMCPacks() { public ApiResponse<List<Map<String, String>>> getZernMCPacks() {
try { try {
String token = authService.getCurrentToken(); String token = authService.getCurrentToken();
if (token == null) { if (token == null) {
return ApiResponse.error("Не авторизован"); return ApiResponse.error("Не авторизован");
} }
URL url = new URL(LAUNCHER_SERVER + "/packs"); String response = ZHttpClient.get("/packs");
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); org.json.JSONArray arr = new org.json.JSONArray(response);
conn.setConnectTimeout(5000); List<Map<String, String>> packs = new ArrayList<>();
conn.setReadTimeout(10000); for (int i = 0; i < arr.length(); i++) {
conn.setRequestProperty("Authorization", "Bearer " + token); org.json.JSONObject pack = arr.getJSONObject(i);
Map<String, String> packInfo = new java.util.HashMap<>();
if (conn.getResponseCode() == 200) { packInfo.put("name", pack.optString("name", ""));
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { packInfo.put("displayName", pack.optString("displayName", pack.optString("name", "")));
StringBuilder sb = new StringBuilder(); packInfo.put("version", pack.optString("version", ""));
String line; packInfo.put("mcVersion", pack.optString("mcVersion", ""));
while ((line = br.readLine()) != null) sb.append(line); packInfo.put("loader", pack.optString("loader", "vanilla"));
org.json.JSONArray arr = new org.json.JSONArray(sb.toString()); packInfo.put("description", pack.optString("description", ""));
List<java.util.Map<String, String>> packs = new java.util.ArrayList<>(); packs.add(packInfo);
for (int i = 0; i < arr.length(); i++) {
org.json.JSONObject pack = arr.getJSONObject(i);
java.util.Map<String, String> packInfo = new java.util.HashMap<>();
packInfo.put("name", pack.optString("name", ""));
packInfo.put("displayName", pack.optString("displayName", pack.optString("name", "")));
packInfo.put("version", pack.optString("version", ""));
packInfo.put("mcVersion", pack.optString("mcVersion", ""));
packInfo.put("loader", pack.optString("loader", "vanilla"));
packInfo.put("description", pack.optString("description", ""));
packs.add(packInfo);
}
return ApiResponse.success(packs);
}
} else if (conn.getResponseCode() == 403) {
return ApiResponse.error("Требуется проходка");
} }
return ApiResponse.success(packs);
} catch (Exception e) { } catch (Exception e) {
System.out.println("[API] Packs fetch failed: " + e.getMessage()); System.out.println("[API] Packs fetch failed: " + e.getMessage());
return ApiResponse.error("Ошибка загрузки сборок: " + e.getMessage()); return ApiResponse.error("Ошибка загрузки сборок: " + e.getMessage());
} }
return ApiResponse.success(java.util.List.of());
} }
} }
@@ -12,9 +12,24 @@ import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
public class LaunchService { public class LaunchService {
private static final ConcurrentHashMap<Long, Process> runningProcesses = new ConcurrentHashMap<>();
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[LAUNCH] Shutting down all running processes...");
runningProcesses.values().forEach(p -> {
try {
p.destroy();
} catch (Exception ignored) {}
});
}));
}
public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) { public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) {
try { try {
@@ -49,7 +64,6 @@ public class LaunchService {
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions(); LaunchOptions options = new LaunchOptions();
// Set auth info
options.setUsername(AuthManager.getUsername()); options.setUsername(AuthManager.getUsername());
options.setAccessToken(AuthManager.getAccessToken()); options.setAccessToken(AuthManager.getAccessToken());
options.setUuid(AuthManager.getUuid()); options.setUuid(AuthManager.getUuid());
@@ -61,27 +75,60 @@ public class LaunchService {
ProcessBuilder processBuilder = new ProcessBuilder(command); ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile()); processBuilder.directory(instance.getPath().toFile());
processBuilder.redirectErrorStream(true); processBuilder.redirectErrorStream(true);
// Не перехватываем вывод игры - пусть выводится напрямую в консоль
// Инициализируем лог файл для игры, но не дублируем вывод
Path logsDir = instance.getPath().resolve("logs"); Path logsDir = instance.getPath().resolve("logs");
java.nio.file.Files.createDirectories(logsDir); java.nio.file.Files.createDirectories(logsDir);
Path gameLog = logsDir.resolve("game.log"); Path gameLog = logsDir.resolve("game.log");
processBuilder.redirectOutput(ProcessBuilder.Redirect.to(gameLog.toFile()));
Process process = processBuilder.start(); Process process = processBuilder.start();
System.out.println("[LAUNCH] Process started, pid=" + process.pid()); long pid = process.pid();
runningProcesses.put(pid, process);
System.out.println("[LAUNCH] Process started, pid=" + pid);
ProcessInfo info = new ProcessInfo( java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true);
instanceName,
process.pid(), Thread logReader = new Thread(() -> {
"RUNNING" 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); return ApiResponse.success(info);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Ошибка запуска: " + e.getMessage()); return ApiResponse.error("Ошибка запуска: " + e.getMessage());
} }
} }
public static void killAllProcesses() {
runningProcesses.values().forEach(p -> {
try {
p.destroyForcibly();
} catch (Exception ignored) {}
});
runningProcesses.clear();
}
public ApiResponse<Boolean> isReady(String instanceName) { public ApiResponse<Boolean> isReady(String instanceName) {
try { try {
Instance instance = InstanceManager.getInstance(instanceName); Instance instance = InstanceManager.getInstance(instanceName);
@@ -1,6 +1,7 @@
package me.sashegdev.zernmc.launcher.ui.jfx; package me.sashegdev.zernmc.launcher.ui.jfx;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.web.WebView; import javafx.scene.web.WebView;
import javafx.scene.web.WebEngine; import javafx.scene.web.WebEngine;
@@ -8,6 +9,7 @@ import javafx.stage.Stage;
import javafx.concurrent.Worker; import javafx.concurrent.Worker;
import com.google.gson.Gson; import com.google.gson.Gson;
import me.sashegdev.zernmc.launcher.api.LauncherAPI; import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
@@ -43,11 +45,43 @@ public class JFXLauncher extends Application {
private final LauncherAPI api = new LauncherAPI(); private final LauncherAPI api = new LauncherAPI();
private final Gson gson = new Gson(); private final Gson gson = new Gson();
private HttpServer server; private HttpServer server;
private StringBuilder logBuffer = new StringBuilder(); private static StringBuilder launcherLogBuffer = new StringBuilder();
private static StringBuilder gameLogBuffer = new StringBuilder(); private static StringBuilder gameLogBuffer = new StringBuilder();
private static Path gameLogFile; private static Path gameLogFile;
private Stage mainStage; private Stage mainStage;
private static final java.util.concurrent.CopyOnWriteArrayList<LogConsumer> logConsumers = new java.util.concurrent.CopyOnWriteArrayList<>();
private static final java.util.concurrent.CopyOnWriteArrayList<LogConsumer> gameLogConsumers = new java.util.concurrent.CopyOnWriteArrayList<>();
public interface LogConsumer {
void onLog(String line);
}
public static void addLogConsumer(LogConsumer consumer) {
logConsumers.add(consumer);
}
public static void removeLogConsumer(LogConsumer consumer) {
logConsumers.remove(consumer);
}
public static void addGameLogConsumer(LogConsumer consumer) {
gameLogConsumers.add(consumer);
}
public static void removeGameLogConsumer(LogConsumer consumer) {
gameLogConsumers.remove(consumer);
}
public static void appendLauncherLog(String log) {
synchronized (launcherLogBuffer) {
launcherLogBuffer.append(log).append("\n");
}
for (LogConsumer consumer : logConsumers) {
try { consumer.onLog(log); } catch (Exception ignored) {}
}
}
public static void appendGameLog(String log) { public static void appendGameLog(String log) {
synchronized (gameLogBuffer) { synchronized (gameLogBuffer) {
gameLogBuffer.append(log).append("\n"); gameLogBuffer.append(log).append("\n");
@@ -60,8 +94,11 @@ public class JFXLauncher extends Application {
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
} }
for (LogConsumer consumer : gameLogConsumers) {
try { consumer.onLog(log); } catch (Exception ignored) {}
}
} }
public static void initGameLog(Path instanceDir) { public static void initGameLog(Path instanceDir) {
synchronized (gameLogBuffer) { synchronized (gameLogBuffer) {
gameLogBuffer.setLength(0); gameLogBuffer.setLength(0);
@@ -74,14 +111,30 @@ public class JFXLauncher extends Application {
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
public static void clearGameLog() {
synchronized (gameLogBuffer) {
gameLogBuffer.setLength(0);
}
}
public static String getGameLogs() { public static String getGameLogs() {
synchronized (gameLogBuffer) { synchronized (gameLogBuffer) {
return gameLogBuffer.toString(); return gameLogBuffer.toString();
} }
} }
public static String getLauncherLogs() {
synchronized (launcherLogBuffer) {
return launcherLogBuffer.toString();
}
}
public static void main(String[] args) { public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[JFX] Shutdown hook triggered");
LaunchService.killAllProcesses();
}));
launch(args); launch(args);
} }
@@ -239,7 +292,8 @@ public class JFXLauncher extends Application {
stage.setOnCloseRequest(e -> { stage.setOnCloseRequest(e -> {
log("Закрытие..."); log("Закрытие...");
stopServer(); LaunchService.killAllProcesses();
if (server != null) server.stop(1);
}); });
} catch (Exception e) { } catch (Exception e) {
@@ -260,9 +314,11 @@ public class JFXLauncher extends Application {
server.createContext("/api/logs", this::handleLogs); server.createContext("/api/logs", this::handleLogs);
server.createContext("/api/logs/stream", this::handleLogsStream); server.createContext("/api/logs/stream", this::handleLogsStream);
server.createContext("/api/game-logs", this::handleGameLogs); server.createContext("/api/game-logs", this::handleGameLogs);
server.createContext("/api/game-logs/stream", this::handleGameLogsStream);
server.createContext("/api/mc-versions", this::handleMCVersions); server.createContext("/api/mc-versions", this::handleMCVersions);
server.createContext("/api/loader-versions", this::handleLoaderVersions); server.createContext("/api/loader-versions", this::handleLoaderVersions);
server.createContext("/api/packs", this::handlePacks); server.createContext("/api/packs", this::handlePacks);
server.createContext("/api/shutdown", this::handleShutdown);
server.createContext("/api/exit", this::handleExit); server.createContext("/api/exit", this::handleExit);
server.createContext("/assets/", this::handleStatic); server.createContext("/assets/", this::handleStatic);
@@ -394,7 +450,7 @@ public class JFXLauncher extends Application {
} }
private void handleLogs(HttpExchange exchange) { private void handleLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", logBuffer.toString())); sendJson(exchange, Map.of("success", true, "data", getLauncherLogs()));
} }
private void handleLogsStream(HttpExchange exchange) { private void handleLogsStream(HttpExchange exchange) {
@@ -404,46 +460,75 @@ public class JFXLauncher extends Application {
exchange.getResponseHeaders().set("Connection", "keep-alive"); exchange.getResponseHeaders().set("Connection", "keep-alive");
exchange.sendResponseHeaders(200, 0); exchange.sendResponseHeaders(200, 0);
String lastLog = "";
int sameCount = 0;
final OutputStream os = exchange.getResponseBody(); final OutputStream os = exchange.getResponseBody();
int[] lastLength = {getLauncherLogs().length()};
Thread.currentThread().setName("LogStream-" + System.currentTimeMillis()); LogConsumer consumer = new LogConsumer() {
@Override
for (int i = 0; !Thread.currentThread().isInterrupted() && i < 3000; i++) { public synchronized void onLog(String line) {
String currentLog = logBuffer.toString(); try {
if (!currentLog.equals(lastLog)) { String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
String newContent = currentLog.substring(lastLog.length()); os.write(data.getBytes(StandardCharsets.UTF_8));
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));
os.flush(); 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 { } catch (Exception ignored) {} finally {
removeLogConsumer(consumer);
try { exchange.getResponseBody().close(); } catch (Exception ignored) {} try { exchange.getResponseBody().close(); } catch (Exception ignored) {}
} }
} }
private LogConsumer consumer = null;
private void handleGameLogs(HttpExchange exchange) { private void handleGameLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", getGameLogs())); sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
} }
private void 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) { private void handleMCVersions(HttpExchange exchange) {
try { try {
var versions = api.getMCVersions(); var versions = api.getMCVersions();
@@ -500,9 +585,19 @@ public class JFXLauncher extends Application {
return params; 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) { private void handleExit(HttpExchange exchange) {
log("Выход..."); log("Выход...");
LaunchService.killAllProcesses();
if (mainStage != null) mainStage.close(); if (mainStage != null) mainStage.close();
Platform.exit();
System.exit(0); System.exit(0);
} }
@@ -563,7 +658,12 @@ public class JFXLauncher extends Application {
private void log(String msg) { private void log(String msg) {
String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n"; String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n";
logBuffer.append(entry); synchronized (launcherLogBuffer) {
launcherLogBuffer.append(entry);
}
System.out.println("[JFX] " + msg); System.out.println("[JFX] " + msg);
for (LogConsumer consumer : logConsumers) {
try { consumer.onLog(entry); } catch (Exception ignored) {}
}
} }
} }
@@ -96,10 +96,12 @@ public class ZHttpClient {
* Вызывать один раз при запуске лаунчера * Вызывать один раз при запуске лаунчера
*/ */
public static void checkAllServicesOnStartup() { public static void checkAllServicesOnStartup() {
checkAllServicesOnStartup(false);
}
public static void checkAllServicesOnStartup(boolean verbose) {
if (proxyTested.get()) return; if (proxyTested.get()) return;
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
List<ServiceType> servicesToCheck = List.of( List<ServiceType> servicesToCheck = List.of(
ServiceType.ZERN_SERVER, ServiceType.ZERN_SERVER,
ServiceType.GOOGLE, ServiceType.GOOGLE,
@@ -116,14 +118,20 @@ public class ZHttpClient {
serviceHealthy.put(service, isHealthy); serviceHealthy.put(service, isHealthy);
if (service.isAlwaysDirect()) { if (service.isAlwaysDirect()) {
System.out.println(isHealthy ? if (verbose) {
ZAnsi.green(" " + service.name() + " - OK") : System.out.println(isHealthy ?
ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); ZAnsi.green(" " + service.name() + " - OK") :
ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)"));
}
} else { } else {
if (isHealthy) { if (isHealthy) {
System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает")); if (verbose) {
System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает"));
}
} else { } else {
System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси")); if (verbose) {
System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси"));
}
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
} }
@@ -131,12 +139,16 @@ public class ZHttpClient {
} }
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!")); if (verbose) {
System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!"));
}
} }
proxyTested.set(true); proxyTested.set(true);
startHealthCheckThread(); if (verbose) {
printStats(); startHealthCheckThread();
printStats();
}
} }
/** /**
+32 -12
View File
@@ -99,6 +99,12 @@
<line x1="21" y1="12" x2="9" y2="12"/> <line x1="21" y1="12" x2="9" y2="12"/>
</svg> </svg>
</button> </button>
<button class="btn-logout" id="close-btn" title="Закрыть" onclick="app.shutdownLauncher()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div> </div>
</aside> </aside>
@@ -159,24 +165,38 @@
<div id="tab-vanilla" class="tab-content"> <div id="tab-vanilla" class="tab-content">
<div class="form-group"> <div class="form-group">
<label>Версия Minecraft</label> <label>Версия Minecraft</label>
<select id="mc-version-select" class="select-input"> <div class="custom-dropdown" id="mc-version-dropdown">
<option value="">Выберите версию</option> <div class="dropdown-trigger">
</select> <span class="dropdown-value">Выберите версию</span>
<span class="dropdown-arrow"></span>
</div>
<div class="dropdown-list"></div>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Лоадер</label> <label>Лоадер</label>
<select id="loader-select" class="select-input"> <div class="custom-dropdown" id="loader-dropdown">
<option value="vanilla">Vanilla (без лоадера)</option> <div class="dropdown-trigger">
<option value="fabric">Fabric</option> <span class="dropdown-value">Vanilla (без лоадера)</span>
<option value="forge">Forge</option> <span class="dropdown-arrow"></span>
<option value="neoforge">NeoForge</option> </div>
</select> <div class="dropdown-list">
<div class="dropdown-item selected" data-value="vanilla">Vanilla (без лоадера)</div>
<div class="dropdown-item" data-value="fabric">Fabric</div>
<div class="dropdown-item" data-value="forge">Forge</div>
<div class="dropdown-item" data-value="neoforge">NeoForge</div>
</div>
</div>
</div> </div>
<div id="loader-version-group" class="form-group hidden"> <div id="loader-version-group" class="form-group hidden">
<label>Версия лоадера</label> <label>Версия лоадера</label>
<select id="loader-version-select" class="select-input"> <div class="custom-dropdown" id="loader-version-dropdown">
<option value="">Загрузка...</option> <div class="dropdown-trigger">
</select> <span class="dropdown-value">Выберите версию</span>
<span class="dropdown-arrow"></span>
</div>
<div class="dropdown-list"></div>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Название сборки</label> <label>Название сборки</label>
+169 -65
View File
@@ -1,5 +1,6 @@
const API_BASE = '/api'; const API_BASE = '/api';
let consoleEventSource = null; let consoleEventSource = null;
let gameLogEventSource = null;
class LauncherApp { class LauncherApp {
constructor() { constructor() {
@@ -17,9 +18,114 @@ class LauncherApp {
async init() { async init() {
this.bindEvents(); this.bindEvents();
this.initGridAnimation(); this.initGridAnimation();
this.initDropdowns();
await this.checkAuth(); 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() { startConsoleLogStream() {
if (consoleEventSource) { if (consoleEventSource) {
consoleEventSource.close(); consoleEventSource.close();
@@ -86,8 +192,8 @@ class LauncherApp {
this.clearLogs(); this.clearLogs();
}); });
document.getElementById('loader-select').addEventListener('change', (e) => { document.getElementById('loader-dropdown').addEventListener('change', (e) => {
this.onLoaderChange(e.target.value); this.onLoaderChange(e.detail.value);
}); });
document.getElementById('install-zernmc-btn').addEventListener('click', () => { document.getElementById('install-zernmc-btn').addEventListener('click', () => {
@@ -172,7 +278,9 @@ class LauncherApp {
this.account = result.data; this.account = result.data;
this.username = result.data.username; this.username = result.data.username;
this.showMainScreen(); this.showMainScreen();
this.renderCurrentInstance();
this.startConsoleLogStream(); this.startConsoleLogStream();
this.startGameLogStream();
await this.loadInstances(); await this.loadInstances();
} else { } else {
this.showLoginScreen(); this.showLoginScreen();
@@ -212,15 +320,35 @@ class LauncherApp {
this.account = result.data; this.account = result.data;
this.username = result.data.username; this.username = result.data.username;
this.showMainScreen(); this.showMainScreen();
this.renderCurrentInstance();
this.startConsoleLogStream(); this.startConsoleLogStream();
this.startGameLogStream();
await this.loadInstances(); await this.loadInstances();
} else { } else {
this.showError(result.error || 'Ошибка входа'); 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() { async handleLogout() {
this.stopConsoleLogStream(); this.stopConsoleLogStream();
this.stopGameLogStream();
this.username = null; this.username = null;
this.account = null; this.account = null;
this.currentInstance = null; this.currentInstance = null;
@@ -229,6 +357,11 @@ class LauncherApp {
this.addLog('Вы вышли из аккаунта', 'info'); this.addLog('Вы вышли из аккаунта', 'info');
} }
async shutdownLauncher() {
const result = await this.request('/shutdown', { method: 'POST' });
window.close();
}
showError(message) { showError(message) {
const errorEl = document.getElementById('login-error'); const errorEl = document.getElementById('login-error');
errorEl.textContent = message; errorEl.textContent = message;
@@ -331,42 +464,20 @@ class LauncherApp {
} }
renderCurrentInstance(instance) { renderCurrentInstance(instance) {
const container = document.getElementById('current-instance'); if (!this.account) return;
let version = instance.version || 'Vanilla';
if (instance.isServerPack) { document.getElementById('username-display').textContent = this.account.username;
version = `v${instance.serverVersion}`; 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 = `
<div class="instance-card-mini">
<span class="instance-name">${this.escapeHtml(instance.name)}</span>
<span class="instance-version">${this.escapeHtml(version)}</span>
</div>
`;
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 = `
<div class="instance-card-mini">
<span class="instance-name" style="color: var(--text-muted)">Нет сборки</span>
<span class="instance-version" style="background: var(--bg-secondary)">Нажмите скачать</span>
</div>
`;
} }
async launchInstance() { async launchInstance() {
@@ -394,36 +505,32 @@ class LauncherApp {
} }
async loadDownloadModalData() { async loadDownloadModalData() {
const mcSelect = document.getElementById('mc-version-select'); this.populateDropdown('mc-version-dropdown', [{ value: '', label: 'Загрузка...' }]);
mcSelect.innerHTML = '<option value="">Загрузка...</option>';
const mcResult = await this.request('/mc-versions'); const mcResult = await this.request('/mc-versions');
if (mcResult.success && mcResult.data) { if (mcResult.success && mcResult.data) {
mcSelect.innerHTML = '<option value="">Выберите версию</option>'; const items = mcResult.data.map(v => ({ value: v, label: v }));
mcResult.data.forEach(v => { this.populateDropdown('mc-version-dropdown', items);
const opt = document.createElement('option'); } else {
opt.value = v; this.populateDropdown('mc-version-dropdown', [{ value: '', label: 'Ошибка загрузки' }]);
opt.textContent = v;
mcSelect.appendChild(opt);
});
} }
const packSelect = document.getElementById('zernmc-pack-select'); const zernmcSelect = document.getElementById('zernmc-pack-select');
packSelect.innerHTML = '<option value="">Загрузка...</option>'; zernmcSelect.innerHTML = '<option value="">Загрузка...</option>';
const packResult = await this.request('/packs'); const packResult = await this.request('/packs');
if (packResult.success && packResult.data && packResult.data.length > 0) { if (packResult.success && packResult.data && packResult.data.length > 0) {
packSelect.innerHTML = '<option value="">Выберите сборку</option>'; zernmcSelect.innerHTML = '<option value="">Выберите сборку</option>';
packResult.data.forEach(p => { packResult.data.forEach(p => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = p.name; opt.value = p.name;
opt.textContent = p.displayName + ' (' + p.version + ')'; opt.textContent = p.displayName + ' (' + p.version + ')';
packSelect.appendChild(opt); zernmcSelect.appendChild(opt);
}); });
} else if (packResult.error && packResult.error.includes('проходка')) { } else if (packResult.error && packResult.error.includes('проходка')) {
packSelect.innerHTML = '<option value="">Требуется проходка</option>'; zernmcSelect.innerHTML = '<option value="">Требуется проходка</option>';
} else { } else {
packSelect.innerHTML = '<option value="">Сборки недоступны</option>'; zernmcSelect.innerHTML = '<option value="">Сборки недоступны</option>';
} }
const zernmcTab = document.querySelector('[data-tab="zernmc"]'); const zernmcTab = document.querySelector('[data-tab="zernmc"]');
@@ -491,9 +598,9 @@ class LauncherApp {
} }
async installVanilla() { async installVanilla() {
const mcVersion = document.getElementById('mc-version-select').value; const mcVersion = this.getDropdownValue('mc-version-dropdown');
const loader = document.getElementById('loader-select').value; const loader = this.getDropdownValue('loader-dropdown');
const loaderVersion = document.getElementById('loader-version-select').value; const loaderVersion = this.getDropdownValue('loader-version-dropdown');
const instanceName = document.getElementById('vanilla-instance-name').value; const instanceName = document.getElementById('vanilla-instance-name').value;
if (!mcVersion) { if (!mcVersion) {
@@ -536,24 +643,21 @@ class LauncherApp {
async onLoaderChange(loader) { async onLoaderChange(loader) {
const loaderVersionGroup = document.getElementById('loader-version-group'); const loaderVersionGroup = document.getElementById('loader-version-group');
const loaderVersionSelect = document.getElementById('loader-version-select'); const mcVersion = this.getDropdownValue('mc-version-dropdown');
const mcVersion = document.getElementById('mc-version-select').value;
if (loader === 'vanilla') { if (loader === 'vanilla') {
loaderVersionGroup.classList.add('hidden'); loaderVersionGroup.classList.add('hidden');
} else { } else {
loaderVersionGroup.classList.remove('hidden'); loaderVersionGroup.classList.remove('hidden');
this.populateDropdown('loader-version-dropdown', [{ value: '', label: 'Загрузка...' }]);
if (mcVersion) { if (mcVersion) {
loaderVersionSelect.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request('/loader-versions?mc=' + mcVersion + '&loader=' + loader); const result = await this.request('/loader-versions?mc=' + mcVersion + '&loader=' + loader);
if (result.success && result.data) { if (result.success && result.data) {
loaderVersionSelect.innerHTML = '<option value="">Выберите версию</option>'; const items = result.data.map(v => ({ value: v, label: v }));
result.data.forEach(v => { this.populateDropdown('loader-version-dropdown', items);
const opt = document.createElement('option'); } else {
opt.value = v; this.populateDropdown('loader-version-dropdown', [{ value: '', label: 'Ошибка загрузки' }]);
opt.textContent = v;
loaderVersionSelect.appendChild(opt);
});
} }
} }
} }
@@ -490,6 +490,10 @@ body {
color: var(--error); color: var(--error);
} }
.btn-logout#close-btn:hover {
background: rgba(239, 68, 68, 0.2);
}
/* Main Content - Logs */ /* Main Content - Logs */
.main-content { .main-content {
display: flex; display: flex;
@@ -899,4 +903,110 @@ body {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--text-muted); 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);
} }
+7 -14
View File
@@ -822,20 +822,13 @@ async def activate_pass_page():
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ====================== # ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
@app.get("/packs") @app.get("/packs")
async def list_packs(request: Request): async def list_packs(
"""List all available packs - requires auth or master key for mirrors""" request: Request,
# Check for master key current_user: dict = Depends(get_current_user)
master_key = request.headers.get("X-Master-Key") ):
if master_key == MASTER_KEY: """List all available packs - requires auth"""
# Master key - allow access if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
pass raise HTTPException(403, "Requires active 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")
packs = [] packs = []