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

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 {
// Проверка всех сервисов при старте
ZHttpClient.checkAllServicesOnStartup();
ZHttpClient.checkAllServicesOnStartup(true);
// === АВТОРИЗАЦИЯ (используем новый API) ===
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
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.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<List<String>> 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<String> 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<String> mcVersions = new ArrayList<>();
for (int i = 0; i < versions.length(); i++) {
mcVersions.add(versions.getJSONObject(i).getString("id"));
}
return ApiResponse.success(mcVersions);
} catch (Exception e) {
System.out.println("[API] MC versions fetch failed, 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) {
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<String> versions = new java.util.ArrayList<>();
for (int i = 0; i < arr.length(); i++) {
versions.add(arr.getString(i));
}
return ApiResponse.success(versions);
}
}
} catch (Exception e) {
System.out.println("[API] Loader versions fetch failed, using fallback: " + e.getMessage());
}
List<String> versions = new ArrayList<>();
java.util.List<String> 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;
switch (loader.toLowerCase()) {
case "fabric":
versions = ZHttpClient.getFabricLoaderVersions();
break;
case "forge":
String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
int idx = 0;
while ((idx = xml.indexOf("<version>", idx)) != -1) {
int start = idx + 9;
int end = xml.indexOf("</version>", start);
if (end == -1) break;
String fullVersion = xml.substring(start, end).trim();
if (fullVersion.startsWith(mcVersion + "-")) {
versions.add(fullVersion.substring(mcVersion.length() + 1));
}
idx = end;
}
versions.sort((a, b) -> b.compareTo(a));
break;
case "neoforge":
String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
int neoidx = 0;
while ((neoidx = neoforgeXml.indexOf("<version>", neoidx)) != -1) {
int start = neoidx + 9;
int end = neoforgeXml.indexOf("</version>", start);
if (end == -1) break;
String fullVersion = neoforgeXml.substring(start, end).trim();
if (isNeoForgeCompatible(fullVersion, mcVersion)) {
versions.add(fullVersion);
}
neoidx = end;
}
versions.sort((a, b) -> b.compareTo(a));
break;
default:
break;
}
return ApiResponse.success(versions);
} catch (Exception e) {
System.out.println("[API] Loader versions fetch failed: " + e.getMessage());
return ApiResponse.error("Не удалось загрузить версии лоадера");
}
return ApiResponse.success(versions);
}
public ApiResponse<List<java.util.Map<String, String>>> getZernMCPacks() {
private boolean isNeoForgeCompatible(String version, String mcVersion) {
if (mcVersion.startsWith("1.21")) {
return version.contains("1.21") && !version.contains("1.20");
} else if (mcVersion.startsWith("1.20") && !mcVersion.equals("1.20")) {
return version.contains("1.20.4") || version.contains("1.20.5") || version.contains("1.20.6");
}
return false;
}
public ApiResponse<List<Map<String, String>>> getZernMCPacks() {
try {
String token = authService.getCurrentToken();
if (token == null) {
return ApiResponse.error("Не авторизован");
}
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<java.util.Map<String, String>> packs = new java.util.ArrayList<>();
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("Требуется проходка");
String response = ZHttpClient.get("/packs");
org.json.JSONArray arr = new org.json.JSONArray(response);
List<Map<String, String>> packs = new ArrayList<>();
for (int i = 0; i < arr.length(); i++) {
org.json.JSONObject pack = arr.getJSONObject(i);
Map<String, String> packInfo = new java.util.HashMap<>();
packInfo.put("name", pack.optString("name", ""));
packInfo.put("displayName", pack.optString("displayName", pack.optString("name", "")));
packInfo.put("version", pack.optString("version", ""));
packInfo.put("mcVersion", pack.optString("mcVersion", ""));
packInfo.put("loader", pack.optString("loader", "vanilla"));
packInfo.put("description", pack.optString("description", ""));
packs.add(packInfo);
}
return ApiResponse.success(packs);
} catch (Exception e) {
System.out.println("[API] Packs fetch failed: " + e.getMessage());
return ApiResponse.error("Ошибка загрузки сборок: " + e.getMessage());
}
return ApiResponse.success(java.util.List.of());
}
}
@@ -12,10 +12,25 @@ 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<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) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
@@ -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();
ProcessInfo info = new ProcessInfo(
instanceName,
process.pid(),
"RUNNING"
);
runningProcesses.put(pid, process);
System.out.println("[LAUNCH] Process started, pid=" + pid);
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<Boolean> isReady(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
@@ -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<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) {
synchronized (gameLogBuffer) {
gameLogBuffer.append(log).append("\n");
@@ -60,6 +94,9 @@ 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) {
@@ -75,13 +112,29 @@ public class JFXLauncher extends Application {
} 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) {}
}
}
}
@@ -96,9 +96,11 @@ public class ZHttpClient {
* Вызывать один раз при запуске лаунчера
*/
public static void checkAllServicesOnStartup() {
if (proxyTested.get()) return;
checkAllServicesOnStartup(false);
}
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
public static void checkAllServicesOnStartup(boolean verbose) {
if (proxyTested.get()) return;
List<ServiceType> servicesToCheck = List.of(
ServiceType.ZERN_SERVER,
@@ -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();
}
}
/**
+32 -12
View File
@@ -99,6 +99,12 @@
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</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>
</aside>
@@ -159,24 +165,38 @@
<div id="tab-vanilla" class="tab-content">
<div class="form-group">
<label>Версия Minecraft</label>
<select id="mc-version-select" class="select-input">
<option value="">Выберите версию</option>
</select>
<div class="custom-dropdown" id="mc-version-dropdown">
<div class="dropdown-trigger">
<span class="dropdown-value">Выберите версию</span>
<span class="dropdown-arrow"></span>
</div>
<div class="dropdown-list"></div>
</div>
</div>
<div class="form-group">
<label>Лоадер</label>
<select id="loader-select" class="select-input">
<option value="vanilla">Vanilla (без лоадера)</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
<div class="custom-dropdown" id="loader-dropdown">
<div class="dropdown-trigger">
<span class="dropdown-value">Vanilla (без лоадера)</span>
<span class="dropdown-arrow"></span>
</div>
<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 id="loader-version-group" class="form-group hidden">
<label>Версия лоадера</label>
<select id="loader-version-select" class="select-input">
<option value="">Загрузка...</option>
</select>
<div class="custom-dropdown" id="loader-version-dropdown">
<div class="dropdown-trigger">
<span class="dropdown-value">Выберите версию</span>
<span class="dropdown-arrow"></span>
</div>
<div class="dropdown-list"></div>
</div>
</div>
<div class="form-group">
<label>Название сборки</label>
+169 -65
View File
@@ -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 = `
<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() {
@@ -394,36 +505,32 @@ class LauncherApp {
}
async loadDownloadModalData() {
const mcSelect = document.getElementById('mc-version-select');
mcSelect.innerHTML = '<option value="">Загрузка...</option>';
this.populateDropdown('mc-version-dropdown', [{ value: '', label: 'Загрузка...' }]);
const mcResult = await this.request('/mc-versions');
if (mcResult.success && mcResult.data) {
mcSelect.innerHTML = '<option value="">Выберите версию</option>';
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 = '<option value="">Загрузка...</option>';
const zernmcSelect = document.getElementById('zernmc-pack-select');
zernmcSelect.innerHTML = '<option value="">Загрузка...</option>';
const packResult = await this.request('/packs');
if (packResult.success && packResult.data && packResult.data.length > 0) {
packSelect.innerHTML = '<option value="">Выберите сборку</option>';
zernmcSelect.innerHTML = '<option value="">Выберите сборку</option>';
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 = '<option value="">Требуется проходка</option>';
zernmcSelect.innerHTML = '<option value="">Требуется проходка</option>';
} else {
packSelect.innerHTML = '<option value="">Сборки недоступны</option>';
zernmcSelect.innerHTML = '<option value="">Сборки недоступны</option>';
}
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 = '<option value="">Загрузка...</option>';
const result = await this.request('/loader-versions?mc=' + mcVersion + '&loader=' + loader);
if (result.success && result.data) {
loaderVersionSelect.innerHTML = '<option value="">Выберите версию</option>';
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: 'Ошибка загрузки' }]);
}
}
}
@@ -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;
@@ -900,3 +904,109 @@ 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);
}
+7 -14
View File
@@ -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 = []