DevBlog №4 | массовый фикс JFX и фиксы связанные с самим лаунчером, добавление админ меню

This commit is contained in:
SashegDev
2026-07-01 19:36:52 +00:00
parent e49e630afe
commit 0d61ad1107
12 changed files with 1915 additions and 99 deletions
@@ -85,7 +85,11 @@ public class AuthManager {
public static boolean tryAutoLogin() {
if (isLoggedIn()) return true;
if (!Files.exists(AUTH_FILE)) return false;
return loadSavedSession();
boolean loaded = loadSavedSession();
if (loaded) {
refreshUserInfo();
}
return loaded;
}
public static AuthResult login(String username, String password) {
@@ -28,15 +28,33 @@ import java.util.concurrent.atomic.AtomicInteger;
public class PackDownloader {
@FunctionalInterface
public interface ProgressCallback {
void onProgress(String label, int percent, String stageName, int stageIndex, int stageCount);
}
private final Instance instance;
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
private final HttpClient httpClient = HttpClient.newHttpClient();
private ProgressCallback progressCallback;
//private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
public PackDownloader(Instance instance) {
this.instance = instance;
}
public void setProgressCallback(ProgressCallback callback) {
this.progressCallback = callback;
}
private void reportProgress(String label, int percent, String stageName, int stageIndex, int stageCount) {
if (progressCallback != null) {
try {
progressCallback.onProgress(label, percent, stageName, stageIndex, stageCount);
} catch (Exception ignored) {}
}
}
/**
* Get list of available packs from server
*/
@@ -140,6 +158,7 @@ public class PackDownloader {
*/
public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception {
LauncherLogger.info("Installing pack " + packName + " from server...");
reportProgress("Fetching manifest...", 0, "manifest", 0, 5);
// 1. Get manifest
PackManifest manifest = getPackManifest(packName);
@@ -157,24 +176,28 @@ public class PackDownloader {
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
reportProgress("Failed to install Fabric", 0, "error", 0, 1);
return false;
}
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
return false;
}
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Forge"));
reportProgress("Failed to install Forge", 0, "error", 0, 1);
return false;
}
} else {
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
return false;
}
}
@@ -182,12 +205,15 @@ public class PackDownloader {
System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
}
reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5);
// 3. Scan local files only if there are files to download
Map<String, String> localFiles = scanLocalFiles();
// If pack has no files (vanilla/loader only), skip diff
if (manifest.files == null || manifest.files.isEmpty()) {
System.out.println(ZAnsi.green("Pack contains no additional files"));
reportProgress("Installing...", 50, "Installing pack files", 4, 5);
// Update instance metadata
instance.setServerPack(true);
@@ -198,15 +224,18 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(manifest.getAssetIndex());
reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5);
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
return true;
}
// 4. Send diff request
System.out.println(ZAnsi.cyan("Checking pack files..."));
reportProgress("Checking pack files...", 40, "Installing pack files", 4, 5);
DiffResponse diff = getDiff(packName, localFiles);
// 5. Apply changes
reportProgress("Downloading pack files...", 50, "Installing pack files", 4, 5);
boolean success = applyDiff(diff, packName);
if (success) {
@@ -445,6 +474,8 @@ public class PackDownloader {
downloaded.incrementAndGet();
if (total > 0) {
ProgressBar.show("Download", downloaded.get(), total, "files");
int pct = 50 + (downloaded.get() * 45 / total);
reportProgress("Downloading " + downloaded.get() + "/" + total + " files...", pct, "Installing pack files", 4, 5);
}
} catch (Exception e) {
@@ -7,12 +7,14 @@ import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -20,6 +22,8 @@ import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class VersionInstaller {
@@ -58,25 +62,36 @@ public class VersionInstaller {
public String install(String versionId) throws Exception {
System.out.println(ZAnsi.cyan("Full install of Minecraft " + versionId + "..."));
ProgressBar.setStage("Version", 0, 5);
Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
Files.createDirectories(versionDir);
String versionUrl = getVersionUrl(versionId);
if (versionUrl == null) throw new Exception("Version " + versionId + " not found");
ProgressBar.show("Fetching version info", 0, 1, "files");
String versionJson = downloadString(versionUrl);
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
ProgressBar.show("Version info", 1, 1, "files");
JSONObject versionData = new JSONObject(versionJson);
// client.jar
ProgressBar.show("Downloading client.jar", 0, 1, "files");
downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
versionDir.resolve(versionId + ".jar"), "client.jar");
ProgressBar.show("Client.jar", 1, 1, "files");
// Libraries
ProgressBar.setStage("Libraries", 1, 5);
System.out.println(ZAnsi.cyan("Downloading libraries..."));
downloadLibraries(versionData.getJSONArray("libraries"));
// Natives
ProgressBar.setStage("Natives", 2, 5);
System.out.println(ZAnsi.cyan("Extracting natives..."));
extractNatives(versionData.getJSONArray("libraries"));
String assetIndex;
if (versionData.has("assetIndex")) {
assetIndex = versionData.getJSONObject("assetIndex").getString("id");
@@ -87,13 +102,88 @@ public class VersionInstaller {
System.out.println(ZAnsi.cyan("Asset index: " + assetIndex));
// Download assets using correct index
ProgressBar.setStage("Assets", 3, 5);
System.out.println(ZAnsi.cyan("Downloading assets..."));
downloadAssets(versionData, assetIndex);
ProgressBar.setStage("Done", 4, 5);
System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " fully installed!"));
return assetIndex;
}
private void extractNatives(JSONArray libraries) throws Exception {
Path nativesDir = minecraftDir.resolve("natives");
Files.createDirectories(nativesDir);
// Determine OS classifier
String osName = System.getProperty("os.name").toLowerCase();
String classifier;
if (osName.contains("win")) {
classifier = "natives-windows";
} else if (osName.contains("mac")) {
classifier = "natives-macos";
} else {
classifier = "natives-linux";
}
int total = libraries.length();
int extracted = 0;
int failed = 0;
for (int i = 0; i < total; i++) {
JSONObject lib = libraries.getJSONObject(i);
if (!lib.has("downloads") || !lib.getJSONObject("downloads").has("classifiers")) continue;
JSONObject classifiers = lib.getJSONObject("downloads").getJSONObject("classifiers");
if (!classifiers.has(classifier)) continue;
JSONObject nativeArtifact = classifiers.getJSONObject(classifier);
String url = nativeArtifact.getString("url");
String path = nativeArtifact.getString("path");
Path libJar = minecraftDir.resolve("libraries").resolve(path);
// Download native library if not already present
if (!Files.exists(libJar)) {
try {
Files.createDirectories(libJar.getParent());
downloadFile(url, libJar, "");
} catch (Exception e) {
failed++;
continue;
}
}
// Extract .so/.dll/.dylib from the native jar
try (JarFile jar = new JarFile(libJar.toFile())) {
var entries = jar.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String name = entry.getName();
if (name.endsWith(".so") || name.endsWith(".dll") || name.endsWith(".dylib") || name.endsWith(".jnilib")) {
Path outFile = nativesDir.resolve(name);
if (!entry.isDirectory()) {
Files.createDirectories(outFile.getParent());
try (InputStream is = jar.getInputStream(entry)) {
Files.copy(is, outFile, StandardCopyOption.REPLACE_EXISTING);
}
}
}
}
} catch (Exception e) {
failed++;
}
extracted++;
ProgressBar.show("Natives", extracted, total, "files");
}
if (extracted > 0) {
ProgressBar.finish("Natives extracted (" + extracted + " ok, " + failed + " failed)");
} else {
System.out.println(ZAnsi.yellow("No natives to extract for " + classifier));
}
}
private void downloadLibraries(JSONArray libraries) throws Exception {
int total = libraries.length();
int count = 0;
@@ -19,6 +19,7 @@ import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.Version;
import java.awt.Desktop;
import java.io.BufferedReader;
@@ -29,6 +30,8 @@ import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Base64;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.net.InetSocketAddress;
@@ -192,8 +195,27 @@ public class JFXLauncher extends Application {
private static void extractAssets() {
try {
Path assetsDir = Paths.get("assets");
String currentVersion = Version.getCurrentVersion();
Path versionFile = assetsDir.resolve(".version");
// Check if assets already match current version
if (Files.exists(assetsDir) && Files.exists(versionFile)) {
try {
String existingVersion = Files.readString(versionFile).trim();
if (existingVersion.equals(currentVersion)) {
System.out.println("[JFX] Assets up to date (v" + currentVersion + ")");
return;
}
} catch (Exception ignored) {}
}
// Clean old assets
if (Files.exists(assetsDir)) {
return;
try {
Files.walk(assetsDir)
.sorted(java.util.Comparator.reverseOrder())
.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} });
} catch (Exception ignored) {}
}
String serverVersion = getServerVersion();
@@ -201,12 +223,14 @@ public class JFXLauncher extends Application {
System.out.println("[JFX] Loading assets via meta for version " + serverVersion);
if (downloadAssetsFromMeta(serverVersion)) {
System.out.println("[JFX] Assets loaded via meta");
Files.writeString(versionFile, currentVersion);
return;
}
System.out.println("[JFX] Meta unavailable, using fallback");
}
System.out.println("[JFX] Extracting assets from JAR...");
Files.createDirectories(assetsDir);
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
try (JarFile jar = new JarFile(jarPath.toFile())) {
@@ -220,13 +244,14 @@ public class JFXLauncher extends Application {
} else {
Files.createDirectories(outPath.getParent());
try (InputStream is = jar.getInputStream(entry)) {
Files.copy(is, outPath);
Files.copy(is, outPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
}
}
}
System.out.println("[JFX] Assets extracted from JAR");
Files.writeString(versionFile, currentVersion);
}
} catch (Exception e) {
System.out.println("[JFX] Error extracting assets: " + e.getMessage());
@@ -420,6 +445,7 @@ public class JFXLauncher extends Application {
LaunchService.killAllProcesses();
if (server != null) server.stop(0);
Platform.exit();
System.exit(0);
});
} catch (Exception e) {
@@ -472,8 +498,13 @@ public class JFXLauncher extends Application {
server.createContext("/api/friends/accept", this::handleFriendAccept);
server.createContext("/api/friends/requests", this::handleFriendRequests);
server.createContext("/api/friends/status", this::handleFriendStatus);
server.createContext("/api/check-updates", this::handleCheckUpdates);
server.createContext("/api/update", this::handleUpdate);
server.createContext("/api/playtime/sync", this::handlePlaytimeSync);
server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
server.createContext("/api/whitelist/mods", this::handleWhitelistMods);
server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod);
server.createContext("/api/admin", this::handleAdmin);
server.createContext("/assets/", this::handleStatic);
server.setExecutor(Executors.newCachedThreadPool());
@@ -711,8 +742,11 @@ public class JFXLauncher extends Application {
boolean success = false;
if ("zernmc".equalsIgnoreCase(loader)) {
setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 1, 4);
setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5);
PackDownloader downloader = new PackDownloader(instance);
downloader.setProgressCallback((label, pct, stageName, stageIdx, stageCount) -> {
setInstallProgressWithStage(label, pct, 100, stageName, stageIdx, stageCount);
});
var packs = downloader.getAvailablePacks();
ServerPack pack = null;
for (ServerPack p : packs) {
@@ -722,17 +756,23 @@ public class JFXLauncher extends Application {
}
}
if (pack != null) {
setInstallProgressWithStage("Downloading pack files...", 30, 100, "Downloading", 2, 4);
success = downloader.installOrUpdatePack(version, pack);
try {
success = downloader.installOrUpdatePack(version, pack);
} catch (Exception e) {
log("Pack install error: " + e.getMessage());
throw e;
}
if (success) {
setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 3, 4);
setInstallProgressWithStage("Finalizing...", 95, 100, "Finalizing", 4, 5);
} else {
log("Install error: pack install returned false");
}
} else {
log("Install error: pack not found on server: " + version + " (available: " + (packs != null ? packs.size() : 0) + ")");
}
} else {
MinecraftLib lib = new MinecraftLib(instance);
setInstallProgressWithStage("Installing Minecraft...", 20, 100, "Installing Minecraft", 1, 3);
setInstallProgressWithStage("Installing Minecraft...", 20, 100, "Installing Minecraft", 0, 3);
if ("vanilla".equalsIgnoreCase(loader)) {
success = lib.installMinecraft(version);
} else {
@@ -741,7 +781,7 @@ public class JFXLauncher extends Application {
}
setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 2, 3);
}
setInstallInProgress(false);
if (success) {
setInstallProgressWithStage("Installation complete!", 100, 100, "Done", 0, 1);
@@ -1365,6 +1405,233 @@ public class JFXLauncher extends Application {
}
}
private void handleCheckUpdates(HttpExchange exchange) {
try {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
String rawName = params.get("name");
if (rawName == null || rawName.isBlank()) {
sendJson(exchange, Map.of("success", false, "error", "Missing 'name' parameter"));
return;
}
String name = URLDecoder.decode(rawName, StandardCharsets.UTF_8);
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Instance not found"));
return;
}
if (!instance.isServerPack()) {
sendJson(exchange, Map.of("success", false, "error", "Not a server pack"));
return;
}
PackDownloader downloader = new PackDownloader(instance);
String packName = instance.getServerPackName();
int serverVersion = downloader.getPackManifest(packName).getVersion();
int localVersion = instance.getServerVersion();
sendJson(exchange, Map.of(
"success", true,
"data", Map.of(
"hasUpdate", serverVersion > localVersion,
"serverVersion", serverVersion,
"localVersion", localVersion
)
));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleUpdate(HttpExchange exchange) {
try {
Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
if (name == null || name.isBlank()) {
sendJson(exchange, Map.of("success", false, "error", "Missing 'name'"));
return;
}
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Instance not found"));
return;
}
if (!instance.isServerPack()) {
sendJson(exchange, Map.of("success", false, "error", "Not a server pack"));
return;
}
PackDownloader downloader = new PackDownloader(instance);
boolean success = downloader.updatePack(instance.getServerPackName());
if (success) {
sendJson(exchange, Map.of("success", true, "data", Map.of("version", instance.getServerVersion())));
} else {
sendJson(exchange, Map.of("success", false, "error", "Update failed"));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleWhitelistMods(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
String response = ZHttpClient.get("/whitelist/mods");
List<Map<String, Object>> mods = new ArrayList<>();
for (String line : response.split("\n")) {
line = line.trim();
if (line.isEmpty()) continue;
String[] parts = line.split("::");
if (parts.length >= 3) {
Map<String, Object> mod = new HashMap<>();
mod.put("name", parts[0].trim());
mod.put("size", Long.parseLong(parts[1].trim()));
mod.put("hash", parts[2].trim());
mods.add(mod);
}
}
sendJson(exchange, Map.of("success", true, "data", mods));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleWhitelistInstallMod(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String instanceName = body.get("instance");
String modName = body.get("modName");
String modHash = body.get("modHash");
if (instanceName == null || modName == null || modHash == null) {
sendJson(exchange, Map.of("success", false, "error", "Missing parameters"));
return;
}
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Instance not found"));
return;
}
Path modsDir = instance.getPath().resolve("mods");
Files.createDirectories(modsDir);
Path targetFile = modsDir.resolve(modName);
// Download mod from server
String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName;
java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(url))
.timeout(java.time.Duration.ofMinutes(5))
.header("Authorization", "Bearer " + AuthManager.getAccessToken())
.GET()
.build();
java.net.http.HttpResponse<Path> resp = java.net.http.HttpClient.newHttpClient()
.send(request, java.net.http.HttpResponse.BodyHandlers.ofFile(targetFile));
if (resp.statusCode() != 200) {
Files.deleteIfExists(targetFile);
sendJson(exchange, Map.of("success", false, "error", "HTTP " + resp.statusCode()));
return;
}
sendJson(exchange, Map.of("success", true, "message", modName + " installed"));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
// ====================== ADMIN ======================
private void handleAdmin(HttpExchange exchange) {
try {
String path = exchange.getRequestURI().getPath();
String method = exchange.getRequestMethod();
byte[] reqBodyBytes = exchange.getRequestBody().readAllBytes();
String body = reqBodyBytes.length > 0 ? new String(reqBodyBytes, StandardCharsets.UTF_8) : null;
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
String remotePath = path.replace("/api/admin", "/admin");
// Special handling for mod upload (base64 multipart)
if ("POST".equals(method) && remotePath.equals("/admin/whitelist/mods") && body != null) {
handleAdminModUpload(exchange, body);
return;
}
String apiMethod = method;
// Java HttpURLConnection doesn't support DELETE directly in remoteApiCall,
// so map DELETE to POST for the Python server
if ("DELETE".equals(method)) {
apiMethod = "POST";
remotePath = remotePath + "/delete";
}
String response = remoteApiCall(apiMethod, remotePath, body);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleAdminModUpload(HttpExchange exchange, String jsonBody) throws Exception {
@SuppressWarnings("unchecked")
Map<String, String> data = gson.fromJson(jsonBody, Map.class);
String filename = data.get("filename");
String base64Data = data.get("data");
if (filename == null || base64Data == null) {
sendJson(exchange, Map.of("success", false, "error", "Missing filename or data"));
return;
}
// Decode base64
byte[] fileBytes = Base64.getDecoder().decode(base64Data);
// Forward to Python API as multipart request
String boundary = "Boundary-" + System.currentTimeMillis();
URL url = new URL(LAUNCHER_SERVER + "/admin/whitelist/mods");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
String token = AuthManager.getAccessToken();
if (token != null && !token.isEmpty() && !token.equals("0")) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
conn.setConnectTimeout(30000);
conn.setReadTimeout(60000);
String header = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n"
+ "Content-Type: application/java-archive\r\n\r\n";
String footer = "\r\n--" + boundary + "--\r\n";
try (OutputStream os = conn.getOutputStream()) {
os.write(header.getBytes(StandardCharsets.UTF_8));
os.write(fileBytes);
os.write(footer.getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
String respBody;
try (InputStream is = code >= 400 ? conn.getErrorStream() : conn.getInputStream()) {
respBody = new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
if (code >= 400) {
sendJson(exchange, Map.of("success", false, "error", "Upload failed: HTTP " + code + " - " + respBody));
} else {
sendRawJson(exchange, respBody);
}
}
private void log(String msg) {
String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"));
String entry = "[" + timestamp + "] " + msg;
@@ -87,16 +87,18 @@ public class ProgressBar {
}
public static void finish(String message) {
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setInProgress = jfxClass.getMethod("setInstallInProgress", boolean.class);
setInProgress.invoke(null, false);
} catch (Exception ignored) {}
System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓"));
System.out.flush();
}
public static void setStage(String stageName, int stageIndex, int stageCount) {
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setStage = jfxClass.getMethod("setInstallStage", String.class, int.class, int.class);
setStage.invoke(null, stageName, stageIndex, stageCount);
} catch (Exception ignored) {}
}
public static void clearLine() {
System.out.print("\r" + " ".repeat(110) + "\r");
System.out.flush();
+118 -7
View File
@@ -78,7 +78,7 @@
</div>
<nav class="sidebar-nav">
<div class="sidebar-nav-left">
<div class="sidebar-nav-primary">
<button class="nav-btn active" data-view="packs" title="Packs">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
<span data-i18n="nav.packs">Packs</span>
@@ -88,11 +88,15 @@
<span data-i18n="nav.news">News</span>
</button>
</div>
<div class="sidebar-nav-right">
<div class="sidebar-nav-secondary" id="nav-secondary">
<button class="nav-btn" data-view="friends" title="Friends">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="nav.friends">Friends</span>
</button>
<button class="nav-btn hidden" data-view="admin" title="Admin" id="admin-nav-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
<span data-i18n="nav.admin">Admin</span>
</button>
</div>
</nav>
@@ -185,6 +189,12 @@
<div id="pack-gallery" class="pack-gallery">
</div>
</div>
<div id="whitelist-mods-section" class="whitelist-mods-section hidden">
<div class="section-header"><span data-i18n="whitelist.title">Additional Mods</span></div>
<div id="whitelist-mods-list" class="whitelist-mods-list">
<div class="whitelist-loading" data-i18n="whitelist.loading">Loading...</div>
</div>
</div>
</div>
</div>
@@ -233,6 +243,111 @@
</div>
</div>
<!-- Admin View -->
<div id="view-admin" class="view">
<div class="view-header">
<h2 class="view-title" data-i18n="admin.title">Admin Panel</h2>
</div>
<div class="admin-tabs">
<button class="admin-tab active" data-atab="clients"><span data-i18n="admin.clients">Clients</span></button>
<button class="admin-tab" data-atab="mods"><span data-i18n="admin.mods">Mods</span></button>
<button class="admin-tab" data-atab="users"><span data-i18n="admin.users">Users</span></button>
<button class="admin-tab" data-atab="passes"><span data-i18n="admin.passes">Passes</span></button>
<button class="admin-tab" data-atab="news"><span data-i18n="admin.news">News</span></button>
</div>
<div class="admin-content">
<!-- Clients -->
<div id="atab-clients" class="admin-tab-content active">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.clientsOnline">Online Clients</span></div>
<div id="admin-clients-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Whitelist Mods -->
<div id="atab-mods" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.modsUpload">Upload Mod</span></div>
<div class="admin-row">
<input type="file" id="admin-mod-file" accept=".jar" class="admin-file-input">
<button class="btn-primary btn-sm" onclick="app.adminUploadMod()"><span data-i18n="admin.modsUploadBtn">Upload</span></button>
</div>
<div id="admin-mod-status" class="admin-status hidden"></div>
</div>
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.modsList">Whitelist Mods</span></div>
<div id="admin-mods-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- Users -->
<div id="atab-users" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.usersSearch">User Management</span></div>
<div class="admin-row">
<input type="text" id="admin-user-search" class="admin-input" placeholder="Username..." data-i18n-placeholder="admin.usersSearchPlaceholder">
<button class="btn-primary btn-sm" onclick="app.adminSearchUser()"><span data-i18n="admin.search">Search</span></button>
</div>
<div id="admin-user-results" class="admin-list"></div>
</div>
<div id="admin-user-detail" class="admin-card hidden">
<div class="admin-card-header" id="admin-user-detail-name"></div>
<div class="admin-form">
<div class="admin-row">
<span data-i18n="admin.role">Role:</span>
<select id="admin-user-role-select" class="admin-select">
<option value="0">USER</option>
<option value="1">PASS_HOLDER</option>
<option value="2">MODERATOR</option>
<option value="3">ELDER</option>
<option value="4">CREATOR</option>
</select>
<button class="btn-primary btn-sm" onclick="app.adminSetRole()"><span data-i18n="admin.save">Save</span></button>
</div>
<div class="admin-row">
<button class="btn-secondary btn-sm btn-danger" onclick="app.adminResetPassword()"><span data-i18n="admin.resetPass">Reset Password</span></button>
</div>
</div>
</div>
</div>
<!-- Passes -->
<div id="atab-passes" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.passesList">Pass Management</span></div>
<div id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- News -->
<div id="atab-news" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.newsCreate">Create News</span></div>
<div class="admin-form">
<div class="admin-row">
<input type="text" id="admin-news-title" class="admin-input" placeholder="Title" data-i18n-placeholder="admin.newsTitlePlaceholder">
</div>
<div class="admin-row">
<select id="admin-news-type" class="admin-select">
<option value="Update">Update</option>
<option value="Announcement">Announcement</option>
<option value="Event">Event</option>
</select>
<input type="text" id="admin-news-version" class="admin-input" placeholder="Version" style="max-width:120px">
</div>
<div class="admin-row">
<textarea id="admin-news-body" class="admin-textarea" placeholder="Body (Markdown)" data-i18n-placeholder="admin.newsBodyPlaceholder"></textarea>
</div>
<div class="admin-row">
<button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button>
</div>
<div id="admin-news-status" class="admin-status hidden"></div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.newsManage">Manage News</span></div>
<div id="admin-news-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
</div>
</div>
<!-- Settings View -->
<div id="view-settings" class="view">
<div class="view-header">
@@ -345,8 +460,8 @@
</div>
<div class="modal-body">
<div class="field">
<input type="text" id="add-friend-input" placeholder=" " data-i18n-placeholder="friends.addPlaceholder">
<label data-i18n="friends.addLabel">Username</label>
<input type="text" id="add-friend-input" placeholder="Enter username..." data-i18n-placeholder="friends.addPlaceholder">
</div>
<button id="add-friend-submit" class="btn-primary" onclick="app.submitAddFriend()"><span data-i18n="friends.add">Add Friend</span></button>
<p id="add-friend-error" class="error-msg hidden"></p>
@@ -391,10 +506,6 @@
<option value="">Loading...</option>
</select>
</div>
<div class="field">
<label data-i18n="install.localName.label">Local Name</label>
<input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
</div>
<button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
</div>
+573 -42
View File
@@ -14,6 +14,7 @@ const LOCALES = {
'pack.noDescription': 'No description available',
'stat.loaderVer': 'Loader Ver', 'stat.files': 'Files', 'stat.size': 'Size',
'playBar.play': 'Play', 'playBar.passRequired': 'Pass Required',
'playBar.update': 'Update', 'playBar.updating': 'Updating...',
'settings.title': 'Settings',
'settings.activatePass.title': 'Activate Pass',
'settings.activatePass.desc': 'Enter your pass code to access server packs',
@@ -74,6 +75,8 @@ const LOCALES = {
'toast.loginFailed': 'Login failed',
'toast.launching': 'Launching {name}...',
'toast.launchFailed': 'Launch failed',
'toast.updated': '{name} updated to v{version}!',
'toast.updateFailed': 'Update failed',
'toast.noPackSelected': 'Select a pack first',
'toast.launched': 'Launched! PID: {pid}',
'toast.installing': 'Installing...',
@@ -116,6 +119,53 @@ const LOCALES = {
'playtime.hours': '{h}h {m}m',
'playtime.total': 'Total: {h}h',
'playtime.syncing': 'Syncing...',
'whitelist.title': 'Additional Mods',
'whitelist.loading': 'Loading...',
'whitelist.empty': 'No additional mods available',
'whitelist.error': 'Failed to load mods',
'whitelist.install': 'Install Selected',
'whitelist.installing': 'Installing...',
'whitelist.installed': 'Installed',
'whitelist.done': 'Mods installed!',
'whitelist.selectNone': 'Select mods first',
'whitelist.installError': 'Failed to install mod',
'nav.admin': 'Admin',
'admin.title': 'Admin Panel',
'admin.clients': 'Clients',
'admin.mods': 'Mods',
'admin.users': 'Users',
'admin.passes': 'Passes',
'admin.news': 'News',
'admin.clientsOnline': 'Online Clients',
'admin.noClients': 'No connected clients',
'admin.modsUpload': 'Upload Mod',
'admin.modsUploadBtn': 'Upload',
'admin.modsList': 'Whitelist Mods',
'admin.modsEmpty': 'No whitelist mods',
'admin.modsUploaded': 'Mod uploaded successfully!',
'admin.modsDeleted': 'Mod deleted',
'admin.usersSearch': 'User Management',
'admin.usersSearchPlaceholder': 'Username...',
'admin.search': 'Search',
'admin.role': 'Role:',
'admin.save': 'Save',
'admin.roleUpdated': 'Role updated!',
'admin.resetPass': 'Reset Password',
'admin.passReset': 'Password reset! New password: {pass}',
'admin.noUser': 'No user selected',
'admin.passesList': 'Pass Management',
'admin.passesEmpty': 'No passes',
'admin.newsCreate': 'Create News',
'admin.newsTitlePlaceholder': 'Title',
'admin.newsBodyPlaceholder': 'Body (Markdown)',
'admin.newsCreateBtn': 'Publish',
'admin.newsManage': 'Manage News',
'admin.newsCreated': 'News published!',
'admin.newsDeleted': 'News deleted',
'admin.newsEmpty': 'No news',
'admin.delete': 'Delete',
'admin.loading': 'Loading...',
'admin.error': 'Error',
},
ru: {
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
@@ -130,6 +180,7 @@ const LOCALES = {
'pack.noDescription': 'Описание отсутствует',
'stat.loaderVer': 'Версия загрузчика', 'stat.files': 'Файлы', 'stat.size': 'Размер',
'playBar.play': 'Играть', 'playBar.passRequired': 'Требуется проходка',
'playBar.update': 'Обновить', 'playBar.updating': 'Обновление...',
'settings.title': 'Настройки',
'settings.activatePass.title': 'Активировать проходку',
'settings.activatePass.desc': 'Введите код проходки для доступа к серверным сборкам',
@@ -190,6 +241,8 @@ const LOCALES = {
'toast.loginFailed': 'Ошибка входа',
'toast.launching': 'Запуск {name}...',
'toast.launchFailed': 'Ошибка запуска',
'toast.updated': '{name} обновлён до v{version}!',
'toast.updateFailed': 'Ошибка обновления',
'toast.noPackSelected': 'Сначала выберите сборку',
'toast.launched': 'Запущено! PID: {pid}',
'toast.installing': 'Установка...',
@@ -232,6 +285,53 @@ const LOCALES = {
'playtime.hours': '{h}ч {m}м',
'playtime.total': 'Всего: {h}ч',
'playtime.syncing': 'Синхронизация...',
'whitelist.title': 'Дополнительные моды',
'whitelist.loading': 'Загрузка...',
'whitelist.empty': 'Нет дополнительных модов',
'whitelist.error': 'Ошибка загрузки модов',
'whitelist.install': 'Установить выбранные',
'whitelist.installing': 'Установка...',
'whitelist.installed': 'Установлено',
'whitelist.done': 'Моды установлены!',
'whitelist.selectNone': 'Выберите моды',
'whitelist.installError': 'Ошибка установки мода',
'nav.admin': 'Админка',
'admin.title': 'Панель администратора',
'admin.clients': 'Клиенты',
'admin.mods': 'Моды',
'admin.users': 'Пользователи',
'admin.passes': 'Проходки',
'admin.news': 'Новости',
'admin.clientsOnline': 'Онлайн клиенты',
'admin.noClients': 'Нет подключенных клиентов',
'admin.modsUpload': 'Загрузить мод',
'admin.modsUploadBtn': 'Загрузить',
'admin.modsList': 'Вайтлист моды',
'admin.modsEmpty': 'Нет модов в вайтлисте',
'admin.modsUploaded': 'Мод успешно загружен!',
'admin.modsDeleted': 'Мод удалён',
'admin.usersSearch': 'Управление пользователями',
'admin.usersSearchPlaceholder': 'Имя пользователя...',
'admin.search': 'Поиск',
'admin.role': 'Роль:',
'admin.save': 'Сохранить',
'admin.roleUpdated': 'Роль обновлена!',
'admin.resetPass': 'Сбросить пароль',
'admin.passReset': 'Пароль сброшен! Новый пароль: {pass}',
'admin.noUser': 'Пользователь не выбран',
'admin.passesList': 'Управление проходками',
'admin.passesEmpty': 'Нет проходок',
'admin.newsCreate': 'Создать новость',
'admin.newsTitlePlaceholder': 'Заголовок',
'admin.newsBodyPlaceholder': 'Текст (Markdown)',
'admin.newsCreateBtn': 'Опубликовать',
'admin.newsManage': 'Управление новостями',
'admin.newsCreated': 'Новость опубликована!',
'admin.newsDeleted': 'Новость удалена',
'admin.newsEmpty': 'Нет новостей',
'admin.delete': 'Удалить',
'admin.loading': 'Загрузка...',
'admin.error': 'Ошибка',
}
};
@@ -463,6 +563,7 @@ class ZernMCLauncher {
this.loadPlaytimeStats();
this.startFriendStatusHeartbeat();
this.enhanceSelects();
this.updateAdminNav();
}
async loadServerPacksList() {
@@ -524,6 +625,10 @@ class ZernMCLauncher {
afInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') app.submitAddFriend(); });
}
document.querySelectorAll('.admin-tab').forEach(function(t) {
t.addEventListener('click', function() { app.switchAdminTab(t.dataset.atab); });
});
var localeSelect = document.getElementById('locale-select');
if (localeSelect) {
localeSelect.addEventListener('change', async () => {
@@ -558,10 +663,36 @@ class ZernMCLauncher {
const r = await this.req('/instances');
if (r.success && r.data) {
this.state.instances = r.data;
this.state.updateMap = {};
await this.checkUpdatesForServerPacks();
this.renderSidebar();
}
}
async checkUpdatesForServerPacks() {
if (!this.state.instances) return;
for (const inst of this.state.instances) {
if (!inst.isServerPack) continue;
const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name));
if (r.success && r.data) {
this.state.updateMap[inst.name] = r.data.hasUpdate;
}
}
}
async checkUpdateForPack(inst) {
if (!inst || !inst.isServerPack) return;
const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name));
if (r.success && r.data) {
if (!this.state.updateMap) this.state.updateMap = {};
this.state.updateMap[inst.name] = r.data.hasUpdate;
}
}
hasUpdate(inst) {
return inst && inst.isServerPack && this.state.updateMap && this.state.updateMap[inst.name] === true;
}
renderSidebar() {
const serverList = document.getElementById('server-packs-list');
const localList = document.getElementById('local-packs-list');
@@ -578,15 +709,17 @@ class ZernMCLauncher {
const el = document.createElement('div');
el.className = 'pack-entry' + (this.state.selectedPack && this.state.selectedPack.name === inst.name ? ' selected' : '');
const hasUpd = this.hasUpdate(inst);
el.innerHTML = `
<div class="pack-entry-icon ${isZern ? 'server' : 'local'}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${isZern
? '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>'
: '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>'
}</svg>
${hasUpd ? '<span class="update-badge"></span>' : ''}
</div>
<div class="pack-entry-info">
<div class="pack-entry-name">${this.esc(inst.name)}</div>
<div class="pack-entry-name">${this.esc(inst.name)}${hasUpd ? ' <span class="update-label">' + t('pack.update') + '</span>' : ''}</div>
<div class="pack-entry-meta">${inst.version || '?'}${inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : ''}</div>
</div>
`;
@@ -611,6 +744,10 @@ class ZernMCLauncher {
// Enable play button immediately so user can click it right away
this.setPlayBtnText('playBar.play', false);
this.showPackDetail(inst);
// Check for updates asynchronously
this.checkUpdateForPack(inst).then(() => {
if (this.state.selectedPack === inst) this.showPackDetail(inst);
});
// Refresh account in background to check pass status
this.refreshAccount().then(() => {
if (this.state.selectedPack === inst) {
@@ -673,7 +810,7 @@ class ZernMCLauncher {
serverTag.classList.add('hidden');
}
document.getElementById('detail-loader-ver').textContent = inst.loaderVersion || '-';
document.getElementById('detail-files').textContent = inst.filesCount || '0';
document.getElementById('detail-files').textContent = '...';
var playtimeEl = document.getElementById('detail-playtime');
var stats = this.state.playtimeStats;
@@ -701,6 +838,8 @@ class ZernMCLauncher {
var passActive = this.state.account ? this.state.account.passActive : false;
if (inst.isServerPack && !passActive) {
this.setPlayBtnText('playBar.passRequired', true);
} else if (this.hasUpdate(inst)) {
this.setPlayBtnText('playBar.update', false);
} else {
this.setPlayBtnText('playBar.play', false);
}
@@ -723,6 +862,15 @@ class ZernMCLauncher {
// Load pack info from local files
this.loadPackInfo(inst.name);
// Load whitelist mods for server packs
const whitelistSection = document.getElementById('whitelist-mods-section');
if (inst.isServerPack) {
this.loadWhitelistMods(inst.name);
whitelistSection.classList.remove('hidden');
} else {
whitelistSection.classList.add('hidden');
}
}
async loadPackInfo(name) {
@@ -739,6 +887,348 @@ class ZernMCLauncher {
}
}
async loadWhitelistMods(instanceName) {
const listEl = document.getElementById('whitelist-mods-list');
listEl.innerHTML = '<div class="whitelist-loading">' + t('whitelist.loading') + '</div>';
const r = await this.req('/whitelist/mods');
if (!r.success || !r.data) {
listEl.innerHTML = '<div class="whitelist-loading">' + (r.error || t('whitelist.error')) + '</div>';
return;
}
if (r.data.length === 0) {
listEl.innerHTML = '<div class="whitelist-loading">' + t('whitelist.empty') + '</div>';
return;
}
var html = '';
r.data.forEach(function(mod) {
var sizeStr = mod.size > 1048576 ? (mod.size / 1048576).toFixed(1) + ' MB' : Math.ceil(mod.size / 1024) + ' KB';
html += '<label class="whitelist-mod-item">'
+ '<input type="checkbox" class="whitelist-mod-checkbox" data-hash="' + mod.hash + '" data-name="' + mod.name + '">'
+ '<div class="whitelist-mod-info">'
+ '<div class="whitelist-mod-name">' + this.esc(mod.name) + '</div>'
+ '<div class="whitelist-mod-size">' + sizeStr + '</div>'
+ '</div>'
+ '</label>';
}, this);
html += '<button class="whitelist-install-btn" onclick="app.installSelectedWhitelistMods(\'' + instanceName + '\')">'
+ '<span data-i18n="whitelist.install">Install Selected</span></button>';
listEl.innerHTML = html;
}
async installSelectedWhitelistMods(instanceName) {
var checkboxes = document.querySelectorAll('.whitelist-mod-checkbox:checked');
if (checkboxes.length === 0) { this.toast(t('whitelist.selectNone'), 'warning'); return; }
var btn = document.querySelector('.whitelist-install-btn');
btn.disabled = true;
btn.textContent = t('whitelist.installing');
for (var i = 0; i < checkboxes.length; i++) {
var cb = checkboxes[i];
var modName = cb.getAttribute('data-name');
var modHash = cb.getAttribute('data-hash');
var r = await this.req('/whitelist/mods/install', {
method: 'POST',
body: JSON.stringify({ instance: instanceName, modName: modName, modHash: modHash })
});
if (r.success) {
cb.checked = false;
cb.parentElement.querySelector('.whitelist-mod-info').innerHTML += '<div class="whitelist-mod-status">' + t('whitelist.installed') + '</div>';
} else {
this.toast(r.error || t('whitelist.installError'), 'error');
}
}
btn.disabled = false;
btn.innerHTML = '<span data-i18n="whitelist.install">Install Selected</span>';
this.toast(t('whitelist.done'), 'success');
}
// ==================== ADMIN ====================
updateAdminNav() {
var a = this.state.account;
var btn = document.getElementById('admin-nav-btn');
if (!btn) return;
if (a && a.role >= 2) {
btn.classList.remove('hidden');
} else {
btn.classList.add('hidden');
}
}
switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(function(t) {
t.classList.toggle('active', t.dataset.atab === tab);
});
document.querySelectorAll('.admin-tab-content').forEach(function(c) {
c.classList.toggle('active', c.id === 'atab-' + tab);
});
if (tab === 'clients') this.adminLoadClients();
if (tab === 'mods') this.adminLoadMods();
if (tab === 'users') this.adminClearUser();
if (tab === 'passes') this.adminLoadPasses();
if (tab === 'news') this.adminLoadNewsList();
}
// --- Clients ---
async adminLoadClients() {
var el = document.getElementById('admin-clients-list');
if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/admin/clients');
if (!r.success) {
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + ': ' + (r.error || '') + '</div>';
return;
}
var clients = r.clients || r.data || [];
if (!clients.length) {
el.innerHTML = '<div class="admin-empty">' + t('admin.noClients') + '</div>';
return;
}
var html = '';
clients.forEach(function(c) {
html += '<div class="admin-item">'
+ '<span class="admin-item-name">' + app.esc(c.username || c.name || '?') + '</span>'
+ '<span class="admin-item-meta">IP: ' + app.esc(c.ip || '-') + '</span>'
+ '</div>';
});
el.innerHTML = html;
setTimeout(function() { app.adminLoadClients(); }, 5000);
}
// --- Mods ---
async adminLoadMods() {
var el = document.getElementById('admin-mods-list');
if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/admin/whitelist/mods');
if (!r.success) {
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
return;
}
var mods = r.mods || r.data || [];
if (!mods.length) {
el.innerHTML = '<div class="admin-empty">' + t('admin.modsEmpty') + '</div>';
return;
}
var html = '';
mods.forEach(function(m) {
var sizeStr = m.size > 1048576 ? (m.size / 1048576).toFixed(1) + ' MB' : Math.ceil(m.size / 1024) + ' KB';
html += '<div class="admin-item">'
+ '<span class="admin-item-name">' + app.esc(m.name) + '</span>'
+ '<span class="admin-item-meta">' + sizeStr + '</span>'
+ '<div class="admin-item-actions">'
+ '<button class="btn-secondary btn-sm btn-danger" onclick="app.adminDeleteMod(\'' + m.hash + '\')">' + t('admin.delete') + '</button>'
+ '</div></div>';
});
el.innerHTML = html;
}
async adminUploadMod() {
var input = document.getElementById('admin-mod-file');
var status = document.getElementById('admin-mod-status');
if (!input || !input.files || !input.files[0]) { this.toast('Select a file', 'error'); return; }
var file = input.files[0];
if (!file.name.endsWith('.jar')) { this.toast('Only .jar files', 'error'); return; }
var reader = new FileReader();
reader.onload = async function(e) {
var base64 = e.target.result.split(',')[1];
var r = await app.req('/admin/whitelist/mods', {
method: 'POST',
body: JSON.stringify({ filename: file.name, data: base64 })
});
status.classList.remove('hidden');
if (r.success) {
status.textContent = t('admin.modsUploaded');
status.className = 'admin-status success';
input.value = '';
app.adminLoadMods();
} else {
status.textContent = r.error || t('admin.error');
status.className = 'admin-status error';
}
};
reader.readAsDataURL(file);
}
async adminDeleteMod(hash) {
var r = await this.req('/admin/whitelist/mods/' + hash, { method: 'DELETE' });
if (r.success) {
this.toast(t('admin.modsDeleted'), 'success');
this.adminLoadMods();
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
// --- Users ---
async adminSearchUser() {
var input = document.getElementById('admin-user-search');
var results = document.getElementById('admin-user-results');
var query = input.value.trim();
if (!query) return;
results.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/admin/users/search', {
method: 'POST',
body: JSON.stringify({ search: query })
});
if (!r.success) {
results.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
return;
}
var users = r.users || r.data || [];
if (!users.length) {
results.innerHTML = '<div class="admin-empty">' + t('admin.noUser') + '</div>';
return;
}
var html = '';
users.forEach(function(u) {
html += '<div class="admin-item" style="cursor:pointer" onclick="app.adminSelectUser(' + u.id + ',\'' + app.esc(u.username) + '\',' + (u.role || 0) + ')">'
+ '<span class="admin-item-name">' + app.esc(u.username) + '</span>'
+ '<span class="admin-item-meta">' + (u.role_name || 'Role ' + (u.role || 0)) + '</span>'
+ '</div>';
});
results.innerHTML = html;
}
adminClearUser() {
document.getElementById('admin-user-results').innerHTML = '';
document.getElementById('admin-user-detail').classList.add('hidden');
document.getElementById('admin-user-search').value = '';
}
adminSelectUser(id, username, role) {
var detail = document.getElementById('admin-user-detail');
document.getElementById('admin-user-detail-name').textContent = username;
document.getElementById('admin-user-role-select').value = String(role);
detail.dataset.userId = id;
detail.dataset.username = username;
detail.classList.remove('hidden');
}
async adminSetRole() {
var detail = document.getElementById('admin-user-detail');
var userId = detail.dataset.userId;
var username = detail.dataset.username;
var role = parseInt(document.getElementById('admin-user-role-select').value);
if (!userId) { this.toast(t('admin.noUser'), 'error'); return; }
var r = await this.req('/admin/users/role', {
method: 'POST',
body: JSON.stringify({ user_id: parseInt(userId), role: role, username: username })
});
if (r.success) {
this.toast(t('admin.roleUpdated'), 'success');
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
async adminResetPassword() {
var detail = document.getElementById('admin-user-detail');
var userId = detail.dataset.userId;
var username = detail.dataset.username;
if (!userId) { this.toast(t('admin.noUser'), 'error'); return; }
var r = await this.req('/admin/users/reset-password', {
method: 'POST',
body: JSON.stringify({ user_id: parseInt(userId), username: username })
});
if (r.success) {
this.toast(tr('admin.passReset', null, {pass: r.new_password || r.password || ''}), 'success');
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
// --- Passes ---
async adminLoadPasses() {
var el = document.getElementById('admin-passes-list');
if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/admin/passes');
if (!r.success) {
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
return;
}
var passes = r.passes || r.data || [];
if (!passes.length) {
el.innerHTML = '<div class="admin-empty">' + t('admin.passesEmpty') + '</div>';
return;
}
var html = '';
passes.forEach(function(p) {
html += '<div class="admin-item">'
+ '<span class="admin-item-name">' + app.esc(p.code || p.name || '?') + '</span>'
+ '<span class="admin-item-meta">' + (p.username ? app.esc(p.username) : '') + '</span>'
+ '</div>';
});
el.innerHTML = html;
}
// --- News ---
async adminLoadNewsList() {
var el = document.getElementById('admin-news-list');
if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/news');
if (!r.success) {
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
return;
}
var newsList = [];
try { newsList = (typeof r.data === 'string' ? JSON.parse(r.data) : r.data).news || []; } catch(e) {}
if (!newsList.length) {
el.innerHTML = '<div class="admin-empty">' + t('admin.newsEmpty') + '</div>';
return;
}
var html = '';
newsList.forEach(function(item, idx) {
html += '<div class="admin-news-item">'
+ '<div class="admin-news-info">'
+ '<div class="admin-news-title">' + app.esc(item.title) + '</div>'
+ '<div class="admin-news-meta">' + app.esc(item.type) + ' \u00b7 ' + app.esc(item.version || '') + '</div>'
+ '</div>'
+ '<div class="admin-item-actions">'
+ '<button class="btn-secondary btn-sm btn-danger" onclick="app.adminDeleteNews(\'' + idx + '\')">' + t('admin.delete') + '</button>'
+ '</div></div>';
});
el.innerHTML = html;
}
async adminCreateNews() {
var title = document.getElementById('admin-news-title').value.trim();
var type = document.getElementById('admin-news-type').value;
var version = document.getElementById('admin-news-version').value.trim();
var body = document.getElementById('admin-news-body').value.trim();
var status = document.getElementById('admin-news-status');
if (!title || !body) { this.toast('Fill in title and body', 'error'); return; }
var r = await this.req('/admin/news', {
method: 'POST',
body: JSON.stringify({ title: title, type: type, version: version, body: body })
});
status.classList.remove('hidden');
if (r.success) {
status.textContent = t('admin.newsCreated');
status.className = 'admin-status success';
document.getElementById('admin-news-title').value = '';
document.getElementById('admin-news-version').value = '';
document.getElementById('admin-news-body').value = '';
this.adminLoadNewsList();
} else {
status.textContent = r.error || t('admin.error');
status.className = 'admin-status error';
}
}
async adminDeleteNews(idx) {
var r = await this.req('/admin/news/delete', {
method: 'POST',
body: JSON.stringify({ index: parseInt(idx) })
});
if (r.success) {
this.toast(t('admin.newsDeleted'), 'success');
this.adminLoadNewsList();
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
// ==================== NEWS ====================
async loadNews() {
const r = await this.req('/news');
@@ -844,6 +1334,10 @@ class ZernMCLauncher {
this.toast(t('toast.noPackSelected'), 'warning');
return;
}
if (this.hasUpdate(inst)) {
await this.updateSelectedPack();
return;
}
this.toast(tr('toast.launching', null, {name: inst.name}), 'info');
const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }) });
if (r.success) {
@@ -854,52 +1348,87 @@ class ZernMCLauncher {
}
}
async updateSelectedPack() {
const inst = this.state.selectedPack;
if (!inst) return;
this.setPlayBtnText('playBar.updating', true);
const r = await this.req('/update', { method: 'POST', body: JSON.stringify({ name: inst.name }) });
if (r.success) {
this.toast(tr('toast.updated', null, {name: inst.name, version: String(r.data?.version || '')}), 'success');
if (this.state.updateMap) {
this.state.updateMap[inst.name] = false;
}
await this.loadInstances();
} else {
this.toast(r.error || t('toast.updateFailed'), 'error');
this.setPlayBtnText('playBar.update', false);
}
}
// ==================== INSTALL ====================
async showInstallModal() {
document.getElementById('install-modal').classList.remove('hidden');
document.getElementById('zernmc-pack-select').innerHTML = '<option>' + t('select.loading') + '</option>';
document.getElementById('mc-version-select').innerHTML = '<option>' + t('select.loading') + '</option>';
const packs = await this.req('/packs');
const zernmcSel = document.getElementById('zernmc-pack-select');
if (packs.success && packs.data && packs.data.length > 0) {
if (this.state.account && this.state.account.passActive) {
zernmcSel.innerHTML = '<option value="">' + t('select.selectPack') + '</option>';
zernmcSel.disabled = false;
packs.data.forEach(p => {
const o = document.createElement('option');
o.value = p.name;
o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')';
zernmcSel.appendChild(o);
});
} else {
zernmcSel.innerHTML = '<option value="">' + t('select.passRequired') + '</option>';
const mcSel = document.getElementById('mc-version-select');
if (zernmcSel) {
zernmcSel.innerHTML = '<option>' + t('select.loading') + '</option>';
}
if (mcSel) {
mcSel.innerHTML = '<option>' + t('select.loading') + '</option>';
}
try {
const packs = await this.req('/packs');
if (zernmcSel && packs.success && Array.isArray(packs.data) && packs.data.length > 0) {
if (this.state.account && this.state.account.passActive) {
zernmcSel.innerHTML = '<option value="">' + t('select.selectPack') + '</option>';
zernmcSel.disabled = false;
packs.data.forEach(p => {
const o = document.createElement('option');
o.value = p.name;
o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')';
zernmcSel.appendChild(o);
});
} else {
zernmcSel.innerHTML = '<option value="">' + t('select.passRequired') + '</option>';
zernmcSel.disabled = true;
}
} else if (zernmcSel) {
zernmcSel.innerHTML = '<option value="">' + t('select.noPacks') + '</option>';
zernmcSel.disabled = true;
}
if (this.state.account && !this.state.account.passActive) {
const zernmcTab = document.querySelector('[data-tab="zernmc"]');
if (zernmcTab) zernmcTab.style.opacity = '0.5';
} else {
const zernmcTab = document.querySelector('[data-tab="zernmc"]');
if (zernmcTab) zernmcTab.style.opacity = '1';
}
} catch (e) {
if (zernmcSel) {
zernmcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
zernmcSel.disabled = true;
}
} else {
zernmcSel.innerHTML = '<option value="">' + t('select.noPacks') + '</option>';
zernmcSel.disabled = true;
}
if (mcSel) this.enhanceSelects();
if (this.state.account && !this.state.account.passActive) {
document.querySelector('[data-tab="zernmc"]').style.opacity = '0.5';
} else {
document.querySelector('[data-tab="zernmc"]').style.opacity = '1';
}
this.enhanceSelects();
const mc = await this.req('/mc-versions');
const mcSel = document.getElementById('mc-version-select');
if (mc.success && mc.data) {
mcSel.innerHTML = '<option value="">' + t('select.selectVersion') + '</option>';
mc.data.forEach(v => {
const o = document.createElement('option');
o.value = v; o.textContent = v;
mcSel.appendChild(o);
});
} else {
mcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
if (mcSel) {
try {
const mc = await this.req('/mc-versions');
if (mc.success && Array.isArray(mc.data)) {
mcSel.innerHTML = '<option value="">' + t('select.selectVersion') + '</option>';
mc.data.forEach(v => {
const o = document.createElement('option');
o.value = v; o.textContent = v;
mcSel.appendChild(o);
});
} else {
mcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
}
} catch (e) {
mcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
}
}
this.enhanceSelects();
}
@@ -1054,9 +1583,9 @@ class ZernMCLauncher {
async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value;
const instanceName = document.getElementById('zernmc-instance-name').value.trim();
if (!packName) { this.toast(t('select.selectPack'), 'error'); return; }
if (!instanceName) { this.toast(t('install.enterName'), 'error'); return; }
// Use server pack name as instance name (no local naming)
const instanceName = packName;
const r = await this.req('/install', {
method: 'POST',
@@ -1288,6 +1817,8 @@ class ZernMCLauncher {
closeAddFriend() {
document.getElementById('add-friend-modal').classList.add('hidden');
document.getElementById('add-friend-input').value = '';
document.getElementById('add-friend-error').classList.add('hidden');
}
async submitAddFriend() {
+128 -8
View File
@@ -92,7 +92,9 @@ body {
background: var(--bg-elevated); padding: 0 4px;
}
.field input:focus + label,
.field input:not(:placeholder-shown) + label {
.field input:not(:placeholder-shown) + label,
.field textarea:focus + label,
.field textarea:not(:placeholder-shown) + label {
top: 0; font-size: 11px; color: var(--accent);
}
.field input {
@@ -174,7 +176,7 @@ body {
padding: 16px 12px;
}
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 20px; overflow: hidden; }
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
.sidebar-brand {
display: flex; align-items: center; gap: 10px;
@@ -185,12 +187,15 @@ body {
.sidebar-brand-ver { font-size: 11px; color: var(--text-muted); }
.sidebar-nav {
display: flex; justify-content: space-between; gap: 4px;
display: flex; flex-direction: column; gap: 4px;
padding-bottom: 16px; border-bottom: 1px solid var(--border);
}
.sidebar-nav-left, .sidebar-nav-right {
.sidebar-nav-primary, .sidebar-nav-secondary {
display: flex; gap: 4px;
}
.sidebar-nav-primary .nav-btn, .sidebar-nav-secondary .nav-btn {
flex: 1;
}
.nav-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px; background: transparent; border: 1px solid transparent;
@@ -226,6 +231,16 @@ body {
.pack-entry:hover { background: var(--bg-card); }
.pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); }
.pack-entry-icon { position: relative; }
.pack-entry-icon .update-badge {
position: absolute; top: -3px; right: -3px; width: 10px; height: 10px;
border-radius: 50%; background: #e94560; border: 2px solid var(--bg-main, #0f0f1a);
}
.update-label {
display: inline-block; font-size: 10px; font-weight: 600; color: #e94560;
background: rgba(233,69,96,0.12); padding: 0 5px; border-radius: 3px;
margin-left: 4px; vertical-align: middle; line-height: 16px;
}
.pack-entry-icon {
width: 32px; height: 32px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
@@ -552,7 +567,7 @@ body {
.modal {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); width: 90%; max-width: 480px;
max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);
max-height: 85vh; box-shadow: var(--shadow);
animation: floatIn 0.3s ease;
}
.modal-head {
@@ -668,12 +683,11 @@ body {
}
@media (max-width: 700px) {
.sidebar { width: 56px; min-width: 56px; }
.sidebar-brand-text, .sidebar-nav .nav-btn span,
.sidebar-brand-text, .sidebar-nav-primary .nav-btn span, .sidebar-nav-secondary .nav-btn span,
.section-header, .pack-entry-info, .user-info,
.sidebar-bottom .user-card .btn-icon:first-child { display: none; }
.sidebar-brand { justify-content: center; padding: 8px; }
.sidebar-nav { flex-direction: column; }
.sidebar-nav-left, .sidebar-nav-right { flex-direction: column; }
.sidebar-nav-primary, .sidebar-nav-secondary { flex-direction: column; }
.nav-btn { padding: 8px; }
.pack-entry { justify-content: center; padding: 8px; }
.content { padding: 12px; }
@@ -756,8 +770,114 @@ body {
.friend-request-text { font-size: 11px; color: var(--text-muted); }
.friend-request-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ========== WHITELIST MODS ========== */
.whitelist-mods-section {
margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border);
}
.whitelist-mods-section .section-header {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px;
}
.whitelist-mods-list { display: flex; flex-direction: column; gap: 4px; }
.whitelist-loading {
text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px;
}
.whitelist-mod-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius-sm); transition: var(--transition);
background: var(--bg-card);
}
.whitelist-mod-item:hover { background: var(--bg-card-hover); }
.whitelist-mod-checkbox {
width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent);
}
.whitelist-mod-info { flex: 1; min-width: 0; }
.whitelist-mod-name { font-size: 13px; font-weight: 500; color: var(--text); }
.whitelist-mod-size { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.whitelist-mod-status { font-size: 11px; color: var(--success); }
.whitelist-install-btn {
width: 100%; padding: 10px; margin-top: 8px;
background: var(--accent-soft); border: 1px solid rgba(233,69,96,0.3);
border-radius: var(--radius-sm); color: var(--accent); font-size: 13px;
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
}
.whitelist-install-btn:hover { background: var(--accent); color: #fff; }
.whitelist-install-btn:disabled { opacity: 0.4; cursor: default; }
/* ========== MODAL SM ========== */
.modal-sm { max-width: 360px; }
/* ========== BADGE SM ========== */
.badge-sm { font-size: 10px; padding: 2px 6px; }
/* ========== ADMIN PANEL ========== */
.admin-tabs {
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
}
.admin-tab {
padding: 8px 14px; background: transparent; border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12px;
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
}
.admin-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); }
.admin-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); }
.admin-tab-content { display: none; flex-direction: column; gap: 12px; }
.admin-tab-content.active { display: flex; }
.admin-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 16px;
}
.admin-card-header {
font-size: 13px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px;
border-bottom: 1px solid var(--border); color: var(--text);
}
.admin-list { display: flex; flex-direction: column; gap: 4px; }
.admin-loading, .admin-empty {
text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px;
}
.admin-row {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;
}
.admin-input {
flex: 1; padding: 8px 12px; background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text); font-size: 13px; outline: none; font-family: var(--font);
}
.admin-input:focus { border-color: var(--accent); }
.admin-select {
padding: 6px 10px; background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text); font-size: 12px; outline: none; cursor: pointer; font-family: var(--font);
}
.admin-textarea {
width: 100%; min-height: 100px; padding: 10px; background: var(--bg-surface);
border: 1px solid var(--border-light); border-radius: var(--radius-sm); color: var(--text);
font-size: 13px; outline: none; font-family: var(--font); resize: vertical;
}
.admin-textarea:focus { border-color: var(--accent); }
.admin-file-input {
font-size: 12px; color: var(--text-secondary); font-family: var(--font);
}
.admin-status {
font-size: 12px; padding: 8px 12px; border-radius: var(--radius-sm); margin-top: 8px;
}
.admin-status.success { background: rgba(74,222,128,0.1); color: var(--success); }
.admin-status.error { background: rgba(248,113,113,0.1); color: var(--error); }
.admin-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius-sm); transition: var(--transition); font-size: 13px;
}
.admin-item:hover { background: var(--bg-card-hover); }
.admin-item-name { flex: 1; color: var(--text); }
.admin-item-meta { font-size: 11px; color: var(--text-muted); }
.admin-item-actions { display: flex; gap: 6px; flex-shrink: 0; }
.admin-form { display: flex; flex-direction: column; gap: 8px; }
.admin-btn-danger { color: var(--error) !important; }
.admin-btn-danger:hover { background: rgba(248,113,113,0.1) !important; border-color: rgba(248,113,113,0.3) !important; }
.admin-news-item {
display: flex; align-items: flex-start; gap: 10px; padding: 10px;
border-radius: var(--radius-sm); transition: var(--transition); border: 1px solid var(--border);
margin-bottom: 6px;
}
.admin-news-item:hover { background: var(--bg-card-hover); }
.admin-news-info { flex: 1; min-width: 0; }
.admin-news-title { font-size: 13px; font-weight: 500; }
.admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
+331 -2
View File
@@ -1,13 +1,15 @@
# admin_router.py
from fastapi import APIRouter, HTTPException, Depends, Request, status
from fastapi import APIRouter, HTTPException, Depends, Request, status, UploadFile, File
from pydantic import BaseModel, Field
from typing import Optional, List
import structlog
import time
import secrets
import hashlib
from datetime import datetime
from pathlib import Path
from auth import get_db, require_role, log_audit, get_current_user
from auth import get_db, require_role, log_audit, get_current_user, hash_password
from roles import (
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
@@ -548,6 +550,333 @@ async def get_admin_stats(
}
# ====================== WHITELIST MODS ======================
WHITELIST_DIR = Path(__file__).parent / "whitelist"
WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt"
def _read_mods_list() -> list[dict]:
"""Parse mods.txt into list of {name, size, hash}"""
if not WHITELIST_MODS_FILE.exists():
return []
mods = []
for line in WHITELIST_MODS_FILE.read_text("utf-8").strip().split("\n"):
line = line.strip()
if not line:
continue
parts = line.split("::")
if len(parts) >= 3:
mods.append({
"name": parts[0].strip(),
"size": int(parts[1].strip()),
"hash": parts[2].strip()
})
return mods
def _write_mods_list(mods: list[dict]):
"""Write mods list to mods.txt"""
lines = [f"{m['name']} :: {m['size']} :: {m['hash']}" for m in mods]
WHITELIST_DIR.mkdir(parents=True, exist_ok=True)
WHITELIST_MODS_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
@router.get("/whitelist/mods")
async def admin_list_whitelist_mods(current_user: dict = Depends(require_role(ROLE_ELDER))):
"""List all whitelist mods (JSON)"""
return {"mods": _read_mods_list()}
@router.post("/whitelist/mods")
async def add_whitelist_mod(
file: UploadFile = File(...),
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Upload a mod file — auto-computes hash, records size, adds to whitelist"""
if not file.filename or not file.filename.endswith(".jar"):
raise HTTPException(400, "Only .jar files are allowed")
contents = await file.read()
if len(contents) == 0:
raise HTTPException(400, "Empty file")
file_hash = hashlib.sha256(contents).hexdigest()
file_size = len(contents)
WHITELIST_DIR.mkdir(parents=True, exist_ok=True)
mod_path = WHITELIST_DIR / file_hash
if mod_path.exists():
raise HTTPException(409, f"Mod with hash {file_hash} already exists")
mod_path.write_bytes(contents)
mods = _read_mods_list()
mods.append({"name": file.filename, "size": file_size, "hash": file_hash})
_write_mods_list(mods)
ip = request.client.host if request.client else "unknown"
log_audit(current_user["id"], "whitelist_add", f"Added {file.filename} ({file_hash})", ip)
return {"success": True, "name": file.filename, "size": file_size, "hash": file_hash}
@router.delete("/whitelist/mods/{mod_hash}")
async def remove_whitelist_mod(
mod_hash: str,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Remove a mod from whitelist by hash"""
if ".." in mod_hash:
raise HTTPException(400, "Invalid hash")
mod_path = WHITELIST_DIR / mod_hash
deleted_file = False
if mod_path.exists() and mod_path.is_file():
mod_path.unlink()
deleted_file = True
mods = _read_mods_list()
filtered = [m for m in mods if m["hash"] != mod_hash]
if len(filtered) == len(mods) and not deleted_file:
raise HTTPException(404, "Mod not found")
_write_mods_list(filtered)
ip = request.client.host if request.client else "unknown"
log_audit(current_user["id"], "whitelist_remove", f"Removed mod with hash {mod_hash}", ip)
return {"success": True, "hash": mod_hash, "file_deleted": deleted_file}
# ====================== ADMIN CLIENTS, USERS, PASSES ======================
@router.get("/clients")
async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
"""List online clients"""
with get_db() as conn:
rows = conn.execute("""
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address
FROM users u
LEFT JOIN user_status us ON u.id = us.user_id
LEFT JOIN user_sessions s ON u.id = s.user_id AND s.is_active = 1
WHERE us.is_online = 1
GROUP BY u.id
ORDER BY us.last_seen DESC
""").fetchall()
clients = []
for row in rows:
clients.append({
"id": row["id"],
"username": row["username"],
"online": bool(row["is_online"]),
"current_pack": row["current_pack"] or "",
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
"ip": row["ip_address"] or ""
})
return {"success": True, "clients": clients}
class UserSearchRequest(BaseModel):
search: str
@router.post("/users/search")
async def admin_search_users(
req: UserSearchRequest,
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
):
"""Search users by username"""
if not req.search.strip():
return {"users": []}
with get_db() as conn:
rows = conn.execute("""
SELECT id, username, uuid, role, created_at, last_login, is_active
FROM users WHERE username LIKE ?
ORDER BY username LIMIT 20
""", (f"%{req.search.strip()}%",)).fetchall()
users = []
for row in rows:
users.append({
"id": row["id"],
"username": row["username"],
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Unknown"),
"created_at": row["created_at"],
"last_login": row["last_login"],
"is_active": bool(row["is_active"])
})
return {"success": True, "users": users}
class RoleUpdateRequest(BaseModel):
user_id: int
role: int = Field(..., ge=0, le=4)
username: Optional[str] = None
@router.post("/users/role")
async def admin_update_user_role(
body: RoleUpdateRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Update user role"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE id = ?",
(body.user_id,)
).fetchone()
if not target:
raise HTTPException(404, "User not found")
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Cannot change creator role")
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Cannot modify equal or higher role")
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Cannot assign role above your own")
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Only creator can assign Elder")
old_role = target["role"]
conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, body.user_id))
conn.commit()
log_audit(current_user["id"], "role_change",
f"Changed role of {target['username']} from {old_role} to {body.role}", ip)
return {"success": True, "user_id": body.user_id, "username": target["username"],
"old_role": old_role, "new_role": body.role}
class ResetPasswordRequest(BaseModel):
user_id: int
username: Optional[str] = None
@router.post("/users/reset-password")
async def admin_reset_password(
body: ResetPasswordRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Reset user password"""
ip = request.client.host if request.client else "unknown"
new_password = secrets.token_hex(8)
pw_hash = hash_password(new_password)
with get_db() as conn:
target = conn.execute(
"SELECT id, username FROM users WHERE id = ?",
(body.user_id,)
).fetchone()
if not target:
raise HTTPException(404, "User not found")
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?",
(pw_hash, body.user_id))
conn.commit()
log_audit(current_user["id"], "password_reset",
f"Reset password for {target['username']}", ip)
return {"success": True, "new_password": new_password, "username": target["username"]}
@router.get("/passes")
async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
"""List all passes"""
with get_db() as conn:
rows = conn.execute("""
SELECT p.code, p.is_active, p.owner, p.activated_by, p.activated_at,
p.expires_at, p.max_uses, p.uses, u.username as activated_username
FROM passes p
LEFT JOIN users u ON p.activated_by = u.id
ORDER BY p.activated_at DESC
""").fetchall()
passes = []
for row in rows:
passes.append({
"code": row["code"],
"owner": row["owner"],
"is_active": bool(row["is_active"]),
"activated_by": row["activated_by"],
"activated_username": row["activated_username"],
"activated_at": row["activated_at"],
"expires_at": row["expires_at"],
"max_uses": row["max_uses"],
"uses": row["uses"]
})
return {"success": True, "passes": passes}
@router.post("/whitelist/mods/{mod_hash}/delete")
async def admin_delete_whitelist_mod_adapter(
mod_hash: str,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Delete whitelist mod (adapter for POST-based deletion)"""
return await remove_whitelist_mod(mod_hash, current_user, request)
# ====================== ADMIN NEWS ======================
ADMIN_NEWS_DIR = Path(__file__).parent / "news"
class CreateNewsRequest(BaseModel):
title: str
type: str = "Update"
version: str = ""
body: str
class DeleteNewsRequest(BaseModel):
index: int
@router.post("/news")
async def admin_create_news(
body: CreateNewsRequest,
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
request: Request = None,
):
"""Create a news item"""
if not body.title.strip() or not body.body.strip():
raise HTTPException(400, "Title and body are required")
ADMIN_NEWS_DIR.mkdir(parents=True, exist_ok=True)
news_id = str(int(time.time()))
file_path = ADMIN_NEWS_DIR / f"{news_id}.txt"
content = f"{body.title.strip()}\n{body.type.strip()}\n{body.version.strip()}\n{body.body.strip()}"
file_path.write_text(content, encoding="utf-8")
ip = request.client.host if request.client else "unknown"
log_audit(current_user["id"], "news_create", f"Created news: {body.title}", ip)
return {"success": True, "id": news_id}
@router.post("/news/delete")
async def admin_delete_news(
body: DeleteNewsRequest,
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
request: Request = None,
):
"""Delete a news item by displayed index (0 = newest)"""
if not ADMIN_NEWS_DIR.exists():
raise HTTPException(404, "No news to delete")
files = sorted(ADMIN_NEWS_DIR.iterdir())
news_files = [f for f in files if f.is_file() and f.suffix == ".txt"]
if body.index < 0 or body.index >= len(news_files):
raise HTTPException(404, "News not found")
# Displayed list is reversed (newest first), so map index
target = news_files[len(news_files) - 1 - body.index]
target.unlink()
ip = request.client.host if request.client else "unknown"
log_audit(current_user["id"], "news_delete", f"Deleted news: {target.stem}", ip)
return {"success": True, "id": target.stem}
@router.get("/me")
async def get_my_info(current_user: dict = Depends(get_current_user)):
"""Информация о текущем пользователе с правами"""
+63 -25
View File
@@ -43,6 +43,7 @@ manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
WHITELIST_DIR = Path(__file__).parent / "whitelist"
# Mirror configuration
LAUNCHER_MIRRORS = {
@@ -1256,36 +1257,42 @@ def generate_launcher_builds_meta():
meta_path = BUILDS_DIR / "meta.json"
# Check if meta exists and is fresh
if meta_path.exists():
try:
with open(meta_path, 'r', encoding='utf-8') as f:
existing = json.load(f)
if existing.get("version") == version:
logger.debug("Launcher meta.json already exists and is current")
return
except Exception:
pass
# Always regenerate to pick up file changes (new JS, CSS, etc.)
# The version check alone is insufficient — same version can have different files
# Generate new meta
files = []
try:
for file_path in BUILDS_DIR.rglob("*"):
if file_path.is_file() and file_path.name not in ["meta.json"]:
rel_path = str(file_path.relative_to(BUILDS_DIR))
stat = file_path.stat()
# Calculate hash
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256_hash.update(chunk)
files.append({
"path": rel_path,
"size": stat.st_size,
"hash": f"sha256:{sha256_hash.hexdigest()}"
})
if not file_path.is_file():
continue
rel_path = str(file_path.relative_to(BUILDS_DIR))
# Only include runtime files needed for launcher updates
# Skip: JRE, JavaFX, old ZIPs, extracted versions, docs, root JARs
if rel_path.startswith(("jre21/", "lib/", "lib-javafx/", "versions/", "libs/")):
continue
if rel_path.endswith(".zip") or rel_path in ("README.txt", "build.version"):
continue
if file_path.name in ("meta.json",):
continue
# Skip root-level JARs — only bin/*.jar is needed at runtime
if rel_path in ("zernmclauncher.jar", "zernmc-bootstrap.jar"):
continue
stat = file_path.stat()
# Calculate hash
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256_hash.update(chunk)
files.append({
"path": rel_path,
"size": stat.st_size,
"hash": f"sha256:{sha256_hash.hexdigest()}"
})
except Exception as e:
logger.warning(f"Failed to generate launcher meta: {e}")
return
@@ -1905,6 +1912,37 @@ async def get_news(news_id: str):
}
# ====================== WHITELIST MODS ======================
WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt"
@app.get("/whitelist/mods")
async def list_whitelist_mods(current_user: dict = Depends(get_current_user)):
"""Return whitelist mods list as name :: size :: hash lines"""
if not WHITELIST_MODS_FILE.exists():
return Response(content="", media_type="text/plain")
content = WHITELIST_MODS_FILE.read_text(encoding="utf-8").strip()
if not content:
return Response(content="", media_type="text/plain")
return Response(content=content, media_type="text/plain")
@app.get("/whitelist/mods/{mod_hash}/{filename:path}")
async def download_whitelist_mod(mod_hash: str, filename: str, request: Request, current_user: dict = Depends(get_current_user)):
"""Download whitelist mod file by hash"""
if ".." in mod_hash or ".." in filename:
raise HTTPException(400, "Invalid path")
mod_path = WHITELIST_DIR / mod_hash
if not mod_path.exists() or not mod_path.is_file():
raise HTTPException(404, "Mod not found")
return await send_file_async(mod_path, request, cache=True)
# ====================== ПРОКСИ ЭНДПОИНТЫ ======================
# Эти эндпоинты позволяют клиентам с сетевыми проблемами
# скачивать файлы через сервер Zern
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
Playwright test for the launcher install modal.
Mocks the server and tests that the install modal loads packs correctly.
"""
import json, os, threading, time, socket, sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from playwright.sync_api import sync_playwright
UI_DIR = Path("/root/launcher/launcher/launcher/src/resources/ui")
PORT = 9877
MOCK_INSTANCES = [
{"name": "ZernMC-Vanilla", "version": "1.21", "loaderType": "vanilla",
"isServerPack": True, "serverPackName": "ZernMC", "serverVersion": 1,
"filesCount": 0, "category": "zernmc"},
{"name": "My Local World", "version": "1.20.1", "loaderType": "fabric",
"isServerPack": False, "serverPackName": None, "serverVersion": 0,
"filesCount": 42, "category": "local",
"loaderVersion": "0.15.11"},
]
MOCK_SERVER_PACKS = [
{"name": "ZernMC", "version": 5, "minecraft_version": "1.21", "loader_type": "vanilla",
"files_count": 128, "description": "Main server pack", "displayName": "ZernMC"},
{"name": "ZernMC-Modded", "version": 3, "minecraft_version": "1.20.1", "loader_type": "fabric",
"files_count": 256, "loader_version": "0.15.11", "description": "Modded pack"},
]
MOCK_SETTINGS = {
"maxMemory": 4096, "windowWidth": 1280, "windowHeight": 720,
"extraJvmArgs": "", "javaPath": "", "locale": "en",
"systemBasedJvm": False, "cpuCores": 4, "totalRamMB": 8192,
"serverUrl": "http://localhost:9877", "instancesDir": "/tmp/zernmc-test/instances",
}
class MockHandler(BaseHTTPRequestHandler):
def _send_json(self, data, status=200):
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_body(self):
length = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(length)) if length > 0 else {}
def _serve_file(self, filename):
file_path = UI_DIR / filename
if not file_path.exists() or not file_path.is_file():
return False
content = file_path.read_bytes()
ext = file_path.suffix
ct_map = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8"}
self.send_response(200)
self.send_header("Content-Type", ct_map.get(ext, "application/octet-stream"))
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
return True
def do_GET(self):
path = self.path
if path in ("/", "/index.html"):
self._serve_file("index.html")
elif path == "/launcher.js":
self._serve_file("launcher.js")
elif path == "/style.css":
self._serve_file("style.css")
elif path == "/marked.min.js":
self._serve_file("marked.min.js")
elif "/api/auto-login" in path:
self._send_json({"success": True, "autoLogin": True,
"data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
elif "/api/account" in path:
self._send_json({"success": True, "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
elif "/api/settings" in path:
self._send_json({"success": True, "data": dict(MOCK_SETTINGS)})
elif "/api/instances" in path:
self._send_json({"success": True, "data": MOCK_INSTANCES})
elif "/api/packs" in path:
self._send_json({"success": True, "data": MOCK_SERVER_PACKS})
elif "/api/news" in path:
self._send_json({"success": True, "data": json.dumps({"news": []})})
elif "/api/mc-versions" in path:
self._send_json({"success": True, "data": ["1.21", "1.20.1", "1.20", "1.19.4"]})
elif "/api/loader-versions" in path:
self._send_json({"success": True, "data": ["0.15.11", "0.15.10"]})
elif "/api/pack-info" in path:
self._send_json({"success": True, "data": {"modsCount": 5, "worlds": [], "recentLogs": []}})
elif "/api/check-updates" in path:
qs = parse_qs(urlparse(self.path).query)
name = qs.get("name", [""])[0]
for inst in MOCK_INSTANCES:
if inst["name"] == name:
self._send_json({"success": True, "data": {"hasUpdate": False, "serverVersion": 1, "localVersion": 1}})
return
self._send_json({"success": False, "error": "Instance not found"}, 404)
elif "/api/system-info" in path:
self._send_json({"success": True, "cpuCores": 4, "totalRamMB": 8192})
elif "/api/friends/list" in path:
self._send_json({"friends": []})
elif "/api/friends/requests" in path:
self._send_json({"requests": []})
elif "/api/playtime/stats" in path:
self._send_json({"total_minutes": 0, "total_hours": 0, "packs": []})
else:
self._send_json({"success": False, "error": "Not found"}, 404)
def do_POST(self):
path = self.path
body = self._read_body()
if "/api/login" in path:
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
elif "/api/register" in path:
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
elif "/api/settings" in path:
self._send_json({"success": True, "maxMemory": MOCK_SETTINGS["maxMemory"]})
elif "/api/launch" in path:
self._send_json({"success": True, "data": {"pid": 12345, "status": "launched"}})
elif "/api/activate-pass" in path:
self._send_json({"success": True, "message": "Pass activated!"})
elif "/api/logout" in path:
self._send_json({"success": True})
elif "/api/update" in path:
name = body.get("name", "")
self._send_json({"success": True, "data": {"version": 2}})
elif "/api/install" in path:
self._send_json({"success": True, "message": "Installation started"})
elif "/api/install/progress" in path:
self._send_json({"success": True, "data": {
"label": "Complete", "inProgress": False, "percent": 100,
"stageName": "Done", "stageIndex": 0, "stageCount": 1
}})
else:
self._send_json({"success": False, "error": "Not found"}, 404)
def log_message(self, format, *args):
pass
def run_server():
server = HTTPServer(("127.0.0.1", PORT), MockHandler)
server.serve_forever()
def wait_for_server(host, port, timeout=10):
start = time.time()
while time.time() - start < timeout:
try:
s = socket.socket()
s.connect((host, port))
s.close()
return True
except:
time.sleep(0.1)
return False
def main():
svr = threading.Thread(target=run_server, daemon=True)
svr.start()
if not wait_for_server("127.0.0.1", PORT):
print("Failed to start mock server")
return 1
print(f"Mock server running on http://127.0.0.1:{PORT}")
passed = 0
failed = 0
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 720})
page = context.new_page()
console_errors = []
page.on("console", lambda msg: console_errors.append(f"[{msg.type}] {msg.text}") if msg.type == "error" else None)
page.on("pageerror", lambda err: console_errors.append(f"[PAGE_ERROR] {err}"))
try:
# ===== Navigate and wait for login =====
page.goto(f"http://127.0.0.1:{PORT}/", wait_until="load", timeout=15000)
page.wait_for_timeout(3000)
# Verify we're on main screen
main_screen = page.locator("#main-screen")
if not main_screen.is_visible():
print("FAIL: Main screen not visible after auto-login")
return 1
print("--- Test 1: Open install modal ---")
add_pack_btn = page.locator("#add-pack-btn")
add_pack_btn.click()
page.wait_for_timeout(1000)
install_modal = page.locator("#install-modal")
if install_modal.is_visible():
print(" PASS: Install modal opened")
passed += 1
else:
print(" FAIL: Install modal not visible")
failed += 1
# ===== Check pack select (zernmc-pack-select) =====
print("--- Test 2: Pack select populated ---")
pack_select = page.locator("#zernmc-pack-select")
page.wait_for_timeout(500)
options_text = pack_select.evaluate("sel => Array.from(sel.options).map(o => o.text).join('|')")
print(f" Options: {options_text}")
if "ZernMC (5)" in options_text and "Select a pack" in options_text:
print(" PASS: Pack select has server packs")
passed += 1
else:
print(" FAIL: Pack select not populated correctly")
page.screenshot(path="/tmp/install-modal-packs.png")
failed += 1
# ===== Check MC version select (may not exist in HTML) =====
print("--- Test 3: MC version select handled gracefully ---")
mc_select = page.locator("#mc-version-select")
mc_exists = mc_select.count()
if mc_exists == 0:
print(" PASS: MC version select absent (custom tab disabled), no crash")
else:
options_text = mc_select.evaluate("sel => Array.from(sel.options).map(o => o.text).join('|')")
print(f" Options: {options_text}")
if "1.21" in options_text:
print(" PASS: MC version select populated")
else:
print(" FAIL: MC version select not populated")
page.screenshot(path="/tmp/install-modal-mc.png")
passed += 1
# ===== Check install button exists =====
print("--- Test 4: Install button exists ---")
install_btn = page.locator("#install-zernmc-btn")
if install_btn.is_visible():
print(" PASS: Install button visible")
passed += 1
else:
print(" FAIL: Install button not visible")
failed += 1
# ===== Check instance name input =====
print("--- Test 5: Instance name input exists ---")
name_input = page.locator("#zernmc-instance-name")
if name_input.is_visible():
print(" PASS: Instance name input visible")
passed += 1
else:
print(" FAIL: Instance name input not visible")
failed += 1
except Exception as e:
print(f" FAIL: {e}")
import traceback
traceback.print_exc()
failed += 1
if console_errors:
print(f"\n--- Console errors ({len(console_errors)}) ---")
for e in console_errors[-10:]:
print(f" {e}")
browser.close()
print(f"\n{'='*40}")
print(f"Results: {passed} passed, {failed} failed")
print(f"{'='*40}")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+12
View File
@@ -128,6 +128,15 @@ class MockHandler(BaseHTTPRequestHandler):
self._send_json({"requests": []})
elif "/api/playtime/stats" in path:
self._send_json({"total_minutes": 120, "total_hours": 2.0, "packs": [{"pack_name": "TestPack", "minutes": 120}]})
elif "/api/check-updates" in path:
from urllib.parse import urlparse, parse_qs
qs = parse_qs(urlparse(self.path).query)
name = qs.get("name", [""])[0]
for inst in MOCK_INSTANCES:
if inst["name"] == name:
self._send_json({"success": True, "data": {"hasUpdate": False, "serverVersion": 1, "localVersion": 1}})
return
self._send_json({"success": False, "error": "Instance not found"}, 404)
else:
self._send_json({"success": False, "error": "Not found"}, 404)
@@ -166,6 +175,9 @@ class MockHandler(BaseHTTPRequestHandler):
self._send_json({"status": "ok"})
elif "/api/playtime/sync" in path:
self._send_json({"status": "ok"})
elif "/api/update" in path:
name = body.get("name", "")
self._send_json({"success": True, "data": {"version": 2}})
else:
self._send_json({"success": False, "error": "Not found"}, 404)