Launcher UI redesign + server mirror sync + file download optimization
This commit is contained in:
@@ -21,6 +21,7 @@ import java.util.jar.Manifest;
|
|||||||
public class Bootstrap {
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -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();
|
||||||
|
|||||||
@@ -5,94 +5,205 @@
|
|||||||
<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">
|
||||||
<h2>Логи</h2>
|
<div class="logs-section">
|
||||||
<div id="logs-container"></div>
|
<div class="logs-header">
|
||||||
</section>
|
<h2>Логи</h2>
|
||||||
</main>
|
<button class="btn-clear-logs" id="clear-logs">Очистить</button>
|
||||||
|
</div>
|
||||||
|
<div id="logs-container" class="logs-container">
|
||||||
|
<div class="log-entry info">Ожидание запуска...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- Низ: управление -->
|
<!-- 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>
|
||||||
<button id="play-btn" class="btn-play" disabled>Выберите сборку</button>
|
</div>
|
||||||
</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">×</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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div id="loading-overlay" class="loading-overlay hidden">
|
||||||
|
<div class="loader"></div>
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="launcher.js"></script>
|
<script src="launcher.js"></script>
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
// ============ API ============
|
this.selectedInstance = null;
|
||||||
|
this.hasUpdate = false;
|
||||||
async function apiCall(endpoint, options = {}) {
|
this.hasMismatches = false;
|
||||||
const url = `${API_BASE}${endpoint}`;
|
this.init();
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Login ============
|
async init() {
|
||||||
|
this.bindEvents();
|
||||||
async function login(username, password) {
|
this.initGridAnimation();
|
||||||
log('Выполняется вход...', 'info');
|
await this.checkAuth();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(message) {
|
bindEvents() {
|
||||||
const el = document.getElementById('login-error');
|
document.getElementById('login-form').addEventListener('submit', (e) => {
|
||||||
el.textContent = message;
|
e.preventDefault();
|
||||||
el.classList.remove('hidden');
|
this.handleLogin();
|
||||||
}
|
});
|
||||||
|
|
||||||
function hideError() {
|
document.getElementById('logout-btn').addEventListener('click', () => {
|
||||||
document.getElementById('login-error').classList.add('hidden');
|
this.handleLogout();
|
||||||
}
|
});
|
||||||
|
|
||||||
// ============ Account ============
|
document.getElementById('download-btn').addEventListener('click', () => {
|
||||||
|
this.showDownloadModal();
|
||||||
|
});
|
||||||
|
|
||||||
async function loadAccountInfo() {
|
document.getElementById('close-download-modal').addEventListener('click', () => {
|
||||||
const result = await apiCall('/account');
|
this.hideDownloadModal();
|
||||||
if (result.success) {
|
});
|
||||||
state.account = result.data;
|
|
||||||
state.loggedIn = true;
|
|
||||||
document.getElementById('account-name').textContent = result.data.username;
|
|
||||||
|
|
||||||
const statusEl = document.getElementById('account-status');
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE';
|
btn.addEventListener('click', (e) => {
|
||||||
statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive');
|
this.switchTab(e.target.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const roleEl = document.getElementById('account-role');
|
document.getElementById('play-btn').addEventListener('click', () => {
|
||||||
if (roleEl && result.data.roleName) {
|
this.launchInstance();
|
||||||
roleEl.textContent = result.data.roleName;
|
});
|
||||||
|
|
||||||
|
document.getElementById('clear-logs').addEventListener('click', () => {
|
||||||
|
this.clearLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('loader-select').addEventListener('change', (e) => {
|
||||||
|
this.onLoaderChange(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('install-zernmc-btn').addEventListener('click', () => {
|
||||||
|
this.installZernMCPack();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('install-vanilla-btn').addEventListener('click', () => {
|
||||||
|
this.installVanilla();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initGridAnimation() {
|
||||||
|
const canvas = document.getElementById('grid-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let mouseX = 0, mouseY = 0;
|
||||||
|
let offsetX = 0, offsetY = 0;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', (e) => {
|
||||||
|
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||||
|
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
offsetX += (mouseX * 0.5 - offsetX) * 0.05;
|
||||||
|
offsetY += (mouseY * 0.5 - offsetY) * 0.05;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawGrid(ctx, width, height, offsetX, offsetY) {
|
||||||
|
const gridSize = 50;
|
||||||
|
const dotSize = 1;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#e94560';
|
||||||
|
|
||||||
|
for (let x = 0; x <= width; x += gridSize) {
|
||||||
|
for (let y = 0; y <= height; y += gridSize) {
|
||||||
|
const px = x + offsetX * 10;
|
||||||
|
const py = y + offsetY * 10;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
showLoginScreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Instances ============
|
|
||||||
|
|
||||||
async function loadInstances() {
|
|
||||||
log('Загрузка списка сборок...', 'info');
|
|
||||||
const result = await apiCall('/instances');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
state.instances = result.data;
|
|
||||||
renderInstances();
|
|
||||||
log('Загружено ' + result.data.length + ' сборок', 'success');
|
|
||||||
} else {
|
|
||||||
log('Ошибка загрузки: ' + result.error, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInstances() {
|
|
||||||
const container = document.getElementById('instances-list');
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
state.instances.forEach(inst => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'instance-card';
|
|
||||||
card.dataset.name = inst.name;
|
|
||||||
card.onclick = () => selectInstance(inst.name);
|
|
||||||
|
|
||||||
let details = `
|
|
||||||
<span class="instance-version">${inst.version || '?'}</span>
|
|
||||||
<span class="instance-loader">${inst.loaderType || 'vanilla'}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (inst.isServerPack) {
|
|
||||||
details += `<span class="instance-server-version">v${inst.serverVersion}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="instance-name">${inst.name}</div>
|
|
||||||
<div class="instance-details">${details}</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.appendChild(card);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectInstance(name) {
|
|
||||||
state.selectedInstance = state.instances.find(i => i.name === name);
|
|
||||||
|
|
||||||
document.querySelectorAll('.instance-card').forEach(c => {
|
|
||||||
c.classList.toggle('selected', c.dataset.name === name);
|
|
||||||
});
|
|
||||||
|
|
||||||
const btn = document.getElementById('play-btn');
|
|
||||||
const inst = state.selectedInstance;
|
|
||||||
|
|
||||||
if (inst) {
|
|
||||||
document.getElementById('selected-name').textContent = inst.name;
|
|
||||||
document.getElementById('selected-version').textContent = inst.version || '-';
|
|
||||||
document.getElementById('selected-loader').textContent = inst.loaderType || 'vanilla';
|
|
||||||
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Играть';
|
|
||||||
btn.classList.remove('update');
|
|
||||||
} else {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Выберите сборку';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Launch ============
|
|
||||||
|
|
||||||
async function launchInstance() {
|
|
||||||
if (!state.selectedInstance) return;
|
|
||||||
|
|
||||||
const name = state.selectedInstance.name;
|
|
||||||
log('Запуск сборки: ' + name, 'info');
|
|
||||||
|
|
||||||
const result = await apiCall('/launch', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ name })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
log('Сборка запущена! PID: ' + result.data.pid, 'success');
|
|
||||||
} else {
|
|
||||||
log('Ошибка запуска: ' + result.error, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Install ============
|
|
||||||
|
|
||||||
function openInstallModal() {
|
|
||||||
document.getElementById('install-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeInstallModal() {
|
|
||||||
document.getElementById('install-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installInstance(formData) {
|
|
||||||
log('Установка сборки...', 'info');
|
|
||||||
const result = await apiCall('/install', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
log('Сборка установлена!', 'success');
|
|
||||||
closeInstallModal();
|
|
||||||
await loadInstances();
|
|
||||||
} else {
|
|
||||||
log('Ошибка установки: ' + result.error, 'error');
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Logs ============
|
|
||||||
|
|
||||||
function log(message, type = 'info') {
|
|
||||||
const container = document.getElementById('logs-container');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const line = document.createElement('div');
|
|
||||||
line.className = 'log-line ' + type;
|
|
||||||
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
|
|
||||||
container.appendChild(line);
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLogs() {
|
|
||||||
document.getElementById('logs-container').innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Screens ============
|
|
||||||
|
|
||||||
function showLoginScreen() {
|
|
||||||
document.getElementById('login-screen').classList.remove('hidden');
|
|
||||||
document.getElementById('main-screen').classList.add('hidden');
|
|
||||||
clearError();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMainScreen() {
|
|
||||||
document.getElementById('login-screen').classList.add('hidden');
|
|
||||||
document.getElementById('main-screen').classList.remove('hidden');
|
|
||||||
|
|
||||||
if (state.account) {
|
|
||||||
document.getElementById('account-name').textContent = state.account.username;
|
|
||||||
const statusEl = document.getElementById('account-status');
|
|
||||||
statusEl.textContent = state.account.passActive ? 'PRO' : 'FREE';
|
|
||||||
statusEl.className = 'badge ' + (state.account.passActive ? 'active' : 'inactive');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Init ============
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
log('Запуск лаунчера...', 'info');
|
|
||||||
|
|
||||||
await loadAccountInfo();
|
|
||||||
|
|
||||||
if (!state.loggedIn) {
|
|
||||||
showLoginScreen();
|
|
||||||
} else {
|
|
||||||
showMainScreen();
|
|
||||||
await loadInstances();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start polling for server logs
|
async request(endpoint, options = {}) {
|
||||||
startLogPolling();
|
try {
|
||||||
});
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
let lastLogLength = 0;
|
headers: {
|
||||||
let lastGameLogLength = 0;
|
'Content-Type': 'application/json',
|
||||||
function startLogPolling() {
|
...options.headers
|
||||||
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;
|
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
|
this.showLoading(false);
|
||||||
const gameResult = await apiCall('/game-logs');
|
}
|
||||||
if (gameResult.success && gameResult.data && gameResult.data.length > lastGameLogLength) {
|
|
||||||
const newLogs = gameResult.data.substring(lastGameLogLength);
|
async handleLogin() {
|
||||||
const lines = newLogs.split('\n').filter(l => l.trim());
|
const username = document.getElementById('username').value;
|
||||||
lines.forEach(line => {
|
const password = document.getElementById('password').value;
|
||||||
log('[GAME] ' + line, 'info');
|
const errorEl = document.getElementById('login-error');
|
||||||
});
|
const btn = document.querySelector('#login-form button[type="submit"]');
|
||||||
lastGameLogLength = gameResult.data.length;
|
const btnText = btn.querySelector('.btn-text');
|
||||||
|
const btnLoader = btn.querySelector('.btn-loader');
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
this.showError('Введите имя пользователя и пароль');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, 2000);
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btnText.classList.add('hidden');
|
||||||
|
btnLoader.classList.remove('hidden');
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
const result = await this.request('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btnText.classList.remove('hidden');
|
||||||
|
btnLoader.classList.add('hidden');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.account = result.data;
|
||||||
|
this.username = result.data.username;
|
||||||
|
this.showMainScreen();
|
||||||
|
await this.loadInstances();
|
||||||
|
await this.loadCurrentInstance();
|
||||||
|
} else {
|
||||||
|
this.showError(result.error || 'Ошибка входа');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLogout() {
|
||||||
|
this.username = null;
|
||||||
|
this.account = null;
|
||||||
|
this.currentInstance = null;
|
||||||
|
this.instances = [];
|
||||||
|
this.showLoginScreen();
|
||||||
|
this.addLog('Вы вышли из аккаунта', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const errorEl = document.getElementById('login-error');
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadInstances() {
|
||||||
|
const result = await this.request('/instances');
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
this.instances = result.data;
|
||||||
|
this.renderInstances();
|
||||||
|
this.addLog('Загружено ' + result.data.length + ' сборок', 'success');
|
||||||
|
} else {
|
||||||
|
this.addLog('Ошибка загрузки: ' + (result.error || 'неизвестная ошибка'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInstances() {
|
||||||
|
const container = document.getElementById('instances-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
this.instances.forEach(inst => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'instance-card';
|
||||||
|
if (this.selectedInstance && this.selectedInstance.name === inst.name) {
|
||||||
|
card.classList.add('selected');
|
||||||
|
}
|
||||||
|
card.dataset.name = inst.name;
|
||||||
|
card.onclick = () => this.selectInstance(inst.name);
|
||||||
|
|
||||||
|
let details = `<span class="instance-card-version">${inst.version || '?'}</span>`;
|
||||||
|
if (inst.loaderType && inst.loaderType !== 'vanilla') {
|
||||||
|
details += `<span class="instance-card-loader">${inst.loaderType}</span>`;
|
||||||
|
}
|
||||||
|
if (inst.isServerPack) {
|
||||||
|
details += `<span class="instance-card-server">v${inst.serverVersion}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="instance-card-name">${this.escapeHtml(inst.name)}</div>
|
||||||
|
<div class="instance-card-details">${details}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectInstance(name) {
|
||||||
|
this.selectedInstance = this.instances.find(i => i.name === name);
|
||||||
|
this.renderInstances();
|
||||||
|
this.updatePlayButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCurrentInstance() {
|
||||||
|
if (this.instances.length > 0) {
|
||||||
|
this.currentInstance = this.instances[0];
|
||||||
|
this.selectedInstance = this.currentInstance;
|
||||||
|
this.renderCurrentInstance(this.currentInstance);
|
||||||
|
this.addLog('Сборка загружена: ' + this.currentInstance.name, 'success');
|
||||||
|
} else {
|
||||||
|
this.renderNoInstance();
|
||||||
|
}
|
||||||
|
this.updatePlayButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlayButton() {
|
||||||
|
const btn = document.getElementById('play-btn');
|
||||||
|
const instance = this.selectedInstance || this.currentInstance;
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.className = 'btn-play';
|
||||||
|
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ВЫБЕРИТЕ СБОРКУ';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasUpdate || this.hasMismatches) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.className = 'btn-update';
|
||||||
|
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>ОБНОВИТЬ';
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.className = 'btn-play';
|
||||||
|
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ИГРАТЬ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCurrentInstance(instance) {
|
||||||
|
const container = document.getElementById('current-instance');
|
||||||
|
let version = instance.version || 'Vanilla';
|
||||||
|
if (instance.isServerPack) {
|
||||||
|
version = `v${instance.serverVersion}`;
|
||||||
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="instance-card-mini">
|
||||||
|
<span class="instance-name">${this.escapeHtml(instance.name)}</span>
|
||||||
|
<span class="instance-version">${this.escapeHtml(version)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (this.account) {
|
||||||
|
document.getElementById('username-display').textContent = this.account.username;
|
||||||
|
const statusEl = document.getElementById('account-status');
|
||||||
|
statusEl.textContent = this.account.passActive ? 'PRO' : 'FREE';
|
||||||
|
statusEl.className = 'badge ' + (this.account.passActive ? 'active' : 'inactive');
|
||||||
|
|
||||||
|
const roleEl = document.getElementById('account-role');
|
||||||
|
if (this.account.roleName) {
|
||||||
|
roleEl.textContent = this.account.roleName;
|
||||||
|
roleEl.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
roleEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoInstance() {
|
||||||
|
const container = document.getElementById('current-instance');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="instance-card-mini">
|
||||||
|
<span class="instance-name" style="color: var(--text-muted)">Нет сборки</span>
|
||||||
|
<span class="instance-version" style="background: var(--bg-secondary)">Нажмите скачать</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async launchInstance() {
|
||||||
|
const instance = this.selectedInstance || this.currentInstance;
|
||||||
|
if (!instance) return;
|
||||||
|
|
||||||
|
const name = instance.name;
|
||||||
|
this.addLog('Запуск сборки: ' + name, 'info');
|
||||||
|
|
||||||
|
const result = await this.request('/launch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.addLog('Сборка запущена! PID: ' + (result.data?.pid || ''), 'success');
|
||||||
|
} else {
|
||||||
|
this.addLog('Ошибка: ' + (result.error || 'неизвестная ошибка'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async showDownloadModal() {
|
||||||
|
document.getElementById('download-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDownloadModal() {
|
||||||
|
document.getElementById('download-modal').classList.add('hidden');
|
||||||
|
this.hideProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
switchTab(tab) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => {
|
||||||
|
content.classList.toggle('active', content.id === 'tab-' + tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async installZernMCPack() {
|
||||||
|
const packName = document.getElementById('zernmc-pack-select').value;
|
||||||
|
const instanceName = document.getElementById('zernmc-instance-name').value;
|
||||||
|
|
||||||
|
if (!packName) {
|
||||||
|
alert('Выберите сборку');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceName) {
|
||||||
|
alert('Введите название сборки');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showProgress('Установка ZernMC сборки...');
|
||||||
|
this.addLog('Начало установки: ' + packName, 'info');
|
||||||
|
|
||||||
|
const result = await this.request('/install', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: instanceName,
|
||||||
|
version: 'latest',
|
||||||
|
loader: 'vanilla'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.hideDownloadModal();
|
||||||
|
await this.loadInstances();
|
||||||
|
await this.loadCurrentInstance();
|
||||||
|
this.addLog('Сборка установлена!', 'success');
|
||||||
|
} else {
|
||||||
|
this.addLog('Ошибка установки: ' + (result.error || 'неизвестная ошибка'), 'error');
|
||||||
|
this.hideProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async installVanilla() {
|
||||||
|
const mcVersion = document.getElementById('mc-version-select').value;
|
||||||
|
const loader = document.getElementById('loader-select').value;
|
||||||
|
const loaderVersion = document.getElementById('loader-version-select').value;
|
||||||
|
const instanceName = document.getElementById('vanilla-instance-name').value;
|
||||||
|
|
||||||
|
if (!mcVersion) {
|
||||||
|
alert('Выберите версию Minecraft');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceName) {
|
||||||
|
alert('Введите название сборки');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loader !== 'vanilla' && !loaderVersion) {
|
||||||
|
alert('Выберите версию лоадера');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showProgress('Установка сборки...');
|
||||||
|
this.addLog(`Начало установки: Minecraft ${mcVersion} ${loader !== 'vanilla' ? loader + ' ' + loaderVersion : ''}`, 'info');
|
||||||
|
|
||||||
|
const result = await this.request('/install', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: instanceName,
|
||||||
|
version: mcVersion,
|
||||||
|
loader: loader === 'vanilla' ? 'vanilla' : loader
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.hideDownloadModal();
|
||||||
|
await this.loadInstances();
|
||||||
|
await this.loadCurrentInstance();
|
||||||
|
this.addLog('Сборка установлена!', 'success');
|
||||||
|
} else {
|
||||||
|
this.addLog('Ошибка установки: ' + (result.error || 'неизвестная ошибка'), 'error');
|
||||||
|
this.hideProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoaderChange(loader) {
|
||||||
|
const loaderVersionGroup = document.getElementById('loader-version-group');
|
||||||
|
if (loader === 'vanilla') {
|
||||||
|
loaderVersionGroup.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
loaderVersionGroup.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showProgress(text) {
|
||||||
|
const progress = document.getElementById('download-progress');
|
||||||
|
const progressText = document.getElementById('progress-text');
|
||||||
|
const progressFill = document.getElementById('progress-fill');
|
||||||
|
|
||||||
|
progress.classList.remove('hidden');
|
||||||
|
progressText.textContent = text;
|
||||||
|
progressFill.style.width = '50%';
|
||||||
|
}
|
||||||
|
|
||||||
|
hideProgress() {
|
||||||
|
document.getElementById('download-progress').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(message, type = 'info') {
|
||||||
|
const container = document.getElementById('logs-container');
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'log-entry ' + type;
|
||||||
|
entry.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
|
||||||
|
container.appendChild(entry);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLogs() {
|
||||||
|
const container = document.getElementById('logs-container');
|
||||||
|
container.innerHTML = '<div class="log-entry info">Логи очищены</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoginScreen() {
|
||||||
|
document.getElementById('login-screen').classList.remove('hidden');
|
||||||
|
document.getElementById('main-screen').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
showMainScreen() {
|
||||||
|
document.getElementById('login-screen').classList.add('hidden');
|
||||||
|
document.getElementById('main-screen').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading(show) {
|
||||||
|
const overlay = document.getElementById('loading-overlay');
|
||||||
|
if (show) {
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Form Handlers ============
|
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
@@ -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}")
|
||||||
|
|||||||
+351
-21
@@ -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 not has_permission(current_user["role"], Permissions.VIEW_PACKS):
|
if master_key == MASTER_KEY:
|
||||||
raise HTTPException(
|
# Master key - allow access
|
||||||
status_code=403,
|
pass
|
||||||
detail="Для просмотра сборок требуется активная проходка"
|
else:
|
||||||
)
|
# Normal auth required
|
||||||
|
current_user = await get_current_user(request)
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
|
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
|
||||||
|
raise HTTPException(403, "Requires active pass")
|
||||||
|
|
||||||
packs = []
|
packs = []
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Lightweight Mirror Server - only serves static files
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
import structlog
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582")
|
||||||
|
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta")
|
||||||
|
PORT = int(os.environ.get("PORT", "1582"))
|
||||||
|
|
||||||
|
BUILDS_DIR = Path("builds")
|
||||||
|
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||||
|
PACKS_DIR = Path("packs")
|
||||||
|
|
||||||
|
BUILDS_DIR.mkdir(exist_ok=True)
|
||||||
|
PACKS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logging = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_with_main():
|
||||||
|
"""Sync files from main server"""
|
||||||
|
logging.info(f"Syncing from {MAIN_SERVER_URL}")
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(timeout=120.0)
|
||||||
|
headers = {"X-Master-Key": MASTER_KEY}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get launcher info
|
||||||
|
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logging.error(f"Failed to get launcher info: {resp.status_code}")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
current_version = data.get("current_version", "1.0.9")
|
||||||
|
files = data.get("files", {})
|
||||||
|
zips = files.get("zips", [])
|
||||||
|
logging.info(f"Current version: {current_version}, zips: {len(zips)}")
|
||||||
|
|
||||||
|
# Download latest ZIP
|
||||||
|
for z in zips:
|
||||||
|
if not z.get("is_legacy"):
|
||||||
|
zip_filename = z.get("filename")
|
||||||
|
zip_path = BUILDS_DIR / zip_filename
|
||||||
|
if not zip_path.exists():
|
||||||
|
logging.info(f"Downloading {zip_filename}...")
|
||||||
|
# Try direct download
|
||||||
|
download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}"
|
||||||
|
resp = await client.get(download_url, headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
zip_path.write_bytes(resp.content)
|
||||||
|
logging.info(f"Downloaded {zip_filename}")
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
version = z.get("version")
|
||||||
|
extract_to = VERSIONS_DIR / version
|
||||||
|
extract_to.mkdir(parents=True, exist_ok=True)
|
||||||
|
import zipfile
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||||
|
zf.extractall(extract_to)
|
||||||
|
logging.info(f"Extracted {version}")
|
||||||
|
|
||||||
|
# Get launcher meta
|
||||||
|
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
(BUILDS_DIR / "meta.json").write_text(resp.text)
|
||||||
|
logging.info("Meta synced")
|
||||||
|
|
||||||
|
# Sync packs list
|
||||||
|
resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
packs_data = resp.json()
|
||||||
|
packs = packs_data.get("packs", [])
|
||||||
|
logging.info(f"Found {len(packs)} packs")
|
||||||
|
|
||||||
|
for pack in packs:
|
||||||
|
pack_name = pack.get("name")
|
||||||
|
pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}"
|
||||||
|
resp = await client.get(pack_meta_url, headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
pack_dir = PACKS_DIR / pack_name
|
||||||
|
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(pack_dir / "meta.json").write_text(resp.text)
|
||||||
|
logging.info(f"Synced pack: {pack_name}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
logging.info("Sync complete")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_server():
|
||||||
|
"""Run static server"""
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import aiofiles
|
||||||
|
import mimetypes
|
||||||
|
import re
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
app = FastAPI(title="ZernMC Mirror")
|
||||||
|
|
||||||
|
async def send_file(file_path: Path, request: Request):
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(404, "Not found")
|
||||||
|
|
||||||
|
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||||
|
file_size = file_path.stat().st_size
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
|
||||||
|
if range_header:
|
||||||
|
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
|
||||||
|
if match:
|
||||||
|
start = int(match.group(1))
|
||||||
|
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
|
||||||
|
content_length = end - start + 1
|
||||||
|
async with aiofiles.open(file_path, "rb") as f:
|
||||||
|
await f.seek(start)
|
||||||
|
chunk = await f.read(content_length)
|
||||||
|
return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type,
|
||||||
|
headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)})
|
||||||
|
|
||||||
|
async def file_iter():
|
||||||
|
async with aiofiles.open(file_path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await f.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(file_iter(), media_type=content_type,
|
||||||
|
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)})
|
||||||
|
|
||||||
|
@app.get("/launcher/info")
|
||||||
|
async def get_launcher_info():
|
||||||
|
meta_path = BUILDS_DIR / "meta.json"
|
||||||
|
if meta_path.exists():
|
||||||
|
import json
|
||||||
|
return json.loads(meta_path.read_text())
|
||||||
|
return {"current_version": "unknown", "files": {}}
|
||||||
|
|
||||||
|
@app.get("/launcher/version")
|
||||||
|
async def get_version():
|
||||||
|
return await get_launcher_info()
|
||||||
|
|
||||||
|
@app.get("/launcher/file/{version}/{file_path:path}")
|
||||||
|
async def get_launcher_file(version: str, file_path: str, request: Request):
|
||||||
|
full_path = BUILDS_DIR / "versions" / version / file_path
|
||||||
|
if ".." in file_path:
|
||||||
|
raise HTTPException(403, "Invalid path")
|
||||||
|
if not full_path.exists():
|
||||||
|
raise HTTPException(404, f"File not found: {file_path}")
|
||||||
|
return await send_file(full_path, request)
|
||||||
|
|
||||||
|
@app.get("/launcher/download/zip/{filename}")
|
||||||
|
async def download_zip(filename: str, request: Request):
|
||||||
|
return await send_file(BUILDS_DIR / filename, request)
|
||||||
|
|
||||||
|
@app.get("/launcher/meta/{version}")
|
||||||
|
async def get_meta(version: str):
|
||||||
|
meta_path = BUILDS_DIR / "meta.json"
|
||||||
|
if meta_path.exists():
|
||||||
|
import json
|
||||||
|
return json.loads(meta_path.read_text())
|
||||||
|
raise HTTPException(404, "Meta not found")
|
||||||
|
|
||||||
|
@app.get("/launcher/mirrors")
|
||||||
|
async def get_mirrors():
|
||||||
|
return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]}
|
||||||
|
|
||||||
|
@app.get("/packs")
|
||||||
|
async def list_packs():
|
||||||
|
import json
|
||||||
|
packs = []
|
||||||
|
for pack_dir in PACKS_DIR.iterdir():
|
||||||
|
if pack_dir.is_dir():
|
||||||
|
meta_path = pack_dir / "meta.json"
|
||||||
|
if meta_path.exists():
|
||||||
|
try:
|
||||||
|
meta = json.loads(meta_path.read_text())
|
||||||
|
packs.append({
|
||||||
|
"name": pack_dir.name,
|
||||||
|
"version": meta.get("version", 1),
|
||||||
|
"files_count": len(meta.get("files", {}))
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
packs.append({"name": pack_dir.name, "error": "invalid"})
|
||||||
|
return {"packs": packs}
|
||||||
|
|
||||||
|
@app.get("/pack/{pack_name}")
|
||||||
|
async def get_pack(pack_name: str):
|
||||||
|
meta_path = PACKS_DIR / pack_name / "meta.json"
|
||||||
|
if meta_path.exists():
|
||||||
|
import json
|
||||||
|
return json.loads(meta_path.read_text())
|
||||||
|
raise HTTPException(404, "Pack not found")
|
||||||
|
|
||||||
|
@app.get("/pack/meta/{pack_name}")
|
||||||
|
async def get_pack_meta(pack_name: str):
|
||||||
|
return await get_pack(pack_name)
|
||||||
|
|
||||||
|
@app.get("/pack/{pack_name}/diff")
|
||||||
|
async def get_pack_diff(pack_name: str):
|
||||||
|
# For mirror, just return empty diff (no local changes)
|
||||||
|
return {"added": [], "removed": [], "changed": []}
|
||||||
|
|
||||||
|
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||||
|
async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||||
|
return await send_file(PACKS_DIR / pack_name / file_path, request)
|
||||||
|
|
||||||
|
config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info")
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
await server.serve()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logging.info("Starting ZernMC Mirror Server")
|
||||||
|
await sync_with_main()
|
||||||
|
await run_server()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user