Launcher UI redesign + server mirror sync + file download optimization
This commit is contained in:
@@ -21,6 +21,7 @@ import java.util.jar.Manifest;
|
||||
public class Bootstrap {
|
||||
private static final String JAR_NAME = "zernmclauncher.jar";
|
||||
private static final String BASE_URL = "http://87.120.187.36:1582";
|
||||
private static List<String> MIRRORS = new ArrayList<>();
|
||||
|
||||
private static Path baseDir;
|
||||
private static Path binDir;
|
||||
@@ -51,6 +52,11 @@ public class Bootstrap {
|
||||
log("Локальная версия: " + currentVersion);
|
||||
log("Версия на сервере: " + serverVersion);
|
||||
|
||||
// Загружаем mirrors
|
||||
loadMirrors();
|
||||
log("Основной сервер: " + BASE_URL);
|
||||
log("Mirrors доступны: " + (MIRRORS.size() + 1));
|
||||
|
||||
if (isNewer(serverVersion, currentVersion)) {
|
||||
log("Доступно обновление!");
|
||||
downloadUpdate(serverVersion);
|
||||
@@ -248,38 +254,94 @@ public class Bootstrap {
|
||||
}
|
||||
|
||||
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception {
|
||||
URL url = new URL(BASE_URL + "/launcher/file/" + version + "/" + filePath);
|
||||
// Пробуем все сервера
|
||||
List<String> servers = new ArrayList<>();
|
||||
if (isServerReachable(BASE_URL)) servers.add(BASE_URL);
|
||||
servers.addAll(MIRRORS);
|
||||
java.util.Collections.shuffle(servers);
|
||||
|
||||
Exception lastError = null;
|
||||
for (String server : servers) {
|
||||
try {
|
||||
downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
|
||||
// Последний резерв - основной сервер
|
||||
downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
|
||||
}
|
||||
|
||||
private static void downloadFileFromServer(String urlStr, long expectedSize, String fileName) throws Exception {
|
||||
URL url = new URL(urlStr);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(60000);
|
||||
|
||||
if (conn.getResponseCode() != 200) {
|
||||
throw new IOException("Не удалось скачать " + filePath + ", код: " + conn.getResponseCode());
|
||||
throw new IOException("HTTP " + conn.getResponseCode());
|
||||
}
|
||||
|
||||
Path outPath = baseDir.resolve(filePath);
|
||||
if (expectedSize <= 0) {
|
||||
expectedSize = conn.getContentLengthLong();
|
||||
}
|
||||
|
||||
Path outPath = baseDir.resolve(fileName);
|
||||
Files.createDirectories(outPath.getParent());
|
||||
|
||||
long downloaded = 0;
|
||||
long lastUpdate = 0;
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
try (InputStream in = conn.getInputStream();
|
||||
OutputStream out = new FileOutputStream(outPath.toFile())) {
|
||||
byte[] buf = new byte[8192];
|
||||
byte[] buf = new byte[65536];
|
||||
int len;
|
||||
while ((len = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, len);
|
||||
downloaded += len;
|
||||
|
||||
// Обновляем прогресс на каждом KB
|
||||
if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) {
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
|
||||
double downloadedMB = downloaded / 1024.0 / 1024.0;
|
||||
double totalMB = expectedSize / 1024.0 / 1024.0;
|
||||
|
||||
System.out.print(String.format("\r[%s] %s - %.1f/%.1f MB (%.1f MB/s",
|
||||
getProgressBar(downloaded, expectedSize),
|
||||
fileName,
|
||||
downloadedMB,
|
||||
totalMB,
|
||||
speed
|
||||
));
|
||||
lastUpdate = downloaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем хеш
|
||||
String actualHash = calculateFileHash(outPath);
|
||||
String expectedHash = expectedSize > 0 ? "" : "";
|
||||
if (downloaded != expectedSize) {
|
||||
log("Предупреждение: размер " + filePath + " не совпадает");
|
||||
}
|
||||
// Финальная строка
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
|
||||
System.out.println(String.format("\r[%s] %s - %.1f MB (%.1f MB/s) - Готово!",
|
||||
getProgressBar(downloaded, expectedSize),
|
||||
fileName,
|
||||
downloaded / 1024.0 / 1024.0,
|
||||
speed
|
||||
));
|
||||
}
|
||||
|
||||
// Выводим прогресс
|
||||
System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB");
|
||||
private static String getProgressBar(long current, long total) {
|
||||
if (total <= 0) return "====";
|
||||
int filled = (int) ((current * 20) / total);
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
for (int i = 0; i < 20; i++) {
|
||||
sb.append(i < filled ? "=" : " ");
|
||||
}
|
||||
sb.append("]");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static class FileMeta {
|
||||
@@ -376,7 +438,8 @@ public class Bootstrap {
|
||||
|
||||
private static Path findJava() {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
String javaExe = os.contains("windows") ? "java.exe" : "java";
|
||||
// Используем javaw для скрытия консоли в JFX режиме
|
||||
String javaExe = os.contains("windows") ? "javaw.exe" : "java";
|
||||
|
||||
// Сначала ищем jre21/bin/java рядом с лаунчером
|
||||
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
|
||||
@@ -407,4 +470,46 @@ public class Bootstrap {
|
||||
|
||||
return javaBin;
|
||||
}
|
||||
|
||||
private static void loadMirrors() {
|
||||
try {
|
||||
URL url = new URL(BASE_URL + "/launcher/mirrors");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
|
||||
if (conn.getResponseCode() == 200) {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
|
||||
com.google.gson.JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject();
|
||||
com.google.gson.JsonArray mirrorsArray = json.getAsJsonArray("mirrors");
|
||||
|
||||
for (com.google.gson.JsonElement elem : mirrorsArray) {
|
||||
com.google.gson.JsonObject mirror = elem.getAsJsonObject();
|
||||
String mirrorUrl = mirror.get("url").getAsString();
|
||||
if (!MIRRORS.contains(mirrorUrl)) {
|
||||
MIRRORS.add(mirrorUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Mirrors недоступны: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isServerReachable(String serverUrl) {
|
||||
try {
|
||||
URL url = new URL(serverUrl + "/launcher/version");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(2000);
|
||||
conn.setReadTimeout(2000);
|
||||
return conn.getResponseCode() == 200;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,4 +71,40 @@ public class LauncherAPI {
|
||||
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
|
||||
return launchService.launch(instanceName);
|
||||
}
|
||||
|
||||
public ApiResponse<List<String>> getMCVersions() {
|
||||
try {
|
||||
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"));
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ApiResponse<List<String>> getLoaderVersions(String mcVersion, String loader) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
return ApiResponse.success(versions);
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -69,11 +69,16 @@ public class InstanceService {
|
||||
}
|
||||
|
||||
private InstanceInfo toInstanceInfo(Instance instance) {
|
||||
// Определяем категорию: ZernMC или локальная
|
||||
String name = instance.getName().toLowerCase();
|
||||
String category = name.contains("zernmc") ? "zernmc" : "local";
|
||||
|
||||
return new InstanceInfo(
|
||||
instance.getName(),
|
||||
instance.getPath().toString(),
|
||||
instance.getMinecraftVersion(),
|
||||
instance.getLoaderType()
|
||||
instance.getLoaderType(),
|
||||
category
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,17 +87,20 @@ public class InstanceService {
|
||||
private String path;
|
||||
private String version;
|
||||
private String loaderType;
|
||||
private String category; // "zernmc" или "local"
|
||||
|
||||
public InstanceInfo(String name, String path, String version, String loaderType) {
|
||||
public InstanceInfo(String name, String path, String version, String loaderType, String category) {
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
this.version = version;
|
||||
this.loaderType = loaderType;
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public String getPath() { return path; }
|
||||
public String getVersion() { return version; }
|
||||
public String getLoaderType() { return loaderType; }
|
||||
public String getCategory() { return category; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
@@ -227,8 +228,10 @@ public class JFXLauncher extends Application {
|
||||
engine.load(url);
|
||||
|
||||
stage.setTitle(APP_TITLE);
|
||||
stage.setWidth(1200);
|
||||
stage.setWidth(1280);
|
||||
stage.setHeight(800);
|
||||
stage.setMinWidth(800);
|
||||
stage.setMinHeight(600);
|
||||
stage.setScene(new Scene(webView));
|
||||
stage.show();
|
||||
|
||||
@@ -256,6 +259,8 @@ public class JFXLauncher extends Application {
|
||||
server.createContext("/api/install", this::handleInstall);
|
||||
server.createContext("/api/logs", this::handleLogs);
|
||||
server.createContext("/api/game-logs", this::handleGameLogs);
|
||||
server.createContext("/api/mc-versions", this::handleMCVersions);
|
||||
server.createContext("/api/loader-versions", this::handleLoaderVersions);
|
||||
server.createContext("/api/exit", this::handleExit);
|
||||
server.createContext("/assets/", this::handleStatic);
|
||||
|
||||
@@ -394,6 +399,49 @@ public class JFXLauncher extends Application {
|
||||
sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
|
||||
}
|
||||
|
||||
private void handleMCVersions(HttpExchange exchange) {
|
||||
try {
|
||||
var versions = api.getMCVersions();
|
||||
if (versions.isSuccess()) {
|
||||
sendJson(exchange, Map.of("success", true, "data", versions.getData()));
|
||||
} else {
|
||||
sendJson(exchange, Map.of("success", false, "error", versions.getError()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleLoaderVersions(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
|
||||
String mcVersion = params.get("mc");
|
||||
String loader = params.get("loader");
|
||||
|
||||
var versions = api.getLoaderVersions(mcVersion, loader);
|
||||
if (versions.isSuccess()) {
|
||||
sendJson(exchange, Map.of("success", true, "data", versions.getData()));
|
||||
} else {
|
||||
sendJson(exchange, Map.of("success", false, "error", versions.getError()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> parseQuery(String query) {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
if (query != null && !query.isEmpty()) {
|
||||
for (String pair : query.split("&")) {
|
||||
String[] kv = pair.split("=");
|
||||
if (kv.length == 2) {
|
||||
params.put(kv[0], kv[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private void handleExit(HttpExchange exchange) {
|
||||
log("Выход...");
|
||||
if (mainStage != null) mainStage.close();
|
||||
|
||||
@@ -5,94 +5,205 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZernMC Launcher</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="grid-canvas"></canvas>
|
||||
|
||||
<div id="app">
|
||||
<!-- Экран логина -->
|
||||
<div id="login-screen" class="screen">
|
||||
<!-- Login Screen -->
|
||||
<div id="login-screen" class="screen hidden">
|
||||
<div class="login-container">
|
||||
<h1 class="logo">ZernMC</h1>
|
||||
<p class="subtitle">Private Launcher</p>
|
||||
<form id="login-form">
|
||||
<input type="text" id="username" placeholder="Никнейм" required>
|
||||
<input type="password" id="password" placeholder="Пароль" required>
|
||||
<button type="submit" class="btn-primary">Войти</button>
|
||||
<div class="logo-section">
|
||||
<div class="logo-placeholder">
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
|
||||
<rect width="80" height="80" rx="20" fill="#e94560"/>
|
||||
<path d="M25 40 L40 25 L55 40 L40 55 Z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="app-title">ZernMC Launcher</h1>
|
||||
<p class="app-version">v<span id="version">1.0.9</span></p>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="login-form">
|
||||
<div class="input-group">
|
||||
<input type="text" id="username" name="username" placeholder="Имя пользователя" required autocomplete="username">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">
|
||||
<span class="btn-text">Войти</span>
|
||||
<div class="btn-loader hidden"></div>
|
||||
</button>
|
||||
<p id="login-error" class="error-message hidden"></p>
|
||||
</form>
|
||||
<div id="login-error" class="error hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Главное меню -->
|
||||
<!-- Main Screen -->
|
||||
<div id="main-screen" class="screen hidden">
|
||||
<!-- Хедер -->
|
||||
<header class="header">
|
||||
<h1 class="logo">ZernMC Launcher</h1>
|
||||
<div class="account-info">
|
||||
<span id="account-name">-</span>
|
||||
<span id="account-status" class="badge">-</span>
|
||||
<span id="account-role" class="badge role-badge">-</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<main class="main-content">
|
||||
<!-- Слева: выбор сборки -->
|
||||
<div class="main-layout">
|
||||
<!-- Left Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<h2>Сборки</h2>
|
||||
<div id="instances-list" class="instances-container">
|
||||
<!-- Динамически заполняется через JS -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-small">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<rect width="40" height="40" rx="10" fill="#e94560"/>
|
||||
<path d="M12 20 L20 12 L28 20 L20 28 Z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<h1 class="header-title">ZernMC</h1>
|
||||
<span class="header-version">v<span id="header-version">1.0.9</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<div class="current-instance-section">
|
||||
<h3 class="section-label">Текущая сборка</h3>
|
||||
<div id="current-instance" class="current-instance">
|
||||
<div class="instance-card-mini">
|
||||
<span class="instance-name">Загрузка...</span>
|
||||
<span class="instance-version">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instances-section">
|
||||
<h3 class="section-label">Все сборки</h3>
|
||||
<div id="instances-list" class="instances-list">
|
||||
<!-- Dynamic content -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="download-btn" class="btn-download">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Скачать сборку
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<span class="username-display" id="username-display"></span>
|
||||
<div class="account-badges">
|
||||
<span id="account-status" class="badge"></span>
|
||||
<span id="account-role" class="badge role-badge"></span>
|
||||
</div>
|
||||
<button class="btn-logout" id="logout-btn" title="Выйти">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- По центру: логи -->
|
||||
<section class="logs-panel">
|
||||
<h2>Логи</h2>
|
||||
<div id="logs-container"></div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<div class="logs-section">
|
||||
<div class="logs-header">
|
||||
<h2>Логи</h2>
|
||||
<button class="btn-clear-logs" id="clear-logs">Очистить</button>
|
||||
</div>
|
||||
<div id="logs-container" class="logs-container">
|
||||
<div class="log-entry info">Ожидание запуска...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Низ: управление -->
|
||||
<footer class="footer">
|
||||
<div class="instance-info">
|
||||
<span id="selected-name">-</span>
|
||||
<span id="selected-version">-</span>
|
||||
<span id="selected-loader">-</span>
|
||||
<!-- Right Panel - Play Button -->
|
||||
<div class="right-panel">
|
||||
<button id="play-btn" class="btn-play">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
ИГРАТЬ
|
||||
</button>
|
||||
</div>
|
||||
<button id="play-btn" class="btn-play" disabled>Выберите сборку</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно установки -->
|
||||
<div id="install-modal" class="modal hidden">
|
||||
<!-- Download Modal -->
|
||||
<div id="download-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h2>Установка сборки</h2>
|
||||
<form id="install-form">
|
||||
<label>Версия Minecraft
|
||||
<select id="install-mc-version">
|
||||
<option value="1.20.4">1.20.4</option>
|
||||
<option value="1.20.2">1.20.2</option>
|
||||
<option value="1.20.1">1.20.1</option>
|
||||
<option value="1.19.2">1.19.2</option>
|
||||
<div class="modal-header">
|
||||
<h2>Скачать сборку</h2>
|
||||
<button class="modal-close" id="close-download-modal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-tabs">
|
||||
<button class="tab-btn active" data-tab="zernmc">ZernMC сборки</button>
|
||||
<button class="tab-btn" data-tab="vanilla">Чистый Minecraft</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-zernmc" class="tab-content active">
|
||||
<div class="form-group">
|
||||
<label>Выберите сборку</label>
|
||||
<select id="zernmc-pack-select" class="select-input">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Загрузчик
|
||||
<select id="install-loader">
|
||||
<option value="vanilla">Vanilla</option>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Название сборки (системное)</label>
|
||||
<input type="text" id="zernmc-instance-name" class="text-input" placeholder="my-zernmc-pack">
|
||||
</div>
|
||||
<button id="install-zernmc-btn" class="btn-install">
|
||||
Скачать и установить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
<label>Имя сборки
|
||||
<input type="text" id="install-name" placeholder="MyServer" required>
|
||||
</label>
|
||||
<div class="modal-buttons">
|
||||
<button type="button" class="btn-secondary" onclick="closeInstallModal()">Отмена</button>
|
||||
<button type="submit" class="btn-primary">Установить</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="loader-version-group" class="form-group hidden">
|
||||
<label>Версия лоадера</label>
|
||||
<select id="loader-version-select" class="select-input">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Название сборки</label>
|
||||
<input type="text" id="vanilla-instance-name" class="text-input" placeholder="my-minecraft">
|
||||
</div>
|
||||
<button id="install-vanilla-btn" class="btn-install">
|
||||
Скачать и установить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="download-progress" class="download-progress hidden">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<p class="progress-text" id="progress-text">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loading-overlay" class="loading-overlay hidden">
|
||||
<div class="loader"></div>
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="launcher.js"></script>
|
||||
|
||||
@@ -1,311 +1,505 @@
|
||||
const API_BASE = 'http://localhost:8080/api';
|
||||
const API_BASE = '/api';
|
||||
|
||||
let state = {
|
||||
loggedIn: false,
|
||||
account: null,
|
||||
instances: [],
|
||||
selectedInstance: null
|
||||
};
|
||||
|
||||
// ============ API ============
|
||||
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
const config = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (e) {
|
||||
log('Ошибка соединения с сервером: ' + e.message, 'error');
|
||||
return { success: false, error: e.message };
|
||||
class LauncherApp {
|
||||
constructor() {
|
||||
this.state = 'INIT';
|
||||
this.username = null;
|
||||
this.account = null;
|
||||
this.currentInstance = null;
|
||||
this.instances = [];
|
||||
this.selectedInstance = null;
|
||||
this.hasUpdate = false;
|
||||
this.hasMismatches = false;
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Login ============
|
||||
|
||||
async function login(username, password) {
|
||||
log('Выполняется вход...', 'info');
|
||||
const result = await apiCall('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
state.loggedIn = true;
|
||||
state.account = result.data;
|
||||
log('Вход выполнен: ' + result.data.username, 'success');
|
||||
showMainScreen();
|
||||
await loadInstances();
|
||||
} else {
|
||||
log('Ошибка входа: ' + result.error, 'error');
|
||||
showError(result.error);
|
||||
async init() {
|
||||
this.bindEvents();
|
||||
this.initGridAnimation();
|
||||
await this.checkAuth();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const el = document.getElementById('login-error');
|
||||
el.textContent = message;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
bindEvents() {
|
||||
document.getElementById('login-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleLogin();
|
||||
});
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('login-error').classList.add('hidden');
|
||||
}
|
||||
document.getElementById('logout-btn').addEventListener('click', () => {
|
||||
this.handleLogout();
|
||||
});
|
||||
|
||||
// ============ Account ============
|
||||
document.getElementById('download-btn').addEventListener('click', () => {
|
||||
this.showDownloadModal();
|
||||
});
|
||||
|
||||
async function loadAccountInfo() {
|
||||
const result = await apiCall('/account');
|
||||
if (result.success) {
|
||||
state.account = result.data;
|
||||
state.loggedIn = true;
|
||||
document.getElementById('account-name').textContent = result.data.username;
|
||||
document.getElementById('close-download-modal').addEventListener('click', () => {
|
||||
this.hideDownloadModal();
|
||||
});
|
||||
|
||||
const statusEl = document.getElementById('account-status');
|
||||
statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE';
|
||||
statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive');
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
this.switchTab(e.target.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
const roleEl = document.getElementById('account-role');
|
||||
if (roleEl && result.data.roleName) {
|
||||
roleEl.textContent = result.data.roleName;
|
||||
document.getElementById('play-btn').addEventListener('click', () => {
|
||||
this.launchInstance();
|
||||
});
|
||||
|
||||
document.getElementById('clear-logs').addEventListener('click', () => {
|
||||
this.clearLogs();
|
||||
});
|
||||
|
||||
document.getElementById('loader-select').addEventListener('change', (e) => {
|
||||
this.onLoaderChange(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('install-zernmc-btn').addEventListener('click', () => {
|
||||
this.installZernMCPack();
|
||||
});
|
||||
|
||||
document.getElementById('install-vanilla-btn').addEventListener('click', () => {
|
||||
this.installVanilla();
|
||||
});
|
||||
}
|
||||
|
||||
initGridAnimation() {
|
||||
const canvas = document.getElementById('grid-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let mouseX = 0, mouseY = 0;
|
||||
let offsetX = 0, offsetY = 0;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||
});
|
||||
|
||||
const animate = () => {
|
||||
offsetX += (mouseX * 0.5 - offsetX) * 0.05;
|
||||
offsetY += (mouseY * 0.5 - offsetY) * 0.05;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
resize();
|
||||
animate();
|
||||
}
|
||||
|
||||
drawGrid(ctx, width, height, offsetX, offsetY) {
|
||||
const gridSize = 50;
|
||||
const dotSize = 1;
|
||||
|
||||
ctx.fillStyle = '#e94560';
|
||||
|
||||
for (let x = 0; x <= width; x += gridSize) {
|
||||
for (let y = 0; y <= height; y += gridSize) {
|
||||
const px = x + offsetX * 10;
|
||||
const py = y + offsetY * 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showLoginScreen();
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Instances ============
|
||||
|
||||
async function loadInstances() {
|
||||
log('Загрузка списка сборок...', 'info');
|
||||
const result = await apiCall('/instances');
|
||||
|
||||
if (result.success) {
|
||||
state.instances = result.data;
|
||||
renderInstances();
|
||||
log('Загружено ' + result.data.length + ' сборок', 'success');
|
||||
} else {
|
||||
log('Ошибка загрузки: ' + result.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderInstances() {
|
||||
const container = document.getElementById('instances-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
state.instances.forEach(inst => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'instance-card';
|
||||
card.dataset.name = inst.name;
|
||||
card.onclick = () => selectInstance(inst.name);
|
||||
|
||||
let details = `
|
||||
<span class="instance-version">${inst.version || '?'}</span>
|
||||
<span class="instance-loader">${inst.loaderType || 'vanilla'}</span>
|
||||
`;
|
||||
|
||||
if (inst.isServerPack) {
|
||||
details += `<span class="instance-server-version">v${inst.serverVersion}</span>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="instance-name">${inst.name}</div>
|
||||
<div class="instance-details">${details}</div>
|
||||
`;
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function selectInstance(name) {
|
||||
state.selectedInstance = state.instances.find(i => i.name === name);
|
||||
|
||||
document.querySelectorAll('.instance-card').forEach(c => {
|
||||
c.classList.toggle('selected', c.dataset.name === name);
|
||||
});
|
||||
|
||||
const btn = document.getElementById('play-btn');
|
||||
const inst = state.selectedInstance;
|
||||
|
||||
if (inst) {
|
||||
document.getElementById('selected-name').textContent = inst.name;
|
||||
document.getElementById('selected-version').textContent = inst.version || '-';
|
||||
document.getElementById('selected-loader').textContent = inst.loaderType || 'vanilla';
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Играть';
|
||||
btn.classList.remove('update');
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Выберите сборку';
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Launch ============
|
||||
|
||||
async function launchInstance() {
|
||||
if (!state.selectedInstance) return;
|
||||
|
||||
const name = state.selectedInstance.name;
|
||||
log('Запуск сборки: ' + name, 'info');
|
||||
|
||||
const result = await apiCall('/launch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
log('Сборка запущена! PID: ' + result.data.pid, 'success');
|
||||
} else {
|
||||
log('Ошибка запуска: ' + result.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Install ============
|
||||
|
||||
function openInstallModal() {
|
||||
document.getElementById('install-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeInstallModal() {
|
||||
document.getElementById('install-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function installInstance(formData) {
|
||||
log('Установка сборки...', 'info');
|
||||
const result = await apiCall('/install', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
log('Сборка установлена!', 'success');
|
||||
closeInstallModal();
|
||||
await loadInstances();
|
||||
} else {
|
||||
log('Ошибка установки: ' + result.error, 'error');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ Logs ============
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const container = document.getElementById('logs-container');
|
||||
if (!container) return;
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = 'log-line ' + type;
|
||||
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
|
||||
container.appendChild(line);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
document.getElementById('logs-container').innerHTML = '';
|
||||
}
|
||||
|
||||
// ============ Screens ============
|
||||
|
||||
function showLoginScreen() {
|
||||
document.getElementById('login-screen').classList.remove('hidden');
|
||||
document.getElementById('main-screen').classList.add('hidden');
|
||||
clearError();
|
||||
}
|
||||
|
||||
function showMainScreen() {
|
||||
document.getElementById('login-screen').classList.add('hidden');
|
||||
document.getElementById('main-screen').classList.remove('hidden');
|
||||
|
||||
if (state.account) {
|
||||
document.getElementById('account-name').textContent = state.account.username;
|
||||
const statusEl = document.getElementById('account-status');
|
||||
statusEl.textContent = state.account.passActive ? 'PRO' : 'FREE';
|
||||
statusEl.className = 'badge ' + (state.account.passActive ? 'active' : 'inactive');
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Init ============
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
log('Запуск лаунчера...', 'info');
|
||||
|
||||
await loadAccountInfo();
|
||||
|
||||
if (!state.loggedIn) {
|
||||
showLoginScreen();
|
||||
} else {
|
||||
showMainScreen();
|
||||
await loadInstances();
|
||||
}
|
||||
|
||||
// Start polling for server logs
|
||||
startLogPolling();
|
||||
});
|
||||
|
||||
let lastLogLength = 0;
|
||||
let lastGameLogLength = 0;
|
||||
function startLogPolling() {
|
||||
setInterval(async () => {
|
||||
// Launcher logs
|
||||
const result = await apiCall('/logs');
|
||||
if (result.success && result.data && result.data.length > lastLogLength) {
|
||||
const newLogs = result.data.substring(lastLogLength);
|
||||
const lines = newLogs.split('\n').filter(l => l.trim());
|
||||
lines.forEach(line => {
|
||||
if (line.includes('[JFX]')) {
|
||||
log(line.replace('[JFX] ', ''), 'info');
|
||||
async request(endpoint, options = {}) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
lastLogLength = result.data.length;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async checkAuth() {
|
||||
this.showLoading(true);
|
||||
const result = await this.request('/account');
|
||||
|
||||
if (result.success && result.data) {
|
||||
this.account = result.data;
|
||||
this.username = result.data.username;
|
||||
this.showMainScreen();
|
||||
await this.loadInstances();
|
||||
await this.loadCurrentInstance();
|
||||
} else {
|
||||
this.showLoginScreen();
|
||||
}
|
||||
|
||||
// Game logs
|
||||
const gameResult = await apiCall('/game-logs');
|
||||
if (gameResult.success && gameResult.data && gameResult.data.length > lastGameLogLength) {
|
||||
const newLogs = gameResult.data.substring(lastGameLogLength);
|
||||
const lines = newLogs.split('\n').filter(l => l.trim());
|
||||
lines.forEach(line => {
|
||||
log('[GAME] ' + line, 'info');
|
||||
});
|
||||
lastGameLogLength = gameResult.data.length;
|
||||
this.showLoading(false);
|
||||
}
|
||||
|
||||
async handleLogin() {
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const errorEl = document.getElementById('login-error');
|
||||
const btn = document.querySelector('#login-form button[type="submit"]');
|
||||
const btnText = btn.querySelector('.btn-text');
|
||||
const btnLoader = btn.querySelector('.btn-loader');
|
||||
|
||||
if (!username || !password) {
|
||||
this.showError('Введите имя пользователя и пароль');
|
||||
return;
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
btn.disabled = true;
|
||||
btnText.classList.add('hidden');
|
||||
btnLoader.classList.remove('hidden');
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
const result = await this.request('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
btn.disabled = false;
|
||||
btnText.classList.remove('hidden');
|
||||
btnLoader.classList.add('hidden');
|
||||
|
||||
if (result.success) {
|
||||
this.account = result.data;
|
||||
this.username = result.data.username;
|
||||
this.showMainScreen();
|
||||
await this.loadInstances();
|
||||
await this.loadCurrentInstance();
|
||||
} else {
|
||||
this.showError(result.error || 'Ошибка входа');
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogout() {
|
||||
this.username = null;
|
||||
this.account = null;
|
||||
this.currentInstance = null;
|
||||
this.instances = [];
|
||||
this.showLoginScreen();
|
||||
this.addLog('Вы вышли из аккаунта', 'info');
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const errorEl = document.getElementById('login-error');
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async loadInstances() {
|
||||
const result = await this.request('/instances');
|
||||
|
||||
if (result.success && result.data) {
|
||||
this.instances = result.data;
|
||||
this.renderInstances();
|
||||
this.addLog('Загружено ' + result.data.length + ' сборок', 'success');
|
||||
} else {
|
||||
this.addLog('Ошибка загрузки: ' + (result.error || 'неизвестная ошибка'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
renderInstances() {
|
||||
const container = document.getElementById('instances-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
this.instances.forEach(inst => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'instance-card';
|
||||
if (this.selectedInstance && this.selectedInstance.name === inst.name) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
card.dataset.name = inst.name;
|
||||
card.onclick = () => this.selectInstance(inst.name);
|
||||
|
||||
let details = `<span class="instance-card-version">${inst.version || '?'}</span>`;
|
||||
if (inst.loaderType && inst.loaderType !== 'vanilla') {
|
||||
details += `<span class="instance-card-loader">${inst.loaderType}</span>`;
|
||||
}
|
||||
if (inst.isServerPack) {
|
||||
details += `<span class="instance-card-server">v${inst.serverVersion}</span>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="instance-card-name">${this.escapeHtml(inst.name)}</div>
|
||||
<div class="instance-card-details">${details}</div>
|
||||
`;
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
selectInstance(name) {
|
||||
this.selectedInstance = this.instances.find(i => i.name === name);
|
||||
this.renderInstances();
|
||||
this.updatePlayButton();
|
||||
}
|
||||
|
||||
async loadCurrentInstance() {
|
||||
if (this.instances.length > 0) {
|
||||
this.currentInstance = this.instances[0];
|
||||
this.selectedInstance = this.currentInstance;
|
||||
this.renderCurrentInstance(this.currentInstance);
|
||||
this.addLog('Сборка загружена: ' + this.currentInstance.name, 'success');
|
||||
} else {
|
||||
this.renderNoInstance();
|
||||
}
|
||||
this.updatePlayButton();
|
||||
}
|
||||
|
||||
updatePlayButton() {
|
||||
const btn = document.getElementById('play-btn');
|
||||
const instance = this.selectedInstance || this.currentInstance;
|
||||
|
||||
if (!instance) {
|
||||
btn.disabled = true;
|
||||
btn.className = 'btn-play';
|
||||
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ВЫБЕРИТЕ СБОРКУ';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasUpdate || this.hasMismatches) {
|
||||
btn.disabled = false;
|
||||
btn.className = 'btn-update';
|
||||
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>ОБНОВИТЬ';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.className = 'btn-play';
|
||||
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ИГРАТЬ';
|
||||
}
|
||||
}
|
||||
|
||||
renderCurrentInstance(instance) {
|
||||
const container = document.getElementById('current-instance');
|
||||
let version = instance.version || 'Vanilla';
|
||||
if (instance.isServerPack) {
|
||||
version = `v${instance.serverVersion}`;
|
||||
}
|
||||
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() {
|
||||
const instance = this.selectedInstance || this.currentInstance;
|
||||
if (!instance) return;
|
||||
|
||||
const name = instance.name;
|
||||
this.addLog('Запуск сборки: ' + name, 'info');
|
||||
|
||||
const result = await this.request('/launch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this.addLog('Сборка запущена! PID: ' + (result.data?.pid || ''), 'success');
|
||||
} else {
|
||||
this.addLog('Ошибка: ' + (result.error || 'неизвестная ошибка'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async showDownloadModal() {
|
||||
document.getElementById('download-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
hideDownloadModal() {
|
||||
document.getElementById('download-modal').classList.add('hidden');
|
||||
this.hideProgress();
|
||||
}
|
||||
|
||||
switchTab(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.toggle('active', content.id === 'tab-' + tab);
|
||||
});
|
||||
}
|
||||
|
||||
async installZernMCPack() {
|
||||
const packName = document.getElementById('zernmc-pack-select').value;
|
||||
const instanceName = document.getElementById('zernmc-instance-name').value;
|
||||
|
||||
if (!packName) {
|
||||
alert('Выберите сборку');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!instanceName) {
|
||||
alert('Введите название сборки');
|
||||
return;
|
||||
}
|
||||
|
||||
this.showProgress('Установка ZernMC сборки...');
|
||||
this.addLog('Начало установки: ' + packName, 'info');
|
||||
|
||||
const result = await this.request('/install', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: instanceName,
|
||||
version: 'latest',
|
||||
loader: 'vanilla'
|
||||
})
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this.hideDownloadModal();
|
||||
await this.loadInstances();
|
||||
await this.loadCurrentInstance();
|
||||
this.addLog('Сборка установлена!', 'success');
|
||||
} else {
|
||||
this.addLog('Ошибка установки: ' + (result.error || 'неизвестная ошибка'), 'error');
|
||||
this.hideProgress();
|
||||
}
|
||||
}
|
||||
|
||||
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 instanceName = document.getElementById('vanilla-instance-name').value;
|
||||
|
||||
if (!mcVersion) {
|
||||
alert('Выберите версию Minecraft');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!instanceName) {
|
||||
alert('Введите название сборки');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loader !== 'vanilla' && !loaderVersion) {
|
||||
alert('Выберите версию лоадера');
|
||||
return;
|
||||
}
|
||||
|
||||
this.showProgress('Установка сборки...');
|
||||
this.addLog(`Начало установки: Minecraft ${mcVersion} ${loader !== 'vanilla' ? loader + ' ' + loaderVersion : ''}`, 'info');
|
||||
|
||||
const result = await this.request('/install', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: instanceName,
|
||||
version: mcVersion,
|
||||
loader: loader === 'vanilla' ? 'vanilla' : loader
|
||||
})
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this.hideDownloadModal();
|
||||
await this.loadInstances();
|
||||
await this.loadCurrentInstance();
|
||||
this.addLog('Сборка установлена!', 'success');
|
||||
} else {
|
||||
this.addLog('Ошибка установки: ' + (result.error || 'неизвестная ошибка'), 'error');
|
||||
this.hideProgress();
|
||||
}
|
||||
}
|
||||
|
||||
onLoaderChange(loader) {
|
||||
const loaderVersionGroup = document.getElementById('loader-version-group');
|
||||
if (loader === 'vanilla') {
|
||||
loaderVersionGroup.classList.add('hidden');
|
||||
} else {
|
||||
loaderVersionGroup.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
showProgress(text) {
|
||||
const progress = document.getElementById('download-progress');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
|
||||
progress.classList.remove('hidden');
|
||||
progressText.textContent = text;
|
||||
progressFill.style.width = '50%';
|
||||
}
|
||||
|
||||
hideProgress() {
|
||||
document.getElementById('download-progress').classList.add('hidden');
|
||||
}
|
||||
|
||||
addLog(message, type = 'info') {
|
||||
const container = document.getElementById('logs-container');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry ' + type;
|
||||
entry.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
|
||||
container.appendChild(entry);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
clearLogs() {
|
||||
const container = document.getElementById('logs-container');
|
||||
container.innerHTML = '<div class="log-entry info">Логи очищены</div>';
|
||||
}
|
||||
|
||||
showLoginScreen() {
|
||||
document.getElementById('login-screen').classList.remove('hidden');
|
||||
document.getElementById('main-screen').classList.add('hidden');
|
||||
}
|
||||
|
||||
showMainScreen() {
|
||||
document.getElementById('login-screen').classList.add('hidden');
|
||||
document.getElementById('main-screen').classList.remove('hidden');
|
||||
}
|
||||
|
||||
showLoading(show) {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
if (show) {
|
||||
overlay.classList.remove('hidden');
|
||||
} else {
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Form Handlers ============
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
await login(username, password);
|
||||
});
|
||||
|
||||
document.getElementById('play-btn').addEventListener('click', async () => {
|
||||
await launchInstance();
|
||||
});
|
||||
|
||||
document.getElementById('install-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = {
|
||||
name: document.getElementById('install-name').value,
|
||||
version: document.getElementById('install-mc-version').value,
|
||||
loader: document.getElementById('install-loader').value
|
||||
};
|
||||
|
||||
await installInstance(formData);
|
||||
});
|
||||
|
||||
// Expose functions globally for inline handlers
|
||||
window.closeInstallModal = closeInstallModal;
|
||||
const app = new LauncherApp();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ def parse_args():
|
||||
mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload")
|
||||
mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers")
|
||||
mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests")
|
||||
mode_group.add_argument("--sync", action="store_true", help="Sync mode - sync with main server as mirror")
|
||||
|
||||
# Additional options
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
|
||||
@@ -53,6 +54,43 @@ async def run_test_mode():
|
||||
logger.info("All packs validated successfully")
|
||||
sys.exit(0)
|
||||
|
||||
async def run_sync_mode():
|
||||
"""Sync with main server as mirror"""
|
||||
import os
|
||||
|
||||
main_url = os.environ.get("MAIN_SERVER_URL")
|
||||
if not main_url:
|
||||
logger.error("MAIN_SERVER_URL not set. Run: MAIN_SERVER_URL=http://main:1582 python cli.py --sync")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"Starting mirror sync from {main_url}")
|
||||
|
||||
# Get version from main
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get version
|
||||
try:
|
||||
resp = await client.get(f"{main_url}/launcher/version")
|
||||
data = resp.json()
|
||||
version = data.get("version")
|
||||
logger.info(f"Main server version: {version}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get version from main: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Get sync manifest
|
||||
try:
|
||||
resp = await client.get(f"{main_url}/launcher/sync/{version}")
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Files to sync: {len(sync_data.get('files', []))}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get sync manifest: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Sync happens during server startup in mirror mode
|
||||
# Just verify we can reach main
|
||||
logger.info("Mirror sync configured. Server will sync on startup.")
|
||||
|
||||
def run_production_mode(host: str, port: int, workers: int):
|
||||
"""Run with multiple workers"""
|
||||
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
|
||||
|
||||
+351
-21
@@ -12,7 +12,7 @@ import json
|
||||
import structlog
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
|
||||
# Disable httpx debug logging
|
||||
@@ -22,13 +22,18 @@ logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||
from models import PackMeta
|
||||
from middleware import LoggingMiddleware
|
||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
|
||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode, run_sync_mode
|
||||
from log_manager import init_logging
|
||||
|
||||
from auth import get_current_user, router as auth_router, init_db, verify_jwt
|
||||
from roles import Permissions, has_permission
|
||||
from admin_router import router as admin_router
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import aiofiles
|
||||
import mimetypes
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Cache for manifests - expires after 5 minutes
|
||||
@@ -37,6 +42,18 @@ manifest_cache = TTLCache(maxsize=100, ttl=300)
|
||||
BUILDS_DIR = Path("builds")
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
|
||||
# Mirror configuration
|
||||
LAUNCHER_MIRRORS = {
|
||||
"main": "http://87.120.187.36:1582",
|
||||
"mirror-1": "http://212.22.82.243:1582",
|
||||
}
|
||||
|
||||
# Server role: "main" or "mirror"
|
||||
SERVER_ROLE = os.environ.get("SERVER_ROLE", "main")
|
||||
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "") # For mirrors to sync from
|
||||
SYNC_API_KEY = os.environ.get("SYNC_API_KEY", "changeme") # API key for mirror sync
|
||||
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror
|
||||
|
||||
# IP Filtering Configuration
|
||||
import os
|
||||
import middleware as mw
|
||||
@@ -148,6 +165,50 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
logger.info("All packs ready. Server is running.")
|
||||
|
||||
# Mirror sync with main server
|
||||
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
||||
logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}")
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/version")
|
||||
main_data = resp.json()
|
||||
main_version = main_data.get("version")
|
||||
logger.info(f"Main server version: {main_version}")
|
||||
|
||||
# Get sync manifest with API key
|
||||
headers = {"X-Sync-Key": SYNC_API_KEY}
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/sync/{main_version}", headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Sync failed: {resp.status_code} - {resp.text}")
|
||||
raise Exception(f"Sync auth failed: {resp.status_code}")
|
||||
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Need to sync {len(sync_data.get('files', []))} files")
|
||||
|
||||
# Download each file
|
||||
for f in sync_data.get("files", []):
|
||||
file_path = BUILDS_DIR / f["path"]
|
||||
if not file_path.exists():
|
||||
logger.info(f"Syncing: {f['path']}")
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download file
|
||||
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
|
||||
resp = await client.get(file_url, headers=headers)
|
||||
file_path.write_bytes(resp.content)
|
||||
logger.debug(f"Downloaded: {f['path']}")
|
||||
|
||||
# Delete removed files
|
||||
for deleted_file in sync_data.get("delete", []):
|
||||
del_path = BUILDS_DIR / deleted_file
|
||||
if del_path.exists():
|
||||
del_path.unlink()
|
||||
logger.info(f"Deleted: {deleted_file}")
|
||||
|
||||
logger.info("Mirror sync complete")
|
||||
except Exception as e:
|
||||
logger.warning(f"Mirror sync failed: {e}")
|
||||
|
||||
# Scan launcher versions and generate meta
|
||||
logger.info("Scanning launcher versions...")
|
||||
|
||||
@@ -172,6 +233,54 @@ async def lifespan(app: FastAPI):
|
||||
global proxy_client
|
||||
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||
|
||||
# Start background sync task for mirrors
|
||||
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
||||
import asyncio
|
||||
|
||||
async def periodic_sync():
|
||||
sync_interval = 7200 # 2 hours
|
||||
while True:
|
||||
await asyncio.sleep(sync_interval)
|
||||
try:
|
||||
logger.info("Periodic mirror sync started...")
|
||||
headers = {"X-Sync-Key": SYNC_API_KEY}
|
||||
|
||||
resp = await proxy_client.get(f"{MAIN_SERVER_URL}/launcher/version")
|
||||
main_data = resp.json()
|
||||
main_version = main_data.get("version")
|
||||
|
||||
resp = await proxy_client.get(
|
||||
f"{MAIN_SERVER_URL}/launcher/sync/{main_version}",
|
||||
headers=headers
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Periodic sync failed: {resp.status_code}")
|
||||
continue
|
||||
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Periodic sync: {len(sync_data.get('files', []))} files")
|
||||
|
||||
for f in sync_data.get("files", []):
|
||||
file_path = BUILDS_DIR / f["path"]
|
||||
if not file_path.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
|
||||
resp = await proxy_client.get(file_url, headers=headers)
|
||||
file_path.write_bytes(resp.content)
|
||||
logger.debug(f"Synced: {f['path']}")
|
||||
|
||||
for deleted_file in sync_data.get("delete", []):
|
||||
del_path = BUILDS_DIR / deleted_file
|
||||
if del_path.exists():
|
||||
del_path.unlink()
|
||||
logger.info(f"Deleted: {deleted_file}")
|
||||
|
||||
logger.info("Periodic mirror sync complete")
|
||||
except Exception as e:
|
||||
logger.warning(f"Periodic sync error: {e}")
|
||||
|
||||
asyncio.create_task(periodic_sync())
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup proxy client
|
||||
@@ -512,6 +621,136 @@ app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
|
||||
# Add Logging middleware
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
|
||||
# ====================== ОПТИМИЗАЦИЯ ЗАГРУЗКИ ФАЙЛОВ ======================
|
||||
|
||||
class CacheControlMiddleware:
|
||||
"""Middleware for caching static and large files"""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
|
||||
# Skip caching for dynamic endpoints
|
||||
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"])
|
||||
if skip_cache:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# Add caching headers for static files
|
||||
async def send_wrapper(status, headers, *args, **kwargs):
|
||||
# Add cache headers for static files
|
||||
cache_headers = [
|
||||
(b"cache-control", b"public, max-age=86400"), # 24 hours
|
||||
(b"etag", b'"file-etag"'),
|
||||
]
|
||||
headers = list(headers) + cache_headers
|
||||
await send(status, headers, *args, **kwargs)
|
||||
|
||||
# Use original send
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
app.add_middleware(CacheControlMiddleware)
|
||||
|
||||
|
||||
# Cache for file hashes (ETag)
|
||||
file_etag_cache = TTLCache(maxsize=1000, ttl=3600)
|
||||
|
||||
|
||||
async def get_etag_for_file(file_path: Path) -> str:
|
||||
"""Get or calculate ETag for file"""
|
||||
cache_key = str(file_path)
|
||||
|
||||
if cache_key in file_etag_cache:
|
||||
return file_etag_cache[cache_key]
|
||||
|
||||
# Calculate from file size + mtime
|
||||
stat = file_path.stat()
|
||||
etag = f'"{stat.st_size}-{stat.st_mtime}"'
|
||||
file_etag_cache[cache_key] = etag
|
||||
|
||||
return etag
|
||||
|
||||
|
||||
async def send_file_async(
|
||||
file_path: Path,
|
||||
request: Request,
|
||||
content_type: str = None,
|
||||
cache: bool = True
|
||||
):
|
||||
"""Optimized async file serving with Range support"""
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
|
||||
# Determine content type
|
||||
if content_type is None:
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
|
||||
# Check for Range header (for resumable downloads)
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
if range_header:
|
||||
# Parse Range header
|
||||
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
|
||||
if match:
|
||||
start = int(match.group(1))
|
||||
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
|
||||
else:
|
||||
start, end = 0, file_size - 1
|
||||
|
||||
content_length = end - start + 1
|
||||
|
||||
# Read chunk asynchronously
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
await f.seek(start)
|
||||
chunk = await f.read(content_length)
|
||||
|
||||
# Return 206 Partial Content
|
||||
return StreamingResponse(
|
||||
iter([chunk]),
|
||||
status_code=206,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(content_length),
|
||||
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Return full file with streaming
|
||||
async def file_iterator():
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(65536) # 64KB chunks
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
# Calculate ETag
|
||||
etag = await get_etag_for_file(file_path)
|
||||
|
||||
return StreamingResponse(
|
||||
file_iterator(),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(file_size),
|
||||
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
|
||||
"ETag": etag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
)
|
||||
|
||||
# Register routers
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
@@ -583,15 +822,20 @@ async def activate_pass_page():
|
||||
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
|
||||
|
||||
@app.get("/packs")
|
||||
async def list_packs(current_user: dict = Depends(get_current_user)):
|
||||
"""List all available packs - требует проходку для просмотра"""
|
||||
|
||||
# Проверяем, есть ли право на просмотр сборок
|
||||
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Для просмотра сборок требуется активная проходка"
|
||||
)
|
||||
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")
|
||||
|
||||
packs = []
|
||||
|
||||
@@ -776,7 +1020,7 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
size=full_path.stat().st_size,
|
||||
client_ip=client_ip)
|
||||
|
||||
return FileResponse(full_path, direct_passthrough=True)
|
||||
return await send_file_async(full_path, request, cache=True)
|
||||
|
||||
|
||||
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
|
||||
@@ -877,11 +1121,14 @@ def generate_launcher_builds_meta():
|
||||
logger.warning(f"Failed to generate launcher meta: {e}")
|
||||
return
|
||||
|
||||
mirrors = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
|
||||
|
||||
meta = {
|
||||
"version": version,
|
||||
"type": "builds",
|
||||
"release_date": datetime.utcnow().isoformat(),
|
||||
"files": files
|
||||
"files": files,
|
||||
"mirrors": mirrors
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1128,16 +1375,16 @@ async def get_launcher_version():
|
||||
|
||||
|
||||
@app.get("/launcher/download/jar")
|
||||
async def download_launcher_jar():
|
||||
async def download_launcher_jar(request: Request = None):
|
||||
"""Download launcher JAR file"""
|
||||
# Prefer new shaded JAR, fallback to old
|
||||
file_path = BUILDS_DIR / "zernmclauncher.jar"
|
||||
if not file_path.exists():
|
||||
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "JAR file not found")
|
||||
|
||||
if request:
|
||||
return await send_file_async(file_path, request, content_type="application/java-archive", cache=True)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="zernmclauncher.jar",
|
||||
@@ -1146,13 +1393,16 @@ async def download_launcher_jar():
|
||||
|
||||
|
||||
@app.get("/launcher/download/exe")
|
||||
async def download_launcher_exe():
|
||||
async def download_launcher_exe(request: Request = None):
|
||||
"""Download launcher EXE file (Windows)"""
|
||||
file_path = BUILDS_DIR / "ZernMCLauncher.exe"
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "EXE file not found")
|
||||
|
||||
if request:
|
||||
return await send_file_async(file_path, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="ZernMCLauncher.exe",
|
||||
@@ -1161,7 +1411,7 @@ async def download_launcher_exe():
|
||||
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_launcher_zip(filename: str):
|
||||
async def download_launcher_zip(filename: str, request: Request = None):
|
||||
"""Download specific launcher ZIP archive"""
|
||||
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
|
||||
if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"):
|
||||
@@ -1172,6 +1422,9 @@ async def download_launcher_zip(filename: str):
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "ZIP file not found")
|
||||
|
||||
if request:
|
||||
return await send_file_async(file_path, request, content_type="application/zip", cache=True)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
@@ -1239,6 +1492,76 @@ async def get_launcher_meta_list():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/launcher/mirrors")
|
||||
async def get_launcher_mirrors():
|
||||
"""Get list of available mirrors"""
|
||||
mirrors_list = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
|
||||
return {"mirrors": mirrors_list}
|
||||
|
||||
|
||||
# ====================== SYNC FOR MIRRORS ======================
|
||||
|
||||
def verify_sync_api_key(request: Request):
|
||||
"""Verify API key for sync endpoints"""
|
||||
api_key = request.headers.get("X-Sync-Key")
|
||||
if not api_key or api_key != SYNC_API_KEY:
|
||||
raise HTTPException(401, "Invalid or missing sync API key")
|
||||
|
||||
|
||||
@app.get("/launcher/sync/{version}")
|
||||
async def get_sync_manifest(version: str, request: Request):
|
||||
"""Get sync manifest for mirror servers - returns files to download/delete"""
|
||||
verify_sync_api_key(request)
|
||||
|
||||
if SERVER_ROLE != "main":
|
||||
raise HTTPException(403, "Sync only available on main server")
|
||||
|
||||
# Check for incremental sync (if-modified-since)
|
||||
last_sync = request.headers.get("If-Modified-Since")
|
||||
|
||||
# Get server meta
|
||||
meta = get_launcher_version_meta(version)
|
||||
if not meta:
|
||||
raise HTTPException(404, f"Version {version} not found")
|
||||
|
||||
# Build sync response
|
||||
sync_data = {
|
||||
"version": version,
|
||||
"files": [],
|
||||
"delete": [], # Files that were removed from main
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
for f in meta.get("files", []):
|
||||
sync_data["files"].append({
|
||||
"path": f["path"],
|
||||
"size": f["size"],
|
||||
"hash": f["hash"],
|
||||
"url": f"/launcher/file/{version}/{f['path']}"
|
||||
})
|
||||
|
||||
return sync_data
|
||||
|
||||
|
||||
@app.get("/launcher/sync/{version}/file/{file_path:path}")
|
||||
async def sync_download_file(version: str, file_path: str, request: Request):
|
||||
"""Download file for mirror sync"""
|
||||
verify_sync_api_key(request)
|
||||
|
||||
if SERVER_ROLE != "main":
|
||||
raise HTTPException(403, "Sync only available on main server")
|
||||
|
||||
full_path = BUILDS_DIR / file_path
|
||||
|
||||
if ".." in file_path:
|
||||
raise HTTPException(403, "Invalid path")
|
||||
|
||||
if not full_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
return await send_file_async(full_path, request, cache=False)
|
||||
|
||||
|
||||
@app.get("/launcher/meta/{version}")
|
||||
async def get_launcher_version_meta_handler(version: str):
|
||||
"""Get meta for specific launcher version"""
|
||||
@@ -1306,17 +1629,21 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
|
||||
else:
|
||||
raise HTTPException(404, "File not found: " + file_path)
|
||||
|
||||
return FileResponse(full_path, direct_passthrough=True)
|
||||
return await send_file_async(full_path, request, cache=True)
|
||||
|
||||
|
||||
@app.get("/launcher/download/zip/{version}")
|
||||
async def download_launcher_zip_version(version: str):
|
||||
async def download_launcher_zip_version(version: str, request: Request = None):
|
||||
"""Download full ZIP for specific version (for new installs)"""
|
||||
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
|
||||
|
||||
if not zip_path.exists():
|
||||
raise HTTPException(404, f"ZIP for version {version} not found")
|
||||
|
||||
if request:
|
||||
return await send_file_async(zip_path, request, content_type="application/zip", cache=True)
|
||||
|
||||
# Fallback without request
|
||||
return FileResponse(
|
||||
path=zip_path,
|
||||
filename=f"ZernMC-win-{version}.zip",
|
||||
@@ -1780,6 +2107,9 @@ if __name__ == "__main__":
|
||||
if args.test:
|
||||
import asyncio
|
||||
asyncio.run(run_test_mode())
|
||||
elif args.sync:
|
||||
import asyncio
|
||||
asyncio.run(run_sync_mode())
|
||||
elif args.dev:
|
||||
run_development_mode(args.host, args.port, args.reload)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lightweight Mirror Server - only serves static files
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import structlog
|
||||
import httpx
|
||||
|
||||
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582")
|
||||
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta")
|
||||
PORT = int(os.environ.get("PORT", "1582"))
|
||||
|
||||
BUILDS_DIR = Path("builds")
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
PACKS_DIR = Path("packs")
|
||||
|
||||
BUILDS_DIR.mkdir(exist_ok=True)
|
||||
PACKS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logging = structlog.get_logger()
|
||||
|
||||
|
||||
async def sync_with_main():
|
||||
"""Sync files from main server"""
|
||||
logging.info(f"Syncing from {MAIN_SERVER_URL}")
|
||||
|
||||
client = httpx.AsyncClient(timeout=120.0)
|
||||
headers = {"X-Master-Key": MASTER_KEY}
|
||||
|
||||
try:
|
||||
# Get launcher info
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logging.error(f"Failed to get launcher info: {resp.status_code}")
|
||||
return
|
||||
|
||||
data = resp.json()
|
||||
current_version = data.get("current_version", "1.0.9")
|
||||
files = data.get("files", {})
|
||||
zips = files.get("zips", [])
|
||||
logging.info(f"Current version: {current_version}, zips: {len(zips)}")
|
||||
|
||||
# Download latest ZIP
|
||||
for z in zips:
|
||||
if not z.get("is_legacy"):
|
||||
zip_filename = z.get("filename")
|
||||
zip_path = BUILDS_DIR / zip_filename
|
||||
if not zip_path.exists():
|
||||
logging.info(f"Downloading {zip_filename}...")
|
||||
# Try direct download
|
||||
download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}"
|
||||
resp = await client.get(download_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
zip_path.write_bytes(resp.content)
|
||||
logging.info(f"Downloaded {zip_filename}")
|
||||
|
||||
# Extract
|
||||
version = z.get("version")
|
||||
extract_to = VERSIONS_DIR / version
|
||||
extract_to.mkdir(parents=True, exist_ok=True)
|
||||
import zipfile
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
zf.extractall(extract_to)
|
||||
logging.info(f"Extracted {version}")
|
||||
|
||||
# Get launcher meta
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
(BUILDS_DIR / "meta.json").write_text(resp.text)
|
||||
logging.info("Meta synced")
|
||||
|
||||
# Sync packs list
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
packs_data = resp.json()
|
||||
packs = packs_data.get("packs", [])
|
||||
logging.info(f"Found {len(packs)} packs")
|
||||
|
||||
for pack in packs:
|
||||
pack_name = pack.get("name")
|
||||
pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}"
|
||||
resp = await client.get(pack_meta_url, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
pack_dir = PACKS_DIR / pack_name
|
||||
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||
(pack_dir / "meta.json").write_text(resp.text)
|
||||
logging.info(f"Synced pack: {pack_name}")
|
||||
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
logging.info("Sync complete")
|
||||
|
||||
|
||||
async def run_server():
|
||||
"""Run static server"""
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
import aiofiles
|
||||
import mimetypes
|
||||
import re
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="ZernMC Mirror")
|
||||
|
||||
async def send_file(file_path: Path, request: Request):
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
file_size = file_path.stat().st_size
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
if range_header:
|
||||
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
|
||||
if match:
|
||||
start = int(match.group(1))
|
||||
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
|
||||
content_length = end - start + 1
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
await f.seek(start)
|
||||
chunk = await f.read(content_length)
|
||||
return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type,
|
||||
headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)})
|
||||
|
||||
async def file_iter():
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(file_iter(), media_type=content_type,
|
||||
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)})
|
||||
|
||||
@app.get("/launcher/info")
|
||||
async def get_launcher_info():
|
||||
meta_path = BUILDS_DIR / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
return {"current_version": "unknown", "files": {}}
|
||||
|
||||
@app.get("/launcher/version")
|
||||
async def get_version():
|
||||
return await get_launcher_info()
|
||||
|
||||
@app.get("/launcher/file/{version}/{file_path:path}")
|
||||
async def get_launcher_file(version: str, file_path: str, request: Request):
|
||||
full_path = BUILDS_DIR / "versions" / version / file_path
|
||||
if ".." in file_path:
|
||||
raise HTTPException(403, "Invalid path")
|
||||
if not full_path.exists():
|
||||
raise HTTPException(404, f"File not found: {file_path}")
|
||||
return await send_file(full_path, request)
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_zip(filename: str, request: Request):
|
||||
return await send_file(BUILDS_DIR / filename, request)
|
||||
|
||||
@app.get("/launcher/meta/{version}")
|
||||
async def get_meta(version: str):
|
||||
meta_path = BUILDS_DIR / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
raise HTTPException(404, "Meta not found")
|
||||
|
||||
@app.get("/launcher/mirrors")
|
||||
async def get_mirrors():
|
||||
return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]}
|
||||
|
||||
@app.get("/packs")
|
||||
async def list_packs():
|
||||
import json
|
||||
packs = []
|
||||
for pack_dir in PACKS_DIR.iterdir():
|
||||
if pack_dir.is_dir():
|
||||
meta_path = pack_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
packs.append({
|
||||
"name": pack_dir.name,
|
||||
"version": meta.get("version", 1),
|
||||
"files_count": len(meta.get("files", {}))
|
||||
})
|
||||
except:
|
||||
packs.append({"name": pack_dir.name, "error": "invalid"})
|
||||
return {"packs": packs}
|
||||
|
||||
@app.get("/pack/{pack_name}")
|
||||
async def get_pack(pack_name: str):
|
||||
meta_path = PACKS_DIR / pack_name / "meta.json"
|
||||
if meta_path.exists():
|
||||
import json
|
||||
return json.loads(meta_path.read_text())
|
||||
raise HTTPException(404, "Pack not found")
|
||||
|
||||
@app.get("/pack/meta/{pack_name}")
|
||||
async def get_pack_meta(pack_name: str):
|
||||
return await get_pack(pack_name)
|
||||
|
||||
@app.get("/pack/{pack_name}/diff")
|
||||
async def get_pack_diff(pack_name: str):
|
||||
# For mirror, just return empty diff (no local changes)
|
||||
return {"added": [], "removed": [], "changed": []}
|
||||
|
||||
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||
async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
return await send_file(PACKS_DIR / pack_name / file_path, request)
|
||||
|
||||
config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
||||
|
||||
async def main():
|
||||
logging.info("Starting ZernMC Mirror Server")
|
||||
await sync_with_main()
|
||||
await run_server()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user