From a8f3ca5049be17c511ab52119e63166a1b452a1e Mon Sep 17 00:00:00 2001 From: SashegDev Date: Sat, 9 May 2026 23:47:04 +0000 Subject: [PATCH] Launcher UI redesign + server mirror sync + file download optimization --- .../sashegdev/zernmc/launcher/Bootstrap.java | 131 +- .../zernmc/launcher/api/LauncherAPI.java | 36 + .../api/instance/InstanceService.java | 12 +- .../zernmc/launcher/ui/jfx/JFXLauncher.java | 50 +- launcher/launcher/src/resources/ui/index.html | 235 +++- .../launcher/src/resources/ui/launcher.js | 776 +++++++----- launcher/launcher/src/resources/ui/style.css | 1050 ++++++++++++----- server/cli.py | 40 +- server/main.py | 410 ++++++- server/mirror.py | 229 ++++ 10 files changed, 2266 insertions(+), 703 deletions(-) create mode 100644 server/mirror.py diff --git a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java index b2e4b47..348c9b7 100644 --- a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java +++ b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java @@ -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 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 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 + )); + } + + 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 ? "=" : " "); } - - // Выводим прогресс - System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB"); + 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; + } + } } \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java index a0c4795..13f9936 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java @@ -71,4 +71,40 @@ public class LauncherAPI { public ApiResponse launch(String instanceName) { return launchService.launch(instanceName); } + + public ApiResponse> 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> getLoaderVersions(String mcVersion, String loader) { + try { + java.util.List versions = new java.util.ArrayList<>(); + switch (loader.toLowerCase()) { + case "fabric": + versions.add("0.15.11"); + versions.add("0.15.9"); + versions.add("0.15.8"); + versions.add("0.14.21"); + break; + case "forge": + versions.add("47.1.0"); + versions.add("46.0.1"); + versions.add("45.0.2"); + break; + case "neoforge": + versions.add("1.21-rc.2"); + versions.add("1.20.4-rc.4"); + break; + default: + break; + } + return ApiResponse.success(versions); + } catch (Exception e) { + return ApiResponse.error(e.getMessage()); + } + } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/instance/InstanceService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/instance/InstanceService.java index 7ce0647..a0f138c 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/instance/InstanceService.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/instance/InstanceService.java @@ -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; } } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index f39d8f4..43e1b2d 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -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 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 parseQuery(String query) { + Map 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(); diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html index 13583c7..73dce83 100644 --- a/launcher/launcher/src/resources/ui/index.html +++ b/launcher/launcher/src/resources/ui/index.html @@ -5,94 +5,205 @@ ZernMC Launcher + + + + +
- -
+ + - +
- - + + +
diff --git a/launcher/launcher/src/resources/ui/launcher.js b/launcher/launcher/src/resources/ui/launcher.js index 42f20d2..d5fe2b7 100644 --- a/launcher/launcher/src/resources/ui/launcher.js +++ b/launcher/launcher/src/resources/ui/launcher.js @@ -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 = ` - ${inst.version || '?'} - ${inst.loaderType || 'vanilla'} - `; - - if (inst.isServerPack) { - details += `v${inst.serverVersion}`; - } - - card.innerHTML = ` -
${inst.name}
-
${details}
- `; - - 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 = `${inst.version || '?'}`; + if (inst.loaderType && inst.loaderType !== 'vanilla') { + details += `${inst.loaderType}`; + } + if (inst.isServerPack) { + details += `v${inst.serverVersion}`; + } + + card.innerHTML = ` +
${this.escapeHtml(inst.name)}
+
${details}
+ `; + + 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 = 'ВЫБЕРИТЕ СБОРКУ'; + return; + } + + if (this.hasUpdate || this.hasMismatches) { + btn.disabled = false; + btn.className = 'btn-update'; + btn.innerHTML = 'ОБНОВИТЬ'; + } else { + btn.disabled = false; + btn.className = 'btn-play'; + btn.innerHTML = 'ИГРАТЬ'; + } + } + + renderCurrentInstance(instance) { + const container = document.getElementById('current-instance'); + let version = instance.version || 'Vanilla'; + if (instance.isServerPack) { + version = `v${instance.serverVersion}`; + } + container.innerHTML = ` +
+ ${this.escapeHtml(instance.name)} + ${this.escapeHtml(version)} +
+ `; + + if (this.account) { + document.getElementById('username-display').textContent = this.account.username; + const statusEl = document.getElementById('account-status'); + statusEl.textContent = this.account.passActive ? 'PRO' : 'FREE'; + statusEl.className = 'badge ' + (this.account.passActive ? 'active' : 'inactive'); + + const roleEl = document.getElementById('account-role'); + if (this.account.roleName) { + roleEl.textContent = this.account.roleName; + roleEl.style.display = 'inline-block'; + } else { + roleEl.style.display = 'none'; + } + } + } + + renderNoInstance() { + const container = document.getElementById('current-instance'); + container.innerHTML = ` +
+ Нет сборки + Нажмите скачать +
+ `; + } + + async launchInstance() { + 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 = '
Логи очищены
'; + } + + 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; \ No newline at end of file +const app = new LauncherApp(); \ No newline at end of file diff --git a/launcher/launcher/src/resources/ui/style.css b/launcher/launcher/src/resources/ui/style.css index 481035c..ac8d7e8 100644 --- a/launcher/launcher/src/resources/ui/style.css +++ b/launcher/launcher/src/resources/ui/style.css @@ -1,16 +1,27 @@ :root { - --bg-primary: #1a1a2e; - --bg-secondary: #16213e; - --bg-tertiary: #0f3460; - --accent: #e94560; - --accent-hover: #ff6b6b; - --text-primary: #eaeaea; - --text-secondary: #a0a0a0; + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --bg-card: #1a1a24; + --bg-card-hover: #222230; + --bg-sidebar: #0d0d12; + --accent-primary: #e94560; + --accent-secondary: #ff6b6b; + --accent-glow: rgba(233, 69, 96, 0.3); + --text-primary: #ffffff; + --text-secondary: #a0a0b0; + --text-muted: #606070; + --border-color: #2a2a3a; --success: #4ade80; + --error: #f87171; --warning: #fbbf24; - --error: #ef4444; - --border: #2d2d4a; - --shadow: rgba(0, 0, 0, 0.3); + --shadow-card: 0 4px 20px rgba(0, 0, 0, 0.4); + --shadow-glow: 0 0 30px var(--accent-glow); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --transition-fast: 150ms ease; + --transition-normal: 300ms ease; + --transition-slow: 500ms ease; } * { @@ -20,167 +31,427 @@ } body { - font-family: 'Segoe UI', system-ui, sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg-primary); color: var(--text-primary); - height: 100vh; - overflow: hidden; + min-height: 100vh; + overflow-x: hidden; +} + +#grid-canvas { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + opacity: 0.12; + pointer-events: none; } #app { - height: 100vh; - display: flex; - flex-direction: column; + position: relative; + z-index: 1; + min-height: 100vh; } -/* Screens */ .screen { - position: absolute; - inset: 0; + min-height: 100vh; display: flex; - flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn var(--transition-slow) forwards; } .hidden { display: none !important; } -/* Login Screen */ -#login-screen { - justify-content: center; - align-items: center; - background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } } +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ==================== LOGIN SCREEN ==================== */ .login-container { - background: var(--bg-secondary); - padding: 3rem; - border-radius: 16px; - box-shadow: 0 25px 50px var(--shadow); + background: var(--bg-card); + border-radius: var(--radius-lg); + padding: 48px; width: 100%; max-width: 400px; - border: 1px solid var(--border); + box-shadow: var(--shadow-card); + border: 1px solid var(--border-color); + animation: slideUp var(--transition-slow) forwards; } -.logo { - font-size: 2.5rem; +.logo-section { text-align: center; - color: var(--accent); - margin-bottom: 0.5rem; + margin-bottom: 40px; } -.subtitle { - text-align: center; - color: var(--text-secondary); - margin-bottom: 2rem; +.logo-placeholder { + display: inline-block; + margin-bottom: 16px; + animation: pulse 2s ease-in-out infinite; } -#login-form { +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +.app-title { + font-size: 28px; + font-weight: 700; + margin-bottom: 8px; + background: linear-gradient(135deg, var(--text-primary), var(--accent-primary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.app-version { + color: var(--text-muted); + font-size: 14px; +} + +.login-form { display: flex; flex-direction: column; - gap: 1rem; + gap: 16px; } -input, select { - background: var(--bg-primary); - border: 1px solid var(--border); +.input-group input { + width: 100%; + padding: 14px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); color: var(--text-primary); - padding: 0.875rem 1rem; - border-radius: 8px; - font-size: 1rem; - transition: border-color 0.2s; + font-size: 16px; + transition: var(--transition-fast); } -input:focus, select:focus { +.input-group input:focus { outline: none; - border-color: var(--accent); + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-glow); } -input::placeholder { - color: var(--text-secondary); +.input-group input::placeholder { + color: var(--text-muted); } .btn-primary { - background: var(--accent); - color: white; + width: 100%; + padding: 14px 24px; + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); border: none; - padding: 0.875rem 1rem; - border-radius: 8px; - font-size: 1rem; + border-radius: var(--radius-sm); + color: white; + font-size: 16px; font-weight: 600; cursor: pointer; - transition: background 0.2s; + transition: var(--transition-fast); + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + min-height: 48px; } .btn-primary:hover { - background: var(--accent-hover); + transform: translateY(-2px); + box-shadow: var(--shadow-glow); +} + +.btn-primary:active { + transform: translateY(0); } .btn-primary:disabled { - background: var(--text-secondary); + opacity: 0.7; cursor: not-allowed; + transform: none; } -.btn-secondary { - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border); - padding: 0.875rem 1rem; - border-radius: 8px; - font-size: 1rem; - cursor: pointer; - transition: background 0.2s; +.btn-text { + transition: opacity var(--transition-fast); } -.btn-secondary:hover { - background: var(--bg-secondary); +.btn-text.hidden { + opacity: 0; } -.error { +.btn-loader { + position: absolute; + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.error-message { color: var(--error); text-align: center; - margin-top: 1rem; - padding: 0.75rem; - border-radius: 8px; - background: rgba(239, 68, 68, 0.1); + font-size: 14px; + padding: 12px; + background: rgba(248, 113, 113, 0.1); + border-radius: var(--radius-sm); + animation: shake 0.5s ease; } -/* Main Screen */ -#main-screen { +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} + +/* ==================== MAIN LAYOUT ==================== */ +.main-layout { + display: grid; + grid-template-columns: 280px 1fr 200px; + width: 100%; + max-width: 1600px; + height: calc(100vh - 40px); + gap: 0; + background: var(--bg-secondary); + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + overflow: hidden; + animation: fadeIn var(--transition-slow) forwards; +} + +/* Sidebar */ +.sidebar { + background: var(--bg-sidebar); + border-right: 1px solid var(--border-color); display: flex; flex-direction: column; - height: 100vh; + padding: 20px; } -/* Header */ -.header { - background: var(--bg-secondary); - padding: 1rem 1.5rem; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--border); -} - -.header .logo { - font-size: 1.5rem; - margin: 0; -} - -.account-info { +.sidebar-header { display: flex; align-items: center; - gap: 1rem; + gap: 12px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 20px; } -#account-name { +.logo-small svg { + display: block; +} + +.header-info { + display: flex; + flex-direction: column; +} + +.header-title { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +.header-version { + font-size: 12px; + color: var(--text-muted); +} + +.sidebar-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; + overflow: hidden; +} + +.section-label { + font-size: 11px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); + margin-bottom: 12px; +} + +.current-instance-section { + flex-shrink: 0; +} + +.current-instance { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 16px; + transition: var(--transition-fast); +} + +.current-instance:hover { + border-color: var(--accent-primary); +} + +.instance-card-mini { + display: flex; + flex-direction: column; + gap: 8px; +} + +.instance-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.instance-version { + font-size: 13px; + color: var(--accent-primary); + background: rgba(233, 69, 96, 0.15); + padding: 4px 8px; + border-radius: 4px; + display: inline-block; + width: fit-content; +} + +.instances-section { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.instances-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.instance-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 12px; + cursor: pointer; + transition: var(--transition-fast); +} + +.instance-card:hover { + background: var(--bg-card-hover); + border-color: var(--accent-primary); +} + +.instance-card.selected { + border-color: var(--accent-primary); + background: rgba(233, 69, 96, 0.1); +} + +.instance-card-name { + font-size: 14px; + font-weight: 600; + margin-bottom: 6px; +} + +.instance-card-details { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.instance-card-version, +.instance-card-loader, +.instance-card-server { + font-size: 11px; + padding: 3px 6px; + border-radius: 4px; +} + +.instance-card-version { + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.instance-card-loader { + background: rgba(99, 102, 241, 0.2); + color: #818cf8; +} + +.instance-card-server { + background: rgba(251, 191, 36, 0.2); + color: var(--warning); +} + +.btn-download { + width: 100%; + padding: 16px; + background: var(--bg-card); + border: 1px dashed var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + transition: var(--transition-fast); + flex-shrink: 0; +} + +.btn-download:hover { + background: var(--bg-card-hover); + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.sidebar-footer { + display: flex; + align-items: center; + gap: 12px; + padding-top: 16px; + border-top: 1px solid var(--border-color); + margin-top: 20px; +} + +.username-display { + font-size: 13px; + color: var(--text-secondary); + flex: 1; +} + +.account-badges { + display: flex; + gap: 6px; } .badge { - padding: 0.25rem 0.75rem; - border-radius: 20px; - font-size: 0.875rem; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; font-weight: 500; } @@ -199,240 +470,433 @@ input::placeholder { color: #818cf8; } -/* Main Content */ -.main-content { - flex: 1; - display: grid; - grid-template-columns: 280px 1fr; - gap: 1px; - background: var(--border); - overflow: hidden; -} - -/* Sidebar */ -.sidebar { - background: var(--bg-secondary); - padding: 1rem; - overflow-y: auto; -} - -.sidebar h2 { - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary); - margin-bottom: 1rem; -} - -.instances-container { +.btn-logout { + width: 36px; + height: 36px; display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.instance-card { - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 10px; - padding: 1rem; + align-items: center; + justify-content: center; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-secondary); cursor: pointer; - transition: all 0.2s; + transition: var(--transition-fast); } -.instance-card:hover { - border-color: var(--accent); - transform: translateY(-2px); +.btn-logout:hover { + background: rgba(248, 113, 113, 0.1); + border-color: var(--error); + color: var(--error); } -.instance-card.selected { - border-color: var(--accent); - background: rgba(233, 69, 96, 0.1); -} - -.instance-name { - font-weight: 600; - margin-bottom: 0.5rem; -} - -.instance-details { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -.instance-version, .instance-loader { - font-size: 0.75rem; - padding: 0.25rem 0.5rem; - border-radius: 4px; - background: var(--bg-tertiary); -} - -.instance-server-version { - font-size: 0.75rem; - padding: 0.25rem 0.5rem; - border-radius: 4px; - background: rgba(251, 191, 36, 0.2); - color: var(--warning); -} - -/* Logs Panel */ -.logs-panel { - background: var(--bg-primary); - padding: 1rem; +/* Main Content - Logs */ +.main-content { display: flex; flex-direction: column; + padding: 20px; + background: var(--bg-primary); +} + +.logs-section { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-card); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); overflow: hidden; } -.logs-panel h2 { - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary); - margin-bottom: 1rem; -} - -#logs-container { - flex: 1; - background: #0d0d1a; - border-radius: 8px; - padding: 1rem; - font-family: 'Consolas', 'Monaco', monospace; - font-size: 0.875rem; - overflow-y: auto; - line-height: 1.6; -} - -.log-line { - margin-bottom: 0.25rem; - white-space: pre-wrap; - word-break: break-all; -} - -.log-line.info { color: var(--text-primary); } -.log-line.success { color: var(--success); } -.log-line.warning { color: var(--warning); } -.log-line.error { color: var(--error); } - -/* Footer */ -.footer { - background: var(--bg-secondary); - padding: 1rem 1.5rem; +.logs-header { display: flex; justify-content: space-between; align-items: center; - border-top: 1px solid var(--border); + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); } -.instance-info { - display: flex; - gap: 1rem; - align-items: center; -} - -.instance-info span { - padding: 0.5rem 1rem; - background: var(--bg-primary); - border-radius: 6px; - font-size: 0.875rem; -} - -#selected-name { +.logs-header h2 { + font-size: 14px; font-weight: 600; - color: var(--accent); + color: var(--text-primary); } -.btn-play { - background: var(--success); - color: #0a0a0a; - border: none; - padding: 0.875rem 2rem; - border-radius: 8px; - font-size: 1.125rem; - font-weight: 700; +.btn-clear-logs { + padding: 6px 12px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 12px; cursor: pointer; - transition: all 0.2s; - text-transform: uppercase; - letter-spacing: 0.05em; + transition: var(--transition-fast); } -.btn-play:hover:not(:disabled) { - transform: scale(1.05); - box-shadow: 0 0 20px rgba(74, 222, 128, 0.4); -} - -.btn-play:disabled { - background: var(--text-secondary); - cursor: not-allowed; -} - -.btn-play.update { - background: var(--warning); -} - -/* Modal */ -.modal { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.8); - display: flex; - justify-content: center; - align-items: center; - z-index: 100; -} - -.modal-content { - background: var(--bg-secondary); - padding: 2rem; - border-radius: 16px; - width: 100%; - max-width: 450px; - border: 1px solid var(--border); -} - -.modal-content h2 { - margin-bottom: 1.5rem; -} - -#install-form { - display: flex; - flex-direction: column; - gap: 1rem; -} - -#install-form label { - display: flex; - flex-direction: column; - gap: 0.5rem; - font-size: 0.875rem; +.btn-clear-logs:hover { + background: var(--bg-card-hover); color: var(--text-secondary); } -#install-form select, #install-form input { - width: 100%; +.logs-container { + flex: 1; + padding: 16px 20px; + overflow-y: auto; + font-family: 'JetBrains Mono', 'Consolas', monospace; + font-size: 12px; + line-height: 1.6; } -.modal-buttons { +.log-entry { + padding: 4px 0; + color: var(--text-secondary); + animation: fadeIn var(--transition-fast) forwards; +} + +.log-entry.info { + color: var(--text-secondary); +} + +.log-entry.success { + color: var(--success); +} + +.log-entry.warning { + color: var(--warning); +} + +.log-entry.error { + color: var(--error); +} + +/* Right Panel - Play Button */ +.right-panel { display: flex; - gap: 1rem; - justify-content: flex-end; - margin-top: 1rem; + align-items: flex-end; + justify-content: center; + padding: 30px; + border-left: 1px solid var(--border-color); + background: var(--bg-sidebar); } -/* Scrollbar */ +.btn-play { + width: 100%; + padding: 20px 30px; + background: linear-gradient(135deg, var(--success), #22c55e); + border: none; + border-radius: var(--radius-md); + color: #0a0a0f; + font-size: 18px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + transition: var(--transition-normal); + box-shadow: 0 4px 20px rgba(74, 222, 128, 0.4); +} + +.btn-play:hover:not(:disabled) { + transform: translateY(-4px) scale(1.02); + box-shadow: 0 8px 40px rgba(74, 222, 128, 0.5); +} + +.btn-play:active { + transform: translateY(0); +} + +.btn-play:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.btn-update { + width: 100%; + padding: 20px 30px; + background: linear-gradient(135deg, var(--warning), #f59e0b); + border: none; + border-radius: var(--radius-md); + color: #1a1a24; + font-size: 18px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + transition: var(--transition-normal); + box-shadow: 0 4px 20px rgba(251, 191, 36, 0.4); +} + +.btn-update:hover:not(:disabled) { + transform: translateY(-4px) scale(1.02); + box-shadow: 0 8px 40px rgba(251, 191, 36, 0.5); +} + +.btn-update:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* ==================== MODAL ==================== */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(10, 10, 15, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn var(--transition-fast) forwards; +} + +.modal-content { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + animation: slideUp var(--transition-normal) forwards; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2 { + font-size: 18px; + font-weight: 600; +} + +.modal-close { + width: 32px; + height: 32px; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 24px; + cursor: pointer; + transition: var(--transition-fast); +} + +.modal-close:hover { + color: var(--text-primary); +} + +.modal-tabs { + display: flex; + padding: 16px 24px; + gap: 8px; + border-bottom: 1px solid var(--border-color); +} + +.tab-btn { + flex: 1; + padding: 12px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + transition: var(--transition-fast); +} + +.tab-btn.active { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: white; +} + +.tab-btn:hover:not(.active) { + background: var(--bg-card-hover); +} + +.tab-content { + padding: 24px; + display: none; +} + +.tab-content.active { + display: block; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.select-input, .text-input { + width: 100%; + padding: 12px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 14px; + transition: var(--transition-fast); +} + +.select-input:focus, .text-input:focus { + outline: none; + border-color: var(--accent-primary); +} + +.select-input option { + background: var(--bg-secondary); +} + +.btn-install { + width: 100%; + padding: 14px; + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + border: none; + border-radius: var(--radius-sm); + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: var(--transition-fast); +} + +.btn-install:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-glow); +} + +.download-progress { + padding: 24px; + border-top: 1px solid var(--border-color); +} + +.progress-bar { + height: 8px; + background: var(--bg-secondary); + border-radius: 4px; + overflow: hidden; + margin-bottom: 12px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); + border-radius: 4px; + width: 0%; + transition: width var(--transition-normal); +} + +.progress-text { + text-align: center; + color: var(--text-secondary); + font-size: 13px; +} + +/* ==================== LOADING ==================== */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(10, 10, 15, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn var(--transition-fast) forwards; +} + +.loader { + width: 48px; + height: 48px; + border: 3px solid var(--border-color); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +/* ==================== RESPONSIVE ==================== */ +@media (max-width: 1024px) { + .main-layout { + grid-template-columns: 240px 1fr 160px; + } +} + +@media (max-width: 768px) { + .main-layout { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr auto; + } + + .sidebar { + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-right: none; + border-bottom: 1px solid var(--border-color); + } + + .sidebar-header { + padding-bottom: 0; + border-bottom: none; + margin-bottom: 0; + } + + .sidebar-content { + display: none; + } + + .sidebar-footer { + margin-top: 0; + padding-top: 0; + border-top: none; + } + + .right-panel { + padding: 16px; + border-left: none; + border-top: 1px solid var(--border-color); + } +} + +/* ==================== SCROLLBAR ==================== */ ::-webkit-scrollbar { width: 8px; + height: 8px; } ::-webkit-scrollbar-track { - background: var(--bg-primary); + background: var(--bg-secondary); } ::-webkit-scrollbar-thumb { - background: var(--bg-tertiary); + background: var(--border-color); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: var(--border); + background: var(--text-muted); } \ No newline at end of file diff --git a/server/cli.py b/server/cli.py index aa0aad4..78f5e6d 100644 --- a/server/cli.py +++ b/server/cli.py @@ -15,7 +15,8 @@ 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)") parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)") @@ -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}") diff --git a/server/main.py b/server/main.py index cfb8d60..0840c65 100644 --- a/server/main.py +++ b/server/main.py @@ -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 @@ -147,7 +164,51 @@ async def lifespan(app: FastAPI): logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True) 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,18 +822,23 @@ 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 = [] - + for pack_dir in PACKS_DIR.iterdir(): if pack_dir.is_dir(): meta_path = DATA_DIR / f"{pack_dir.name}.meta" @@ -770,13 +1014,13 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request): client_ip=client_ip) raise HTTPException(404, "File not found") - logger.info("Serving file", - pack=pack_name, - file=file_path, + logger.info("Serving file", + pack=pack_name, + file=file_path, 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,17 +1411,20 @@ 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"): raise HTTPException(400, "Invalid filename") - + file_path = BUILDS_DIR / filename - + 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, @@ -1233,12 +1486,82 @@ async def get_launcher_meta_list(): versions = get_launcher_versions() return { "versions": [ - {"version": v["version"], "meta": v["meta"]} + {"version": v["version"], "meta": v["meta"]} for v in versions ] } +@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""" @@ -1305,18 +1628,22 @@ async def get_launcher_file(version: str, file_path: str, request: Request): full_path = alt_path 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", @@ -1776,10 +2103,13 @@ async def global_exception_handler(request: Request, exc: Exception): if __name__ == "__main__": args = parse_args() - + 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: diff --git a/server/mirror.py b/server/mirror.py new file mode 100644 index 0000000..97d87bb --- /dev/null +++ b/server/mirror.py @@ -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()) \ No newline at end of file