Launcher UI redesign + server mirror sync + file download optimization

This commit is contained in:
SashegDev
2026-05-09 23:47:04 +00:00
parent 59480217aa
commit a8f3ca5049
10 changed files with 2266 additions and 703 deletions
@@ -21,6 +21,7 @@ import java.util.jar.Manifest;
public class Bootstrap { public class Bootstrap {
private static final String JAR_NAME = "zernmclauncher.jar"; private static final String JAR_NAME = "zernmclauncher.jar";
private static final String BASE_URL = "http://87.120.187.36:1582"; 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 baseDir;
private static Path binDir; private static Path binDir;
@@ -51,6 +52,11 @@ public class Bootstrap {
log("Локальная версия: " + currentVersion); log("Локальная версия: " + currentVersion);
log("Версия на сервере: " + serverVersion); log("Версия на сервере: " + serverVersion);
// Загружаем mirrors
loadMirrors();
log("Основной сервер: " + BASE_URL);
log("Mirrors доступны: " + (MIRRORS.size() + 1));
if (isNewer(serverVersion, currentVersion)) { if (isNewer(serverVersion, currentVersion)) {
log("Доступно обновление!"); log("Доступно обновление!");
downloadUpdate(serverVersion); downloadUpdate(serverVersion);
@@ -248,38 +254,94 @@ public class Bootstrap {
} }
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception { 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(); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000); conn.setConnectTimeout(10000);
conn.setReadTimeout(60000); conn.setReadTimeout(60000);
if (conn.getResponseCode() != 200) { 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()); Files.createDirectories(outPath.getParent());
long downloaded = 0; long downloaded = 0;
long lastUpdate = 0;
long startTime = System.currentTimeMillis();
try (InputStream in = conn.getInputStream(); try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(outPath.toFile())) { OutputStream out = new FileOutputStream(outPath.toFile())) {
byte[] buf = new byte[8192]; byte[] buf = new byte[65536];
int len; int len;
while ((len = in.read(buf)) > 0) { while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len); out.write(buf, 0, len);
downloaded += 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); long elapsed = System.currentTimeMillis() - startTime;
String expectedHash = expectedSize > 0 ? "" : ""; double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
if (downloaded != expectedSize) { System.out.println(String.format("\r[%s] %s - %.1f MB (%.1f MB/s) - Готово!",
log("Предупреждение: размер " + filePath + " не совпадает"); getProgressBar(downloaded, expectedSize),
fileName,
downloaded / 1024.0 / 1024.0,
speed
));
} }
// Выводим прогресс private static String getProgressBar(long current, long total) {
System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB"); 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 { private static class FileMeta {
@@ -376,7 +438,8 @@ public class Bootstrap {
private static Path findJava() { private static Path findJava() {
String os = System.getProperty("os.name").toLowerCase(); 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 рядом с лаунчером // Сначала ищем jre21/bin/java рядом с лаунчером
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe); Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
@@ -407,4 +470,46 @@ public class Bootstrap {
return javaBin; 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) { public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
return launchService.launch(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());
}
}
} }
@@ -69,11 +69,16 @@ public class InstanceService {
} }
private InstanceInfo toInstanceInfo(Instance instance) { private InstanceInfo toInstanceInfo(Instance instance) {
// Определяем категорию: ZernMC или локальная
String name = instance.getName().toLowerCase();
String category = name.contains("zernmc") ? "zernmc" : "local";
return new InstanceInfo( return new InstanceInfo(
instance.getName(), instance.getName(),
instance.getPath().toString(), instance.getPath().toString(),
instance.getMinecraftVersion(), instance.getMinecraftVersion(),
instance.getLoaderType() instance.getLoaderType(),
category
); );
} }
@@ -82,17 +87,20 @@ public class InstanceService {
private String path; private String path;
private String version; private String version;
private String loaderType; 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.name = name;
this.path = path; this.path = path;
this.version = version; this.version = version;
this.loaderType = loaderType; this.loaderType = loaderType;
this.category = category;
} }
public String getName() { return name; } public String getName() { return name; }
public String getPath() { return path; } public String getPath() { return path; }
public String getVersion() { return version; } public String getVersion() { return version; }
public String getLoaderType() { return loaderType; } 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.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStream; import java.io.InputStream;
@@ -227,8 +228,10 @@ public class JFXLauncher extends Application {
engine.load(url); engine.load(url);
stage.setTitle(APP_TITLE); stage.setTitle(APP_TITLE);
stage.setWidth(1200); stage.setWidth(1280);
stage.setHeight(800); stage.setHeight(800);
stage.setMinWidth(800);
stage.setMinHeight(600);
stage.setScene(new Scene(webView)); stage.setScene(new Scene(webView));
stage.show(); stage.show();
@@ -256,6 +259,8 @@ public class JFXLauncher extends Application {
server.createContext("/api/install", this::handleInstall); server.createContext("/api/install", this::handleInstall);
server.createContext("/api/logs", this::handleLogs); server.createContext("/api/logs", this::handleLogs);
server.createContext("/api/game-logs", this::handleGameLogs); 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("/api/exit", this::handleExit);
server.createContext("/assets/", this::handleStatic); server.createContext("/assets/", this::handleStatic);
@@ -394,6 +399,49 @@ public class JFXLauncher extends Application {
sendJson(exchange, Map.of("success", true, "data", getGameLogs())); 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) { private void handleExit(HttpExchange exchange) {
log("Выход..."); log("Выход...");
if (mainStage != null) mainStage.close(); if (mainStage != null) mainStage.close();
+171 -60
View File
@@ -5,93 +5,204 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title> <title>ZernMC Launcher</title>
<link rel="stylesheet" href="style.css"> <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> </head>
<body> <body>
<canvas id="grid-canvas"></canvas>
<div id="app"> <div id="app">
<!-- Экран логина --> <!-- Login Screen -->
<div id="login-screen" class="screen"> <div id="login-screen" class="screen hidden">
<div class="login-container"> <div class="login-container">
<h1 class="logo">ZernMC</h1> <div class="logo-section">
<p class="subtitle">Private Launcher</p> <div class="logo-placeholder">
<form id="login-form"> <svg width="80" height="80" viewBox="0 0 80 80" fill="none">
<input type="text" id="username" placeholder="Никнейм" required> <rect width="80" height="80" rx="20" fill="#e94560"/>
<input type="password" id="password" placeholder="Пароль" required> <path d="M25 40 L40 25 L55 40 L40 55 Z" fill="white"/>
<button type="submit" class="btn-primary">Войти</button> </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> </form>
<div id="login-error" class="error hidden"></div>
</div> </div>
</div> </div>
<!-- Главное меню --> <!-- Main Screen -->
<div id="main-screen" class="screen hidden"> <div id="main-screen" class="screen hidden">
<!-- Хедер --> <div class="main-layout">
<header class="header"> <!-- Left Sidebar -->
<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">
<!-- Слева: выбор сборки -->
<aside class="sidebar"> <aside class="sidebar">
<h2>Сборки</h2> <div class="sidebar-header">
<div id="instances-list" class="instances-container"> <div class="logo-small">
<!-- Динамически заполняется через JS --> <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> </div>
</aside> </aside>
<!-- По центру: логи --> <!-- Main Content -->
<section class="logs-panel"> <main class="main-content">
<div class="logs-section">
<div class="logs-header">
<h2>Логи</h2> <h2>Логи</h2>
<div id="logs-container"></div> <button class="btn-clear-logs" id="clear-logs">Очистить</button>
</section> </div>
<div id="logs-container" class="logs-container">
<div class="log-entry info">Ожидание запуска...</div>
</div>
</div>
</main> </main>
<!-- Низ: управление --> <!-- Right Panel - Play Button -->
<footer class="footer"> <div class="right-panel">
<div class="instance-info"> <button id="play-btn" class="btn-play">
<span id="selected-name">-</span> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<span id="selected-version">-</span> <polygon points="5 3 19 12 5 21 5 3"/>
<span id="selected-loader">-</span> </svg>
ИГРАТЬ
</button>
</div>
</div> </div>
<button id="play-btn" class="btn-play" disabled>Выберите сборку</button>
</footer>
</div> </div>
<!-- Модальное окно установки --> <!-- Download Modal -->
<div id="install-modal" class="modal hidden"> <div id="download-modal" class="modal hidden">
<div class="modal-content"> <div class="modal-content">
<h2>Установка сборки</h2> <div class="modal-header">
<form id="install-form"> <h2>Скачать сборку</h2>
<label>Версия Minecraft <button class="modal-close" id="close-download-modal">&times;</button>
<select id="install-mc-version"> </div>
<option value="1.20.4">1.20.4</option>
<option value="1.20.2">1.20.2</option> <div class="modal-tabs">
<option value="1.20.1">1.20.1</option> <button class="tab-btn active" data-tab="zernmc">ZernMC сборки</button>
<option value="1.19.2">1.19.2</option> <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> </select>
</label> </div>
<label>Загрузчик <div class="form-group">
<select id="install-loader"> <label>Название сборки (системное)</label>
<option value="vanilla">Vanilla</option> <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="fabric">Fabric</option>
<option value="forge">Forge</option> <option value="forge">Forge</option>
<option value="neoforge">NeoForge</option> <option value="neoforge">NeoForge</option>
</select> </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> </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>
<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>
</div> </div>
+432 -238
View File
@@ -1,311 +1,505 @@
const API_BASE = 'http://localhost:8080/api'; const API_BASE = '/api';
let state = { class LauncherApp {
loggedIn: false, constructor() {
account: null, this.state = 'INIT';
instances: [], this.username = null;
selectedInstance: null this.account = null;
}; this.currentInstance = null;
this.instances = [];
this.selectedInstance = null;
this.hasUpdate = false;
this.hasMismatches = false;
this.init();
}
// ============ API ============ async init() {
this.bindEvents();
this.initGridAnimation();
await this.checkAuth();
}
async function apiCall(endpoint, options = {}) { bindEvents() {
const url = `${API_BASE}${endpoint}`; document.getElementById('login-form').addEventListener('submit', (e) => {
const config = { e.preventDefault();
headers: { 'Content-Type': 'application/json' }, this.handleLogin();
...options });
document.getElementById('logout-btn').addEventListener('click', () => {
this.handleLogout();
});
document.getElementById('download-btn').addEventListener('click', () => {
this.showDownloadModal();
});
document.getElementById('close-download-modal').addEventListener('click', () => {
this.hideDownloadModal();
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
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);
}; };
try { window.addEventListener('resize', resize);
const response = await fetch(url, config);
const data = await response.json(); window.addEventListener('mousemove', (e) => {
return data; mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
} catch (e) { mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
log('Ошибка соединения с сервером: ' + e.message, 'error'); });
return { success: false, error: e.message };
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();
} }
}
// ============ Login ============ drawGrid(ctx, width, height, offsetX, offsetY) {
const gridSize = 50;
const dotSize = 1;
async function login(username, password) { ctx.fillStyle = '#e94560';
log('Выполняется вход...', 'info');
const result = await apiCall('/login', { 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();
}
}
}
async request(endpoint, options = {}) {
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
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();
}
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;
}
btn.disabled = true;
btnText.classList.add('hidden');
btnLoader.classList.remove('hidden');
errorEl.classList.add('hidden');
const result = await this.request('/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
}); });
if (result.success) { btn.disabled = false;
state.loggedIn = true; btnText.classList.remove('hidden');
state.account = result.data; btnLoader.classList.add('hidden');
log('Вход выполнен: ' + result.data.username, 'success');
showMainScreen();
await loadInstances();
} else {
log('Ошибка входа: ' + result.error, 'error');
showError(result.error);
}
return result;
}
function showError(message) {
const el = document.getElementById('login-error');
el.textContent = message;
el.classList.remove('hidden');
}
function hideError() {
document.getElementById('login-error').classList.add('hidden');
}
// ============ Account ============
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;
const statusEl = document.getElementById('account-status');
statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE';
statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive');
const roleEl = document.getElementById('account-role');
if (roleEl && result.data.roleName) {
roleEl.textContent = result.data.roleName;
}
} else {
showLoginScreen();
}
}
// ============ Instances ============
async function loadInstances() {
log('Загрузка списка сборок...', 'info');
const result = await apiCall('/instances');
if (result.success) { if (result.success) {
state.instances = result.data; this.account = result.data;
renderInstances(); this.username = result.data.username;
log('Загружено ' + result.data.length + ' сборок', 'success'); this.showMainScreen();
await this.loadInstances();
await this.loadCurrentInstance();
} else { } else {
log('Ошибка загрузки: ' + result.error, 'error'); this.showError(result.error || 'Ошибка входа');
}
} }
}
function renderInstances() { 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'); const container = document.getElementById('instances-list');
container.innerHTML = ''; container.innerHTML = '';
state.instances.forEach(inst => { this.instances.forEach(inst => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'instance-card'; card.className = 'instance-card';
if (this.selectedInstance && this.selectedInstance.name === inst.name) {
card.classList.add('selected');
}
card.dataset.name = inst.name; card.dataset.name = inst.name;
card.onclick = () => selectInstance(inst.name); card.onclick = () => this.selectInstance(inst.name);
let details = `
<span class="instance-version">${inst.version || '?'}</span>
<span class="instance-loader">${inst.loaderType || 'vanilla'}</span>
`;
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) { if (inst.isServerPack) {
details += `<span class="instance-server-version">v${inst.serverVersion}</span>`; details += `<span class="instance-card-server">v${inst.serverVersion}</span>`;
} }
card.innerHTML = ` card.innerHTML = `
<div class="instance-name">${inst.name}</div> <div class="instance-card-name">${this.escapeHtml(inst.name)}</div>
<div class="instance-details">${details}</div> <div class="instance-card-details">${details}</div>
`; `;
container.appendChild(card); 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 ============ selectInstance(name) {
this.selectedInstance = this.instances.find(i => i.name === name);
this.renderInstances();
this.updatePlayButton();
}
async function launchInstance() { async loadCurrentInstance() {
if (!state.selectedInstance) return; 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();
}
const name = state.selectedInstance.name; updatePlayButton() {
log('Запуск сборки: ' + name, 'info'); const btn = document.getElementById('play-btn');
const instance = this.selectedInstance || this.currentInstance;
const result = await apiCall('/launch', { 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', method: 'POST',
body: JSON.stringify({ name }) body: JSON.stringify({ name })
}); });
if (result.success) { if (result.success) {
log('Сборка запущена! PID: ' + result.data.pid, 'success'); this.addLog('Сборка запущена! PID: ' + (result.data?.pid || ''), 'success');
} else { } else {
log('Ошибка запуска: ' + result.error, 'error'); this.addLog('Ошибка: ' + (result.error || 'неизвестная ошибка'), 'error');
}
} }
}
// ============ Install ============ async showDownloadModal() {
document.getElementById('download-modal').classList.remove('hidden');
}
function openInstallModal() { hideDownloadModal() {
document.getElementById('install-modal').classList.remove('hidden'); document.getElementById('download-modal').classList.add('hidden');
} this.hideProgress();
}
function closeInstallModal() { switchTab(tab) {
document.getElementById('install-modal').classList.add('hidden'); 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 function installInstance(formData) { async installZernMCPack() {
log('Установка сборки...', 'info'); const packName = document.getElementById('zernmc-pack-select').value;
const result = await apiCall('/install', { 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', method: 'POST',
body: JSON.stringify(formData) body: JSON.stringify({
name: instanceName,
version: 'latest',
loader: 'vanilla'
})
}); });
if (result.success) { if (result.success) {
log('Сборка установлена!', 'success'); this.hideDownloadModal();
closeInstallModal(); await this.loadInstances();
await loadInstances(); await this.loadCurrentInstance();
this.addLog('Сборка установлена!', 'success');
} else { } else {
log('Ошибка установки: ' + result.error, 'error'); this.addLog('Ошибка установки: ' + (result.error || 'неизвестная ошибка'), 'error');
this.hideProgress();
}
} }
return result;
}
// ============ Logs ============ 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;
function log(message, type = 'info') { 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 container = document.getElementById('logs-container');
if (!container) return; const entry = document.createElement('div');
entry.className = 'log-entry ' + type;
const line = document.createElement('div'); entry.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
line.className = 'log-line ' + type; container.appendChild(entry);
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
container.appendChild(line);
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
function clearLogs() { clearLogs() {
document.getElementById('logs-container').innerHTML = ''; const container = document.getElementById('logs-container');
} container.innerHTML = '<div class="log-entry info">Логи очищены</div>';
}
// ============ Screens ============ showLoginScreen() {
function showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden'); document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden'); document.getElementById('main-screen').classList.add('hidden');
clearError(); }
}
function showMainScreen() { showMainScreen() {
document.getElementById('login-screen').classList.add('hidden'); document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('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 ============ showLoading(show) {
const overlay = document.getElementById('loading-overlay');
document.addEventListener('DOMContentLoaded', async () => { if (show) {
log('Запуск лаунчера...', 'info'); overlay.classList.remove('hidden');
await loadAccountInfo();
if (!state.loggedIn) {
showLoginScreen();
} else { } else {
showMainScreen(); overlay.classList.add('hidden');
await loadInstances(); }
} }
// Start polling for server logs escapeHtml(text) {
startLogPolling(); if (!text) return '';
}); const div = document.createElement('div');
div.textContent = text;
let lastLogLength = 0; return div.innerHTML;
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');
} }
});
lastLogLength = result.data.length;
}
// 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;
}
}, 2000);
} }
// ============ Form Handlers ============ const app = new LauncherApp();
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;
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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("--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("--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("--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 # Additional options
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") 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") logger.info("All packs validated successfully")
sys.exit(0) 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): def run_production_mode(host: str, port: int, workers: int):
"""Run with multiple workers""" """Run with multiple workers"""
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}") logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
+350 -20
View File
@@ -12,7 +12,7 @@ import json
import structlog import structlog
from cachetools import TTLCache from cachetools import TTLCache
from fastapi import Depends, FastAPI, HTTPException, Request, Response 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 from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
# Disable httpx debug logging # 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 pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
from models import PackMeta from models import PackMeta
from middleware import LoggingMiddleware 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 log_manager import init_logging
from auth import get_current_user, router as auth_router, init_db, verify_jwt from auth import get_current_user, router as auth_router, init_db, verify_jwt
from roles import Permissions, has_permission from roles import Permissions, has_permission
from admin_router import router as admin_router from admin_router import router as admin_router
import asyncio
import hashlib
import aiofiles
import mimetypes
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes # Cache for manifests - expires after 5 minutes
@@ -37,6 +42,18 @@ manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds") BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions" 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 # IP Filtering Configuration
import os import os
import middleware as mw import middleware as mw
@@ -148,6 +165,50 @@ async def lifespan(app: FastAPI):
logger.info("All packs ready. Server is running.") 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 # Scan launcher versions and generate meta
logger.info("Scanning launcher versions...") logger.info("Scanning launcher versions...")
@@ -172,6 +233,54 @@ async def lifespan(app: FastAPI):
global proxy_client global proxy_client
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) 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 yield
# Cleanup proxy client # Cleanup proxy client
@@ -512,6 +621,136 @@ app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
# Add Logging middleware # Add Logging middleware
app.add_middleware(LoggingMiddleware) 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 # Register routers
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
@@ -583,15 +822,20 @@ async def activate_pass_page():
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ====================== # ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
@app.get("/packs") @app.get("/packs")
async def list_packs(current_user: dict = Depends(get_current_user)): async def list_packs(request: Request):
"""List all available packs - требует проходку для просмотра""" """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): if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
raise HTTPException( raise HTTPException(403, "Requires active pass")
status_code=403,
detail="Для просмотра сборок требуется активная проходка"
)
packs = [] packs = []
@@ -776,7 +1020,7 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
size=full_path.stat().st_size, size=full_path.stat().st_size,
client_ip=client_ip) 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}") logger.warning(f"Failed to generate launcher meta: {e}")
return return
mirrors = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
meta = { meta = {
"version": version, "version": version,
"type": "builds", "type": "builds",
"release_date": datetime.utcnow().isoformat(), "release_date": datetime.utcnow().isoformat(),
"files": files "files": files,
"mirrors": mirrors
} }
try: try:
@@ -1128,16 +1375,16 @@ async def get_launcher_version():
@app.get("/launcher/download/jar") @app.get("/launcher/download/jar")
async def download_launcher_jar(): async def download_launcher_jar(request: Request = None):
"""Download launcher JAR file""" """Download launcher JAR file"""
# Prefer new shaded JAR, fallback to old
file_path = BUILDS_DIR / "zernmclauncher.jar" file_path = BUILDS_DIR / "zernmclauncher.jar"
if not file_path.exists():
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
if not file_path.exists(): if not file_path.exists():
raise HTTPException(404, "JAR file not found") 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( return FileResponse(
path=file_path, path=file_path,
filename="zernmclauncher.jar", filename="zernmclauncher.jar",
@@ -1146,13 +1393,16 @@ async def download_launcher_jar():
@app.get("/launcher/download/exe") @app.get("/launcher/download/exe")
async def download_launcher_exe(): async def download_launcher_exe(request: Request = None):
"""Download launcher EXE file (Windows)""" """Download launcher EXE file (Windows)"""
file_path = BUILDS_DIR / "ZernMCLauncher.exe" file_path = BUILDS_DIR / "ZernMCLauncher.exe"
if not file_path.exists(): if not file_path.exists():
raise HTTPException(404, "EXE file not found") 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( return FileResponse(
path=file_path, path=file_path,
filename="ZernMCLauncher.exe", filename="ZernMCLauncher.exe",
@@ -1161,7 +1411,7 @@ async def download_launcher_exe():
@app.get("/launcher/download/zip/{filename}") @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""" """Download specific launcher ZIP archive"""
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"] valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"): 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(): if not file_path.exists():
raise HTTPException(404, "ZIP file not found") 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( return FileResponse(
path=file_path, path=file_path,
filename=filename, 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}") @app.get("/launcher/meta/{version}")
async def get_launcher_version_meta_handler(version: str): async def get_launcher_version_meta_handler(version: str):
"""Get meta for specific launcher version""" """Get meta for specific launcher version"""
@@ -1306,17 +1629,21 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
else: else:
raise HTTPException(404, "File not found: " + file_path) 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}") @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)""" """Download full ZIP for specific version (for new installs)"""
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip" zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
if not zip_path.exists(): if not zip_path.exists():
raise HTTPException(404, f"ZIP for version {version} not found") 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( return FileResponse(
path=zip_path, path=zip_path,
filename=f"ZernMC-win-{version}.zip", filename=f"ZernMC-win-{version}.zip",
@@ -1780,6 +2107,9 @@ if __name__ == "__main__":
if args.test: if args.test:
import asyncio import asyncio
asyncio.run(run_test_mode()) asyncio.run(run_test_mode())
elif args.sync:
import asyncio
asyncio.run(run_sync_mode())
elif args.dev: elif args.dev:
run_development_mode(args.host, args.port, args.reload) run_development_mode(args.host, args.port, args.reload)
else: else:
+229
View File
@@ -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())