From 201269efeaf437ca87f8da849865db36ad1b97b9 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Mon, 13 Jul 2026 13:16:51 +0000 Subject: [PATCH] =?UTF-8?q?ForgeFix=20=D0=B8=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=84=D0=B8=D1=87=D0=B8=20=D0=B2=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sashegdev/zernmc/launcher/Bootstrap.java | 28 +- launcher/launcher/pom.xml | 20 +- .../java/sashegdev/zernmc/launcher/Main.java | 36 +- .../zernmc/launcher/menu/LaunchMenu.java | 83 +---- .../zernmc/launcher/minecraft/Instance.java | 17 +- .../launcher/minecraft/MinecraftLib.java | 37 ++ .../launcher/minecraft/PackDownloader.java | 319 +++++++++++++---- .../minecraft/installer/ForgeInstaller.java | 3 +- .../installer/NeoForgeInstaller.java | 3 +- .../launch/LaunchCommandBuilder.java | 328 ++++++++++++++++-- .../minecraft/launch/VersionManifest.java | 81 +++-- .../zernmc/launcher/ui/jfx/JFXLauncher.java | 44 ++- .../zernmc/launcher/utils/Config.java | 10 - .../zernmc/launcher/utils/LauncherLogger.java | 4 +- .../zernmc/launcher/utils/ZHttpClient.java | 14 +- launcher/launcher/src/resources/ui/index.html | 24 ++ .../launcher/src/resources/ui/launcher.js | 163 ++++++++- launcher/launcher/src/resources/ui/style.css | 6 + launcher/pom.xml | 23 +- server/admin_router.py | 54 +++ server/main.py | 59 +++- server/middleware.py | 27 +- server/pack_manager.py | 110 +++++- 23 files changed, 1166 insertions(+), 327 deletions(-) diff --git a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java index 1f997e7..e73ac52 100644 --- a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java +++ b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java @@ -210,6 +210,10 @@ public class Bootstrap { if (ui != null) ui.setProgress(current, total); } + private static void setSpeedText(String text) { + if (ui != null) ui.setSpeedText(text); + } + private static void setVersionInfo(String localVer, String serverVer) { if (ui != null) ui.setVersionInfo(localVer, serverVer); } @@ -467,13 +471,25 @@ public class Bootstrap { if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) { long elapsed = System.currentTimeMillis() - startTime; - double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001); + double speedBps = elapsed > 0 ? downloaded * 1000.0 / elapsed : 0; + double speed = speedBps / 1024.0 / 1024.0; double downloadedMB = downloaded / 1024.0 / 1024.0; double totalMB = expectedSize / 1024.0 / 1024.0; - - String progressStr = String.format("%.1f/%.1f MB (%.1f MB/s)", downloadedMB, totalMB, speed); + String etaStr = ""; + if (speedBps > 0) { + long etaSec = (long)((expectedSize - downloaded) / speedBps); + if (etaSec > 3600) { + etaStr = String.format(" ETA: %dh %dm", etaSec / 3600, (etaSec % 3600) / 60); + } else if (etaSec > 60) { + etaStr = String.format(" ETA: %dm %ds", etaSec / 60, etaSec % 60); + } else if (etaSec > 0) { + etaStr = " ETA: " + etaSec + "s"; + } + } + String progressStr = String.format("%.1f/%.1f MB (%.1f MB/s%s)", downloadedMB, totalMB, speed, etaStr); log(progressStr); setProgress((int) downloaded, (int) Math.max(expectedSize, 1)); + setSpeedText(String.format("%.1f / %.1f MB \u2022 %.1f MB/s", downloadedMB, totalMB, speed)); lastUpdate = downloaded; } } @@ -698,14 +714,16 @@ public class Bootstrap { if (total > 0) { int pct = (int) ((long) current * 100 / total); progressBar.setValue(Math.min(pct, 100)); - speedLabel.setText(String.format("%.1f / %.1f MB", - current / 1024.0 / 1024.0, total / 1024.0 / 1024.0)); } else { progressBar.setIndeterminate(true); } }); } + void setSpeedText(final String text) { + SwingUtilities.invokeLater(() -> speedLabel.setText(text)); + } + void setVersionInfo(final String local, final String server) { SwingUtilities.invokeLater(() -> versionLabel.setText("v" + local + " \u2192 v" + server)); diff --git a/launcher/launcher/pom.xml b/launcher/launcher/pom.xml index 92ec24c..4aec56b 100644 --- a/launcher/launcher/pom.xml +++ b/launcher/launcher/pom.xml @@ -61,31 +61,31 @@ org.openjfx javafx-controls - 21 + 23 win org.openjfx javafx-web - 21 + 23 win org.openjfx javafx-graphics - 21 + 23 win org.openjfx javafx-base - 21 + 23 win org.openjfx javafx-media - 21 + 23 win @@ -226,27 +226,27 @@ - + - + - + - + - + diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java index 829723e..a6416f5 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java @@ -96,41 +96,7 @@ public class Main { } private static void mainLoop() throws Exception { - if (Config.isZernMCBuild()) { - zernMCFlow(); - } else { - globalFlow(); - } - } - - private static void zernMCFlow() throws Exception { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.header("=== ZernMC Private Launcher ===")); - - System.out.println(ZAnsi.cyan("Checking connection to ZernMC server...")); - try { - String response = ZHttpClient.get("/health"); - System.out.println(ZAnsi.brightGreen("✓ Server is available")); - } catch (Exception e) { - System.out.println(ZAnsi.brightRed("✗ Could not connect to ZernMC server")); - System.out.println(ZAnsi.white("Error: " + e.getMessage())); - ConsoleUtils.pause(); - System.exit(1); - } - - boolean sessionRestored = AuthManager.loadSavedSession(); - if (!sessionRestored) { - LoginMenu loginMenu = new LoginMenu(); - boolean loggedIn = loginMenu.show(); - if (!loggedIn) { - System.exit(0); - } - } else { - System.out.println(ZAnsi.brightGreen("Welcome back, " + AuthManager.getUsername() + "!")); - } - - LaunchMenu launchMenu = new LaunchMenu(); - launchMenu.show(); + globalFlow(); } private static void globalFlow() throws Exception { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LaunchMenu.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LaunchMenu.java index aed2fdf..33b54ec 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LaunchMenu.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/menu/LaunchMenu.java @@ -26,51 +26,7 @@ import java.util.stream.Collectors; public class LaunchMenu { public void show() throws Exception { - if (Config.isZernMCBuild()) { - showZernMCOnly(); - } else { - showGlobal(); - } - } - - private void showZernMCOnly() throws Exception { - while (true) { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.header("=== ZernMC Private Launcher ===")); - System.out.println(ZAnsi.cyan("Server packs only")); - - if (!awaitActivePass()) { - return; - } - - PackDownloader tempDownloader = new PackDownloader(null); - List availablePacks = tempDownloader.getAvailablePacks(); - - if (availablePacks.isEmpty()) { - System.out.println(ZAnsi.yellow("No packs available on the server.")); - ConsoleUtils.pause(); - return; - } - - List options = availablePacks.stream() - .map(p -> String.format("%s [%s + %s v%d] - %d files", - p.getName(), - p.getMinecraftVersion(), - p.getLoaderType(), - p.getVersion(), - p.getFilesCount())) - .collect(Collectors.toList()); - - options.add("Back to main menu"); - - ArrowMenu menu = new ArrowMenu("Select a pack", options); - int choice = menu.show(); - - if (choice == -1 || choice == options.size() - 1) return; - - ServerPack selected = availablePacks.get(choice); - installAndRunServerPack(selected); - } + showGlobal(); } private boolean awaitActivePass() throws Exception { @@ -130,43 +86,6 @@ public class LaunchMenu { } } - private void installAndRunServerPack(ServerPack selected) throws Exception { - ConsoleUtils.clearScreen(); - System.out.println(ZAnsi.header("Installing pack: " + selected.getName())); - - System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion()); - System.out.println(ZAnsi.white(" Loader: ") + selected.getLoaderType() + - (selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : "")); - System.out.println(ZAnsi.white(" Version: v") + selected.getVersion()); - System.out.println(ZAnsi.white(" Files: ") + selected.getFilesCount()); - - String localName = askPackName(); - if (localName == null) return; - - if (InstanceManager.getInstance(localName) != null) { - System.out.println(ZAnsi.brightRed("A pack with this name already exists!")); - ConsoleUtils.pause(); - return; - } - - InstanceManager.createInstanceFolder(localName); - Instance newInstance = InstanceManager.getInstance(localName); - - PackDownloader packDownloader = new PackDownloader(newInstance); - boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected); - - if (!success) { - System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack.")); - ConsoleUtils.pause(); - return; - } - - System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!")); - ConsoleUtils.pause(); - - launchExistingInstance(newInstance); - } - private void showGlobal() throws Exception { while (true) { ConsoleUtils.clearScreen(); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java index da5eb2a..f989a98 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java @@ -21,6 +21,7 @@ public class Instance { private int serverVersion; // версия сборки на сервере private String serverPackName; // имя пака на сервере private String fabricVersionId; + private String presetId; private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @@ -88,6 +89,15 @@ public class Instance { saveMetadata(); } + public String getPresetId() { + return presetId; + } + + public void setPresetId(String presetId) { + this.presetId = presetId; + saveMetadata(); + } + public String getServerPackName() { return serverPackName; } @@ -130,6 +140,7 @@ public class Instance { this.isServerPack = meta.isServerPack; this.serverVersion = meta.serverVersion; this.serverPackName = meta.serverPackName; + this.presetId = meta.presetId; } catch (Exception e) { System.err.println("[Instance] Failed to load metadata for " + name + ": " + e.getMessage()); } @@ -139,7 +150,7 @@ public class Instance { Path metaFile = path.resolve("instance.json"); InstanceMeta meta = new InstanceMeta( minecraftVersion, loaderType, loaderVersion, assetIndex, - isServerPack, serverVersion, serverPackName + isServerPack, serverVersion, serverPackName, presetId ); try { Files.writeString(metaFile, GSON.toJson(meta)); @@ -156,12 +167,13 @@ public class Instance { boolean isServerPack = false; int serverVersion = 0; String serverPackName; + String presetId; public InstanceMeta(String minecraftVersion, String loaderType, String loaderVersion, String assetIndex, boolean isServerPack, int serverVersion, - String serverPackName) { + String serverPackName, String presetId) { this.minecraftVersion = minecraftVersion; this.loaderType = loaderType; this.loaderVersion = loaderVersion; @@ -169,6 +181,7 @@ public class Instance { this.isServerPack = isServerPack; this.serverVersion = serverVersion; this.serverPackName = serverPackName; + this.presetId = presetId; } } } \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java index 0219bf9..0fde470 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java @@ -18,6 +18,8 @@ import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; public class MinecraftLib { @@ -110,6 +112,7 @@ public class MinecraftLib { public void launch(LaunchOptions options) throws Exception { System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName())); cleanupOldLoaders(); + validateJarFiles(); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); List command = builder.build(options); @@ -224,6 +227,40 @@ public class MinecraftLib { + private void validateJarFiles() { + Path[] dirs = { + instance.getPath().resolve("mods"), + instance.getPath().resolve("libraries") + }; + for (Path dir : dirs) { + if (!Files.exists(dir)) continue; + try (var stream = Files.walk(dir)) { + stream.filter(p -> p.toString().endsWith(".jar")) + .filter(p -> !isValidJar(p)) + .forEach(p -> { + try { + System.out.println(ZAnsi.yellow(" Removing corrupt JAR: " + instance.getPath().relativize(p))); + Files.delete(p); + } catch (IOException e) { + System.out.println(ZAnsi.red(" Failed to delete: " + p.getFileName())); + } + }); + } catch (Exception e) { + System.out.println(ZAnsi.yellow(" Error scanning for corrupt JARs: " + e.getMessage())); + } + } + } + + private boolean isValidJar(Path path) { + try (ZipFile zf = new ZipFile(path.toFile())) { + return true; + } catch (ZipException e) { + return false; + } catch (IOException e) { + return false; + } + } + public Instance getInstance() { return instance; } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java index c66ab23..c1f7d07 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java @@ -14,17 +14,15 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import java.io.*; -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.security.MessageDigest; -import java.time.Duration; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; public class PackDownloader { @@ -33,9 +31,10 @@ public class PackDownloader { void onProgress(String label, int percent, String stageName, int stageIndex, int stageCount); } + private static final String USER_FILES_FILE = ".user-files.json"; + 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; @@ -157,65 +156,89 @@ public class PackDownloader { * Install or update a pack from the server */ public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception { - LauncherLogger.info("Installing pack " + packName + " from server..."); + return installOrUpdatePack(packName, serverPack, null); + } + + public boolean installOrUpdatePack(String packName, ServerPack serverPack, String preset) throws Exception { + LauncherLogger.info("installOrUpdatePack: START packName=" + packName + " mc=" + serverPack.getMinecraftVersion() + " loader=" + serverPack.getLoaderType() + " preset=" + (preset != null ? preset : "none")); reportProgress("Fetching manifest...", 0, "manifest", 0, 5); // 1. Get manifest PackManifest manifest = getPackManifest(packName); + LauncherLogger.info("installOrUpdatePack: manifest loaded version=" + manifest.getVersion() + " mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderType() + " files=" + (manifest.files == null ? "null" : manifest.files.size())); // 2. First install Minecraft + Loader via MinecraftLib MinecraftLib lib = new MinecraftLib(instance); - System.out.println(ZAnsi.cyan("Installing Minecraft " + manifest.getMinecraftVersion() + "...")); + LauncherLogger.info("installOrUpdatePack: checking Minecraft install. currentMc=" + instance.getMinecraftVersion() + " targetMc=" + manifest.getMinecraftVersion()); boolean needsMinecraftInstall = instance.getMinecraftVersion() == null || !instance.getMinecraftVersion().equals(manifest.getMinecraftVersion()); if (needsMinecraftInstall) { + LauncherLogger.info("installOrUpdatePack: needs Minecraft install. loader=" + manifest.getLoaderType() + " loaderVer=" + manifest.getLoaderVersion()); if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) { + LauncherLogger.info("installOrUpdatePack: installing Fabric mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion()); boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); + LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success); if (!success) { System.err.println(ZAnsi.brightRed("Failed to install Fabric")); + LauncherLogger.error("installOrUpdatePack: Fabric install failed"); reportProgress("Failed to install Fabric", 0, "error", 0, 1); return false; } } else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) { + LauncherLogger.info("installOrUpdatePack: installing NeoForge mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion()); boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); + LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success); if (!success) { System.err.println(ZAnsi.brightRed("Failed to install NeoForge")); + LauncherLogger.error("installOrUpdatePack: NeoForge install failed"); reportProgress("Failed to install NeoForge", 0, "error", 0, 1); return false; } } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { + LauncherLogger.info("installOrUpdatePack: installing Forge mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion()); boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); + LauncherLogger.info("installOrUpdatePack: Forge install result=" + success); if (!success) { System.err.println(ZAnsi.brightRed("Failed to install Forge")); + LauncherLogger.error("installOrUpdatePack: Forge install failed"); reportProgress("Failed to install Forge", 0, "error", 0, 1); return false; } } else { + LauncherLogger.info("installOrUpdatePack: installing Vanilla Minecraft " + manifest.getMinecraftVersion()); boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); + LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success); if (!success) { System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft")); + LauncherLogger.error("installOrUpdatePack: Vanilla install failed"); reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1); return false; } } + LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully"); } else { + LauncherLogger.info("installOrUpdatePack: Minecraft already installed, skipping"); 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 + LauncherLogger.info("installOrUpdatePack: scanning local files..."); Map localFiles = scanLocalFiles(); + LauncherLogger.info("installOrUpdatePack: scanLocalFiles returned " + localFiles.size() + " files. instancePath=" + instance.getPath()); // If pack has no files (vanilla/loader only), skip diff if (manifest.files == null || manifest.files.isEmpty()) { + LauncherLogger.info("installOrUpdatePack: no files in manifest, skipping diff"); System.out.println(ZAnsi.green("Pack contains no additional files")); reportProgress("Installing...", 50, "Installing pack files", 4, 5); - // Update instance metadata + saveUserFilesSnapshot(localFiles); + instance.setServerPack(true); instance.setServerPackName(packName); instance.setServerVersion(manifest.getVersion()); @@ -228,18 +251,44 @@ public class PackDownloader { System.out.println(ZAnsi.brightGreen("Pack installed successfully!")); return true; } + LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff"); // 4. Send diff request - System.out.println(ZAnsi.cyan("Checking pack files...")); + LauncherLogger.info("installOrUpdatePack: requesting diff from server..."); reportProgress("Checking pack files...", 40, "Installing pack files", 4, 5); - DiffResponse diff = getDiff(packName, localFiles); + DiffResponse diff = null; + try { + diff = getDiff(packName, localFiles, preset); + LauncherLogger.info("installOrUpdatePack: diff received: toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " version=" + diff.getVersion()); + } catch (Exception e) { + LauncherLogger.error("installOrUpdatePack: getDiff failed: " + e.getClass().getName() + " - " + e.getMessage()); + e.printStackTrace(); + throw e; + } - // 5. Apply changes + // 5. Detect user-modified files + Set protectedFiles = getUserModifiedFiles(localFiles); + LauncherLogger.info("installOrUpdatePack: user-modified files count=" + protectedFiles.size()); + + // 6. Apply changes + LauncherLogger.info("installOrUpdatePack: starting applyDiff..."); reportProgress("Downloading pack files...", 50, "Installing pack files", 4, 5); - boolean success = applyDiff(diff, packName); + boolean success = false; + try { + success = applyDiff(diff, packName, protectedFiles); + LauncherLogger.info("installOrUpdatePack: applyDiff returned=" + success); + } catch (Exception e) { + LauncherLogger.error("installOrUpdatePack: applyDiff threw exception: " + e.getClass().getName() + " - " + e.getMessage()); + e.printStackTrace(); + throw e; + } if (success) { - // 6. Update instance metadata + LauncherLogger.info("installOrUpdatePack: saving user files snapshot..."); + Map updatedHashes = scanLocalFiles(); + saveUserFilesSnapshot(updatedHashes); + + LauncherLogger.info("installOrUpdatePack: updating instance metadata..."); instance.setServerPack(true); instance.setServerPackName(packName); instance.setServerVersion(manifest.getVersion()); @@ -249,6 +298,9 @@ public class PackDownloader { instance.setAssetIndex(manifest.getAssetIndex()); System.out.println(ZAnsi.brightGreen("Pack installed successfully!")); + LauncherLogger.info("installOrUpdatePack: SUCCESS"); + } else { + LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception"); } return success; @@ -271,6 +323,10 @@ public class PackDownloader { * Update an existing server pack */ public boolean updatePack(String packName) throws Exception { + return updatePack(packName, instance.getPresetId()); + } + + public boolean updatePack(String packName, String preset) throws Exception { System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "...")); PackManifest manifest = getPackManifest(packName); @@ -286,13 +342,24 @@ public class PackDownloader { // Scan local files Map localFiles = scanLocalFiles(); + // Detect user-modified files (skip overwriting) + Set protectedFiles = getUserModifiedFiles(localFiles); + if (!protectedFiles.isEmpty()) { + LauncherLogger.info("[DEBUG] Found " + protectedFiles.size() + " user-modified files (will not overwrite)"); + System.out.println(ZAnsi.yellow("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten")); + } + // Get diff - DiffResponse diff = getDiff(packName, localFiles); + DiffResponse diff = getDiff(packName, localFiles, preset); // Apply changes - boolean success = applyDiff(diff, packName); + boolean success = applyDiff(diff, packName, protectedFiles, preset); if (success) { + // Save new user files snapshot + Map updatedHashes = scanLocalFiles(); + saveUserFilesSnapshot(updatedHashes); + instance.setServerVersion(serverVersion); System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion)); } @@ -310,7 +377,7 @@ public class PackDownloader { // Игнорируемые директории Set ignoredDirs = Set.of( "resourcepacks", "shaderpacks", "saves", "logs", - "crash-reports", "screenshots", "journeymap", "config", + "crash-reports", "screenshots", "journeymap", "natives", "assets", "libraries", "versions", "cache" ); @@ -324,7 +391,8 @@ public class PackDownloader { Path relative = instancePath.relativize(file); String path = relative.toString().replace("\\", "/"); - // Проверяем, не в игнорируемой ли директории + // Skip user files snapshot and ignored directories + if (path.equals(USER_FILES_FILE)) return; for (String ignored : ignoredDirs) { if (path.startsWith(ignored + "/") || path.startsWith(ignored + "\\")) { return; @@ -346,6 +414,10 @@ public class PackDownloader { * Send diff request to server */ private DiffResponse getDiff(String packName, Map localFiles) throws Exception { + return getDiff(packName, localFiles, null); + } + + private DiffResponse getDiff(String packName, Map localFiles, String preset) throws Exception { String json = gson.toJson(localFiles); // Get auth token @@ -358,6 +430,10 @@ public class PackDownloader { } String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff"; + if (preset != null && !preset.isEmpty()) { + url += "?preset=" + java.net.URLEncoder.encode(preset, "UTF-8"); + } + LauncherLogger.info("getDiff: url=" + url + " bodySize=" + json.length() + " authToken=" + (accessToken != null ? "present" : "null") + " localFilesCount=" + json.length()); // Use HttpURLConnection for full control java.net.HttpURLConnection connection = null; @@ -381,6 +457,7 @@ public class PackDownloader { } int responseCode = connection.getResponseCode(); + LauncherLogger.info("getDiff: responseCode=" + responseCode + " packName=" + packName); // Read response StringBuilder response = new StringBuilder(); @@ -393,6 +470,7 @@ public class PackDownloader { } String responseBody = response.toString(); + LauncherLogger.info("[DEBUG] getDiff response body: " + responseBody.substring(0, Math.min(responseBody.length(), 500))); if (responseCode == 403) { throw new IOException("Active pass required to download packs. Contact the administrator."); @@ -402,7 +480,9 @@ public class PackDownloader { throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody)); } - return gson.fromJson(responseBody, DiffResponse.class); + DiffResponse result = gson.fromJson(responseBody, DiffResponse.class); + LauncherLogger.info("[DEBUG] getDiff parsed: toDownload=" + result.getToDownload().size() + " toDelete=" + result.getToDelete().size()); + return result; } finally { if (connection != null) { @@ -425,9 +505,21 @@ public class PackDownloader { * Apply diff (download new files, delete old ones) */ private boolean applyDiff(DiffResponse diff, String packName) { + return applyDiff(diff, packName, new HashSet<>(), null); + } + + private boolean applyDiff(DiffResponse diff, String packName, Set protectedFiles) { + return applyDiff(diff, packName, protectedFiles, null); + } + + private boolean applyDiff(DiffResponse diff, String packName, Set protectedFiles, String preset) { + LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size()); System.out.println(ZAnsi.cyan("\nApplying changes:")); System.out.println(" Download: " + diff.getToDownload().size() + " files"); System.out.println(" Delete: " + diff.getToDelete().size() + " files"); + if (!protectedFiles.isEmpty()) { + System.out.println(ZAnsi.yellow(" Protected: " + protectedFiles.size() + " user-modified files (skipped)")); + } // Create directories if needed try { @@ -437,8 +529,12 @@ public class PackDownloader { return false; } - // Delete files + // Delete files (skip protected) for (String filePath : diff.getToDelete()) { + if (protectedFiles.contains(filePath)) { + System.out.println(ZAnsi.yellow(" Skipped delete (user-modified): " + filePath)); + continue; + } Path fullPath = instance.getPath().resolve(filePath); try { if (Files.deleteIfExists(fullPath)) { @@ -449,12 +545,25 @@ public class PackDownloader { } } - // Download files + // Download files (skip protected) AtomicInteger downloaded = new AtomicInteger(0); int total = diff.getToDownload().size(); + int skipped = 0; + long totalBytes = 0; + for (FileInfo f : diff.getToDownload()) totalBytes += f.getSize(); + AtomicLong downloadedBytes = new AtomicLong(0); + long downloadStartTime = System.currentTimeMillis(); + LauncherLogger.info("applyDiff: downloading " + total + " files, total size=" + totalBytes + " bytes"); for (FileInfo file : diff.getToDownload()) { String path = file.getPath(); + + if (protectedFiles.contains(path)) { + skipped++; + System.out.println(ZAnsi.yellow(" Skipped (user-modified): " + path)); + continue; + } + Path fullPath = instance.getPath().resolve(path); try { @@ -462,80 +571,103 @@ public class PackDownloader { Files.createDirectories(fullPath.getParent()); // Download file + String fileUrl = ZHttpClient.getBaseUrl() + file.getUrl(); + if (preset != null && !preset.isEmpty() && !file.getUrl().contains("preset=")) { + String sep = file.getUrl().contains("?") ? "&" : "?"; + fileUrl = fileUrl + sep + "preset=" + java.net.URLEncoder.encode(preset, "UTF-8"); + } + LauncherLogger.info("applyDiff: downloading file=" + path + " url=" + fileUrl + " size=" + file.getSize() + " hash=" + file.getHash()); downloadFile(file, fullPath); // Verify hash String actualHash = calculateHash(fullPath); - if (!actualHash.equals(file.getHash())) { - throw new IOException("Hash mismatch! Expected: " + file.getHash() + - ", got: " + actualHash); + boolean hashMatch = actualHash.equals(file.getHash()); + LauncherLogger.info("applyDiff: downloaded file=" + path + " hashMatch=" + hashMatch + " expected=" + file.getHash() + " got=" + actualHash); + if (!hashMatch) { + LauncherLogger.warn("applyDiff: hash mismatch for " + path + ", retrying once..."); + downloadFile(file, fullPath); + actualHash = calculateHash(fullPath); + hashMatch = actualHash.equals(file.getHash()); + LauncherLogger.info("applyDiff: retry result for " + path + " hashMatch=" + hashMatch); + } + if (!hashMatch) { + LauncherLogger.warn("applyDiff: skipping file " + path + " due to hash mismatch (expected=" + file.getHash() + " got=" + actualHash + ")"); + System.err.println(ZAnsi.yellow(" Skipped (hash mismatch): " + path)); + skipped++; + continue; } downloaded.incrementAndGet(); + downloadedBytes.addAndGet(file.getSize()); 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); + long elapsed = System.currentTimeMillis() - downloadStartTime; + long bytesSoFar = downloadedBytes.get(); + long remainingBytes = totalBytes - bytesSoFar; + int pct = totalBytes > 0 ? (int)(bytesSoFar * 45 / totalBytes) : (downloaded.get() * 45 / total); + pct = Math.min(pct, 45); + String speedStr = ""; + String etaStr = ""; + if (elapsed > 1000) { + long speedBps = bytesSoFar * 1000 / elapsed; + if (speedBps > 1_000_000) { + speedStr = String.format("%.1f MB/s", speedBps / 1_000_000.0); + } else if (speedBps > 1_000) { + speedStr = String.format("%.0f KB/s", speedBps / 1_000.0); + } else { + speedStr = speedBps + " B/s"; + } + if (speedBps > 0 && remainingBytes > 0) { + long etaSec = remainingBytes / speedBps; + if (etaSec > 3600) { + etaStr = String.format("%dh %dm", etaSec / 3600, (etaSec % 3600) / 60); + } else if (etaSec > 60) { + etaStr = String.format("%dm %ds", etaSec / 60, etaSec % 60); + } else { + etaStr = etaSec + "s"; + } + } + } + String label = "Downloading " + downloaded.get() + "/" + total + " files"; + if (!speedStr.isEmpty()) label += " (" + speedStr; + if (!etaStr.isEmpty()) label += " ETA: " + etaStr; + if (!speedStr.isEmpty()) label += ")"; + reportProgress(label, 50 + pct, "Installing pack files", 4, 5); } } catch (Exception e) { + LauncherLogger.error("applyDiff: download FAILED for path=" + path + " error=" + e.getClass().getName() + ": " + e.getMessage()); System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage())); - return false; + System.err.println(ZAnsi.yellow(" Skipping file " + path + " due to download error")); + skipped++; + continue; } } if (total > 0) { ProgressBar.finish("Download"); } + if (skipped > 0) { + System.out.println(ZAnsi.cyan(" Skipped " + skipped + " user-modified files")); + } return true; } /** - * Скачать один файл с сервера + * Скачать один файл с сервера через временный файл для атомарности. + * Предотвращает появление битых файлов при обрыве соединения. */ private void downloadFile(FileInfo file, Path destination) throws Exception { - String url = ZHttpClient.getBaseUrl() + file.getUrl(); - String accessToken = AuthManager.getAccessToken(); - - HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(java.net.URI.create(url)) - .timeout(Duration.ofSeconds(60)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET(); - - if (accessToken != null && !accessToken.equals("0")) { - builder.header("Authorization", "Bearer " + accessToken); + Path tmpFile = destination.resolveSibling(destination.getFileName() + ".tmp"); + try { + String url = ZHttpClient.getBaseUrl() + file.getUrl(); + ZHttpClient.downloadFileWithSmartProxy(url, tmpFile); + Files.move(tmpFile, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + try { Files.deleteIfExists(tmpFile); } catch (Exception ignored) {} + throw e; } - - HttpRequest request = builder.build(); - HttpResponse response = httpClient.send(request, - HttpResponse.BodyHandlers.ofInputStream()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); - } - - // Скачиваем с прогрессом - try (InputStream in = response.body(); - FileOutputStream out = new FileOutputStream(destination.toFile())) { - - byte[] buffer = new byte[8192]; - int bytesRead; - long totalRead = 0; - long fileSize = file.getSize(); - - while ((bytesRead = in.read(buffer)) != -1) { - out.write(buffer, 0, bytesRead); - totalRead += bytesRead; - - if (fileSize > 0 && totalRead % 8192 == 0) { - ProgressBar.showDownload(" " + file.getPath(), totalRead, fileSize); - } - } - } - - ProgressBar.clearLine(); } /** @@ -560,6 +692,59 @@ public class PackDownloader { return sb.toString(); } + // ====================== User Files Protection ====================== + // Keeps a snapshot of file hashes after each install/update. + // On next update, files modified by the user are not overwritten. + + private Path getUserFilesPath() { + return instance.getPath().resolve(USER_FILES_FILE); + } + + private void saveUserFilesSnapshot(Map fileHashes) { + try { + Path path = getUserFilesPath(); + Files.createDirectories(path.getParent()); + Files.writeString(path, gson.toJson(fileHashes)); + } catch (Exception e) { + LauncherLogger.warn("Failed to save user files snapshot: " + e.getMessage()); + } + } + + private Map loadUserFilesSnapshot() { + Path path = getUserFilesPath(); + if (!Files.exists(path)) return new HashMap<>(); + try { + String content = Files.readString(path); + Map raw = gson.fromJson(content, Map.class); + Map result = new HashMap<>(); + if (raw != null) { + for (Map.Entry e : raw.entrySet()) { + result.put(e.getKey(), e.getValue() != null ? e.getValue().toString() : ""); + } + } + return result; + } catch (Exception e) { + LauncherLogger.warn("Failed to load user files snapshot: " + e.getMessage()); + return new HashMap<>(); + } + } + + private Set getUserModifiedFiles(Map currentHashes) { + Map snapshot = loadUserFilesSnapshot(); + if (snapshot.isEmpty()) return new HashSet<>(); + + Set modified = new HashSet<>(); + for (Map.Entry e : currentHashes.entrySet()) { + String path = e.getKey(); + String currentHash = e.getValue(); + String snapshotHash = snapshot.get(path); + if (snapshotHash != null && !snapshotHash.equals(currentHash)) { + modified.add(path); + } + } + return modified; + } + // ====================== Вложенные классы ====================== public static class PackManifest { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java index e728b0e..57387ab 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java @@ -157,7 +157,8 @@ public class ForgeInstaller { "java", "-jar", installerJar.toAbsolutePath().toString(), - "--installClient" + "--installClient", + instance.getPath().toAbsolutePath().toString() ); // Add JVM args for increased timeouts diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java index 2bb8418..1657aa7 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java @@ -164,7 +164,8 @@ public class NeoForgeInstaller { "java", "-jar", installerJar.toAbsolutePath().toString(), - "--installClient" + "--installClient", + instance.getPath().toAbsolutePath().toString() ); pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java index 22590d5..6990037 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java @@ -5,15 +5,30 @@ import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; import me.sashegdev.zernmc.launcher.utils.ZAnsi; import org.json.JSONObject; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; public class LaunchCommandBuilder { + private static final List tempFiles = new ArrayList<>(); + + static { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + for (Path p : tempFiles) { + try { Files.deleteIfExists(p); } catch (Exception ignored) {} + } + })); + } + private final Instance instance; public LaunchCommandBuilder(Instance instance) { @@ -42,13 +57,68 @@ public class LaunchCommandBuilder { VersionManifest manifest = resolveVersionManifest(); - // For modloaders, always use vanilla classpath with all libraries + // For modloaders, use vanilla classpath but read main class from manifest when available if (isModloader) { - System.out.println(ZAnsi.cyan(" Modloader detected (" + loaderType + "), using vanilla classpath")); - command.add("-cp"); - command.add(buildVanillaClasspath()); - command.add(getVanillaMainClass()); - command.addAll(getVanillaGameArguments(options)); + // For Forge/NeoForge: use recursive scan of libraries/ for classpath, + // always use ModLauncher as main class (version.json might have + // BootstrapLauncher which breaks with Java module system) + if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) { + // BootstrapLauncher creates the module layer ModLauncher needs + // for ServiceLoader discovery. + // Build classpath from manifest (shorter, ordered). + // Only JARs explicitly listed in version.json go on the classpath. + // Do NOT append JARs from recursive scan — tool JARs like + // ForgeAutoRenamingTool that contain shaded ASM packages cause + // split-package conflicts with the Java module system when + // --add-modules=ALL-MODULE-PATH resolves them as automatic modules. + String classpath; + if (manifest != null) { + classpath = buildClasspathFromManifest(manifest); + } else { + classpath = ""; + } + if (classpath == null || classpath.isEmpty()) { + classpath = buildVanillaClasspath(); + } else { + // Safety net: remove JARs from classpath that are already + // on the module path (from manifest JVM args) to prevent + // Java module system split-package conflicts. + classpath = filterClasspathAgainstModulePath(classpath, manifest.getJvmArguments()); + } + command.add("-cp"); + command.add(writeClasspathFile(classpath)); + + // Add JVM arguments from version.json manifest + // Includes -DignoreList, -p (module path), --add-modules, --add-opens, + // and -DlibraryDirectory — all required for BootstrapLauncher to + // resolve cpw.mods.securejarhandler as a module. + if (manifest != null) { + Map vars = buildVariableMap(options); + for (String arg : manifest.getJvmArguments()) { + command.add(resolveVariable(arg, vars)); + } + } + + command.add("-DlibraryDirectory=" + instance.getPath().resolve("libraries").toAbsolutePath()); + command.add(getVanillaMainClass()); + command.addAll(getVanillaGameArguments(options)); + command.addAll(getModloaderLaunchArgs()); + } else { + System.out.println(ZAnsi.cyan(" Modloader detected (" + loaderType + "), using vanilla classpath")); + command.add("-cp"); + command.add(writeClasspathFile(buildVanillaClasspath())); + String mainClass = null; + if (manifest != null) { + mainClass = manifest.getMainClass(); + System.out.println(ZAnsi.cyan(" Main class from manifest: " + mainClass)); + } + if (mainClass == null || mainClass.isEmpty()) { + mainClass = getVanillaMainClass(); + System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass)); + } + command.add(mainClass); + command.addAll(getVanillaGameArguments(options)); + } } else if (manifest != null) { String classpath = buildClasspathFromManifest(manifest); @@ -56,12 +126,12 @@ public class LaunchCommandBuilder { if (classpath.isEmpty() || classpath.equals(instance.getPath().resolve("versions").resolve(getVersionId()).resolve(getVersionId() + ".jar").toAbsolutePath().toString())) { System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath")); command.add("-cp"); - command.add(buildVanillaClasspath()); + command.add(writeClasspathFile(buildVanillaClasspath())); command.add(getVanillaMainClass()); command.addAll(getVanillaGameArguments(options)); } else { command.add("-cp"); - command.add(classpath); + command.add(writeClasspathFile(classpath)); String mainClass = resolveMainClass(manifest); command.add(mainClass); @@ -70,7 +140,7 @@ public class LaunchCommandBuilder { } } else { command.add("-cp"); - command.add(buildVanillaClasspath()); + command.add(writeClasspathFile(buildVanillaClasspath())); command.add(getVanillaMainClass()); command.addAll(getVanillaGameArguments(options)); } @@ -85,7 +155,12 @@ public class LaunchCommandBuilder { String content = Files.readString(versionJson); JSONObject json = new JSONObject(content); System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName())); - return new VersionManifest(json); + VersionManifest manifest = new VersionManifest(json); + manifest.resolveParent(instance.getPath().resolve("versions")); + if (manifest.getInheritsFrom() != null) { + System.out.println(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom())); + } + return manifest; } else { System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName())); System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion())); @@ -212,13 +287,107 @@ public class LaunchCommandBuilder { if ("fabric".equals(loaderType)) { return "net.fabricmc.loader.impl.launch.knot.KnotClient"; } else if ("forge".equals(loaderType)) { - return "net.minecraftforge.client.main.ForgeClient"; + return "cpw.mods.bootstraplauncher.BootstrapLauncher"; } else if ("neoforge".equals(loaderType)) { return "cpw.mods.bootstraplauncher.BootstrapLauncher"; } return "net.minecraft.client.main.Main"; } + private List getModloaderLaunchArgs() { + List args = new ArrayList<>(); + String loaderType = instance.getLoaderType().toLowerCase(); + String mcVer = instance.getMinecraftVersion(); + String loaderVer = instance.getLoaderVersion(); + + if ("forge".equals(loaderType)) { + args.add("--launchTarget"); + args.add("forgeclient"); + if (loaderVer != null && !loaderVer.isEmpty()) { + args.add("--fml.forgeVersion"); + args.add(loaderVer); + } + if (mcVer != null && !mcVer.isEmpty()) { + args.add("--fml.mcVersion"); + args.add(mcVer); + } + args.add("--fml.forgeGroup"); + args.add("net.minecraftforge"); + // Read mcpVersion from version.json if possible, fallback to empty + String mcpVer = getMcpVersion(mcVer); + if (mcpVer != null && !mcpVer.isEmpty()) { + args.add("--fml.mcpVersion"); + args.add(mcpVer); + } + } else if ("neoforge".equals(loaderType)) { + args.add("--launchTarget"); + args.add("neoforgeclient"); + if (loaderVer != null && !loaderVer.isEmpty()) { + args.add("--fml.neoForgeVersion"); + args.add(loaderVer); + } + if (mcVer != null && !mcVer.isEmpty()) { + args.add("--fml.mcVersion"); + args.add(mcVer); + } + args.add("--fml.neoForgeGroup"); + args.add("net.neoforged"); + } + + return args; + } + + private String getMcpVersion(String mcVersion) { + if (mcVersion == null) return null; + Path versionsDir = instance.getPath().resolve("versions"); + String loaderType = instance.getLoaderType().toLowerCase(); + String loaderVer = instance.getLoaderVersion(); + // Try the same candidate paths as findVersionJson() + String[] candidates = { + getVersionId(), + mcVersion + "-" + loaderType + "-" + loaderVer, + loaderType + "-" + loaderVer, + mcVersion + "-" + loaderVer, + mcVersion + }; + for (String candidate : candidates) { + try { + Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json"); + if (Files.exists(jsonPath)) { + String content = Files.readString(jsonPath); + JSONObject json = new JSONObject(content); + if (json.has("+mcpVersion")) { + return json.getString("+mcpVersion"); + } + if (json.has("mcpVersion")) { + return json.getString("mcpVersion"); + } + } + } catch (Exception ignored) {} + } + // Fallback: known MCP versions for common MC releases + switch (mcVersion) { + case "1.21": return "20240808.143909"; + case "1.20.6": return "20240429.145301"; + case "1.20.4": return "20231214.122116"; + case "1.20.3": return "20231214.122116"; + case "1.20.2": return "20231119.165538"; + case "1.20.1": return "20231207.220116"; + case "1.20": return "20231013.142340"; + case "1.19.4": return "20230316.153408"; + case "1.19.3": return "20221207.173059"; + case "1.19.2": return "20221124.190222"; + case "1.19.1": return "20220810.141647"; + case "1.19": return "20220617.144144"; + case "1.18.2": return "20220404.170452"; + case "1.18.1": return "20220116.232031"; + case "1.18": return "20211214.190725"; + case "1.17.1": return "20210727.130541"; + case "1.17": return "20210701.125435"; + default: return null; + } + } + private List resolveGameArguments(VersionManifest manifest, LaunchOptions options) { List args = new ArrayList<>(); Map vars = buildVariableMap(options); @@ -321,19 +490,53 @@ public class LaunchCommandBuilder { List paths = new ArrayList<>(); Path librariesDir = instance.getPath().resolve("libraries"); - System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getLibraries().size() + " libraries in manifest")); + System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)")); - for (VersionManifest.Library lib : manifest.getLibraries()) { - Path libPath = librariesDir.resolve(lib.artifactPath); - if (Files.exists(libPath)) { - paths.add(libPath.toAbsolutePath().toString()); + String loaderType = instance.getLoaderType().toLowerCase(); + boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType); + + for (VersionManifest.Library lib : manifest.getAllLibraries()) { + // For Forge/NeoForge: skip Minecraft client libraries — they're loaded + // through Forge's own module system (securejarhandler + module path). + // Having them on the classpath creates automatic modules that conflict + // with Forge's own module layer (split-package ResolutionException). + if (isForgeLike && (lib.name.startsWith("net.minecraft:client") || lib.name.contains("minecraft"))) { + System.out.println(ZAnsi.cyan(" Skipping Minecraft lib: " + lib.name)); + continue; + } + Path libPath = lib.artifactPath != null ? librariesDir.resolve(lib.artifactPath) : null; + if (libPath != null && Files.exists(libPath)) { + if (isValidJar(libPath)) { + paths.add(libPath.toAbsolutePath().toString()); + } else { + System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name)); + Files.delete(libPath); + } } else { String mavenPath = mavenToPath(lib.name); Path fallbackPath = librariesDir.resolve(mavenPath); if (Files.exists(fallbackPath)) { - paths.add(fallbackPath.toAbsolutePath().toString()); + if (isValidJar(fallbackPath)) { + paths.add(fallbackPath.toAbsolutePath().toString()); + } else { + System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name)); + Files.delete(fallbackPath); + } } else { - System.out.println(ZAnsi.yellow(" Library not found: " + lib.name)); + // Last resort: scan libraries dir for a JAR matching this artifact + String artifactName = lib.name.split(":")[1]; + Path found = scanForJar(librariesDir, artifactName); + if (found != null) { + if (isValidJar(found)) { + System.out.println(ZAnsi.green(" Found by scan: " + lib.name + " → " + librariesDir.relativize(found))); + paths.add(found.toAbsolutePath().toString()); + } else { + System.out.println(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName())); + Files.delete(found); + } + } else { + System.out.println(ZAnsi.yellow(" Library not found (even after scan): " + lib.name)); + } } } } @@ -342,14 +545,34 @@ public class LaunchCommandBuilder { Path versionJar = findVersionJar(); if (versionJar != null) { - paths.add(0, versionJar.toAbsolutePath().toString()); - System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName())); + if (isValidJar(versionJar)) { + paths.add(0, versionJar.toAbsolutePath().toString()); + System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName())); + } else { + System.out.println(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName())); + Files.delete(versionJar); + } } String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; return String.join(separator, paths); } + private Path scanForJar(Path dir, String artifactName) throws IOException { + if (!Files.exists(dir)) return null; + try (var stream = Files.walk(dir)) { + return stream + .filter(p -> p.toString().endsWith(".jar")) + .filter(p -> { + String name = p.getFileName().toString(); + // Match JARs like "artifactName-version.jar" but not "artifactName-other-version.jar" + return name.startsWith(artifactName + "-") || name.equals(artifactName + ".jar"); + }) + .findFirst() + .orElse(null); + } + } + private String buildVanillaClasspath() throws Exception { List paths = new ArrayList<>(); String versionId = getVersionId(); @@ -491,4 +714,69 @@ public class LaunchCommandBuilder { return jvmArgs; } + + private boolean isValidJar(Path path) { + if (path == null || !Files.exists(path)) return false; + try (ZipFile zf = new ZipFile(path.toFile())) { + return true; + } catch (ZipException e) { + return false; + } catch (IOException e) { + return false; + } + } + + /** + * Removes classpath entries that are already listed on the module path + * (from -p or --module-path in manifest JVM arguments). Prevents split-package + * conflicts when --add-modules=ALL-MODULE-PATH resolves classpath JARs + * as automatic modules. + */ + private String filterClasspathAgainstModulePath(String classpath, List manifestJvmArgs) { + if (classpath == null || classpath.isEmpty() || manifestJvmArgs == null || manifestJvmArgs.isEmpty()) { + return classpath; + } + + Set modulePathFilenames = new HashSet<>(); + + // Parse -p / --module-path values from manifest JVM arguments + for (int i = 0; i < manifestJvmArgs.size(); i++) { + String arg = manifestJvmArgs.get(i); + if (("-p".equals(arg) || "--module-path".equals(arg)) && i + 1 < manifestJvmArgs.size()) { + String modulePathValue = manifestJvmArgs.get(i + 1); + String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; + for (String entry : modulePathValue.split(sep)) { + int lastSep = Math.max(entry.lastIndexOf('/'), entry.lastIndexOf('\\')); + modulePathFilenames.add(lastSep >= 0 ? entry.substring(lastSep + 1) : entry); + } + } + } + + if (modulePathFilenames.isEmpty()) return classpath; + + String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; + StringBuilder filtered = new StringBuilder(); + for (String entry : classpath.split(separator)) { + if (entry.isEmpty()) continue; + int lastSep = Math.max(entry.lastIndexOf('/'), entry.lastIndexOf('\\')); + String filename = lastSep >= 0 ? entry.substring(lastSep + 1) : entry; + if (!modulePathFilenames.contains(filename)) { + if (filtered.length() > 0) filtered.append(separator); + filtered.append(entry); + } + } + + String result = filtered.toString(); + return result.isEmpty() ? classpath : result; + } + + // For very long classpaths (Windows cmd truncation), write to a temp argfile + private String writeClasspathFile(String classpath) throws IOException { + if (classpath.length() < 8000) return classpath; + Path tempFile = Files.createTempFile("zernmc-cp-", ".txt"); + Files.writeString(tempFile, classpath); + tempFiles.add(tempFile); + System.out.println(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile)); + return "@" + tempFile.toAbsolutePath(); + } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java index 74debf6..74acc4f 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java @@ -15,13 +15,15 @@ public class VersionManifest { private final String id; private final String mainClass; private final String assetIndexId; + private final String inheritsFrom; private final List jvmArguments; private final List gameArguments; private final List libraries; + private VersionManifest parent; public VersionManifest(JSONObject json) { this.id = json.getString("id"); - this.mainClass = json.getString("mainClass"); + this.mainClass = json.optString("mainClass", "net.minecraft.client.main.Main"); if (json.has("assetIndex")) { JSONObject ai = json.getJSONObject("assetIndex"); @@ -30,6 +32,7 @@ public class VersionManifest { this.assetIndexId = "unknown"; } + this.inheritsFrom = json.optString("inheritsFrom", null); this.jvmArguments = parseArguments(json, "jvm"); this.gameArguments = parseArguments(json, "game"); this.libraries = parseLibraries(json); @@ -38,10 +41,47 @@ public class VersionManifest { public String getId() { return id; } public String getMainClass() { return mainClass; } public String getAssetIndexId() { return assetIndexId; } + public String getInheritsFrom() { return inheritsFrom; } public List getJvmArguments() { return jvmArguments; } public List getGameArguments() { return gameArguments; } public List getLibraries() { return libraries; } + public void resolveParent(Path versionsDir) { + if (inheritsFrom == null || parent != null) return; + Path parentDir = versionsDir.resolve(inheritsFrom); + Path parentJson = parentDir.resolve(inheritsFrom + ".json"); + if (Files.exists(parentJson)) { + try { + String content = Files.readString(parentJson); + JSONObject json = new JSONObject(content); + this.parent = new VersionManifest(json); + this.parent.resolveParent(versionsDir); + } catch (Exception e) { + System.out.println("Failed to load parent version " + inheritsFrom + ": " + e.getMessage()); + } + } + } + + public List getAllLibraries() { + List all = new ArrayList<>(); + if (parent != null) { + all.addAll(parent.getAllLibraries()); + } + for (Library lib : libraries) { + if (!containsLibrary(all, lib.name)) { + all.add(lib); + } + } + return all; + } + + private boolean containsLibrary(List list, String name) { + for (Library l : list) { + if (l.name.equals(name)) return true; + } + return false; + } + private List parseArguments(JSONObject json, String type) { List args = new ArrayList<>(); if (!json.has("arguments")) return args; @@ -129,29 +169,30 @@ public class VersionManifest { JSONArray arr = json.getJSONArray("libraries"); for (int i = 0; i < arr.length(); i++) { JSONObject libJson = arr.getJSONObject(i); + String name = libJson.getString("name"); + String artifactPath = null; if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) { - String name = libJson.getString("name"); - String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path"); - Library lib = new Library(name, artifactPath); + artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path"); + } + Library lib = new Library(name, artifactPath); - if (libJson.has("natives")) { - JSONObject natives = libJson.getJSONObject("natives"); - for (String key : natives.keySet()) { - String osKey = key.toLowerCase(); - lib.natives.put(osKey, natives.getString(key)); - } + if (libJson.has("natives")) { + JSONObject natives = libJson.getJSONObject("natives"); + for (String key : natives.keySet()) { + String osKey = key.toLowerCase(); + lib.natives.put(osKey, natives.getString(key)); } + } - if (libJson.has("rules")) { - JSONObject dummyObj = new JSONObject(); - dummyObj.put("rules", libJson.getJSONArray("rules")); - dummyObj.put("value", ""); - if (ruleMatches(dummyObj)) { - libs.add(lib); - } - } else { - libs.add(lib); - } + boolean allowed = true; + if (libJson.has("rules")) { + JSONObject dummyObj = new JSONObject(); + dummyObj.put("rules", libJson.getJSONArray("rules")); + dummyObj.put("value", ""); + allowed = ruleMatches(dummyObj); + } + if (allowed) { + libs.add(lib); } } return libs; diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index f71a9d8..00e9045 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -713,8 +713,9 @@ public class JFXLauncher extends Application { String version = body.get("version"); String loader = body.get("loader"); String loaderVersion = body.get("loaderVersion"); + String preset = body.get("preset"); - log("Install: " + name + " " + version + " " + loader + (loaderVersion != null ? " " + loaderVersion : "")); + log("Install: " + name + " " + version + " " + loader + (loaderVersion != null ? " " + loaderVersion : "") + (preset != null ? " preset=" + preset : "")); var createResult = api.instances().createInstance(name); if (!createResult.isSuccess()) { @@ -742,24 +743,35 @@ public class JFXLauncher extends Application { boolean success = false; if ("zernmc".equalsIgnoreCase(loader)) { + log("[DEBUG] Starting zernmc pack install for version=" + version); 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); }); + log("[DEBUG] Getting available packs..."); var packs = downloader.getAvailablePacks(); + log("[DEBUG] Available packs: " + (packs != null ? packs.size() : "null")); ServerPack pack = null; for (ServerPack p : packs) { + log("[DEBUG] Checking pack: " + p.getName() + " vs " + version); if (p.getName().equals(version)) { pack = p; break; } } + log("[DEBUG] Selected pack: " + (pack != null ? pack.getName() : "null")); if (pack != null) { try { - success = downloader.installOrUpdatePack(version, pack); + log("[DEBUG] Calling installOrUpdatePack..." + (preset != null ? " preset=" + preset : "")); + if (preset != null && !preset.isEmpty()) { + instance.setPresetId(preset); + } + success = downloader.installOrUpdatePack(version, pack, preset); + log("[DEBUG] installOrUpdatePack returned: " + success); } catch (Exception e) { log("Pack install error: " + e.getMessage()); + e.printStackTrace(); throw e; } if (success) { @@ -796,7 +808,6 @@ public class JFXLauncher extends Application { setInstallProgressWithStage("Error: " + e.getMessage(), 0, 100, "Error", 0, 1); } }); - installThread.setDaemon(true); installThread.start(); } else { sendJson(exchange, Map.of("success", false, "error", "Instance not found")); @@ -1144,6 +1155,7 @@ public class JFXLauncher extends Application { data.put("systemBasedJvm", Config.isSystemBasedJvm()); data.put("cpuCores", Config.getSystemCpuCores()); data.put("totalRamMB", Config.getSystemTotalRamMB()); + data.put("systemJvmFlags", Config.getSystemJvmFlags()); sendJson(exchange, Map.of("success", true, "data", data)); } catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); @@ -1156,7 +1168,10 @@ public class JFXLauncher extends Application { for (String pair : query.split("&")) { String[] kv = pair.split("="); if (kv.length == 2) { - params.put(kv[0], kv[1]); + try { + params.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), + URLDecoder.decode(kv[1], StandardCharsets.UTF_8)); + } catch (Exception ignored) {} } } } @@ -1408,12 +1423,11 @@ public class JFXLauncher extends Application { private void handleCheckUpdates(HttpExchange exchange) { try { Map params = parseQuery(exchange.getRequestURI().getQuery()); - String rawName = params.get("name"); - if (rawName == null || rawName.isBlank()) { + String name = params.get("name"); + if (name == null || name.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")); @@ -1425,7 +1439,12 @@ public class JFXLauncher extends Application { } PackDownloader downloader = new PackDownloader(instance); String packName = instance.getServerPackName(); - int serverVersion = downloader.getPackManifest(packName).getVersion(); + var manifest = downloader.getPackManifest(packName); + if (manifest == null) { + sendJson(exchange, Map.of("success", false, "error", "Failed to fetch pack manifest")); + return; + } + int serverVersion = manifest.getVersion(); int localVersion = instance.getServerVersion(); sendJson(exchange, Map.of( "success", true, @@ -1458,7 +1477,14 @@ public class JFXLauncher extends Application { return; } PackDownloader downloader = new PackDownloader(instance); - boolean success = downloader.updatePack(instance.getServerPackName()); + String packName = instance.getServerPackName(); + String preset = instance.getPresetId(); + boolean success; + if (preset != null && !preset.isEmpty()) { + success = downloader.updatePack(packName, preset); + } else { + success = downloader.updatePack(packName); + } if (success) { sendJson(exchange, Map.of("success", true, "data", Map.of("version", instance.getServerVersion()))); } else { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java index 33efce8..532a968 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java @@ -10,8 +10,6 @@ public class Config { private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc"); private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties"); - private static final String BUILD_PROFILE = System.getProperty("build.profile", "global"); - private static final Properties props = new Properties(); private static volatile int maxMemory = 4096; @@ -133,14 +131,6 @@ public class Config { return maxMemory; } - public static boolean isZernMCBuild() { - return "zernmc".equalsIgnoreCase(BUILD_PROFILE); - } - - public static boolean isGlobalBuild() { - return !isZernMCBuild(); - } - public static void setMaxMemory(int memory) { if (memory < 1024) memory = 1536; if (memory > 32768) memory = 32768; diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java index 960a26b..2c5bf08 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java @@ -22,13 +22,13 @@ public class LauncherLogger { initialized = true; try { - Path logsDir = Paths.get(System.getProperty("user.home"), ".zernmc", "logs"); + Path logsDir = Paths.get("").toAbsolutePath().resolve("logs"); Files.createDirectories(logsDir); logFile = logsDir.resolve("launcher.log"); Files.writeString(logFile, "=== Launcher Log " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " ===\n", - StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + StandardOpenOption.CREATE, StandardOpenOption.APPEND); System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath()); } catch (Exception e) { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java index bfa2b19..3b26cc0 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -332,12 +332,20 @@ public class ZHttpClient { public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { if (!shouldUseProxyForUrl(url)) { try { - HttpRequest request = HttpRequest.newBuilder() + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(40)) .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); + .GET(); + + if (url.startsWith(BASE_URL)) { + String accessToken = AuthManager.getAccessToken(); + if (accessToken != null && !accessToken.equals("0")) { + requestBuilder.header("Authorization", "Bearer " + accessToken); + } + } + + HttpRequest request = requestBuilder.build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html index ad2d98b..bce8137 100644 --- a/launcher/launcher/src/resources/ui/index.html +++ b/launcher/launcher/src/resources/ui/index.html @@ -103,6 +103,9 @@ @@ -198,6 +201,13 @@ +
Select a pack @@ -253,6 +263,7 @@ +
@@ -315,6 +326,13 @@
Loading...
+ +
+
+
Pack Management
+
Loading...
+
+
@@ -506,6 +524,12 @@
+
diff --git a/launcher/launcher/src/resources/ui/launcher.js b/launcher/launcher/src/resources/ui/launcher.js index 3c92dfb..999d188 100644 --- a/launcher/launcher/src/resources/ui/launcher.js +++ b/launcher/launcher/src/resources/ui/launcher.js @@ -163,9 +163,19 @@ const LOCALES = { 'admin.newsCreated': 'News published!', 'admin.newsDeleted': 'News deleted', 'admin.newsEmpty': 'No news', + 'admin.packs': 'Packs', + 'admin.packsList': 'Pack Management', + 'admin.packsEmpty': 'No packs', + 'admin.packsToggle': 'Toggle', + 'admin.packsDisabled': 'Disabled', + 'admin.packsEnabled': 'Enabled', + 'admin.packsStatus': 'Status', 'admin.delete': 'Delete', 'admin.loading': 'Loading...', 'admin.error': 'Error', + 'install.preset.label': 'Preset (optional)', + 'install.preset.default': 'Default', + 'pack.disabled': 'Disabled', }, ru: { 'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки', @@ -329,9 +339,19 @@ const LOCALES = { 'admin.newsCreated': 'Новость опубликована!', 'admin.newsDeleted': 'Новость удалена', 'admin.newsEmpty': 'Нет новостей', + 'admin.packs': 'Сборки', + 'admin.packsList': 'Управление сборками', + 'admin.packsEmpty': 'Нет сборок', + 'admin.packsToggle': 'Переключить', + 'admin.packsDisabled': 'Отключена', + 'admin.packsEnabled': 'Включена', + 'admin.packsStatus': 'Статус', 'admin.delete': 'Удалить', 'admin.loading': 'Загрузка...', 'admin.error': 'Ошибка', + 'install.preset.label': 'Пресет (опционально)', + 'install.preset.default': 'Стандартный', + 'pack.disabled': 'Отключена', } }; @@ -570,6 +590,10 @@ class ZernMCLauncher { const r = await this.req('/packs'); if (r.success && r.data) { this.state.serverPacks = r.data; + this.state.serverPacksMap = {}; + r.data.forEach(function(p) { + app.state.serverPacksMap[p.name] = p; + }); } } @@ -584,6 +608,7 @@ class ZernMCLauncher { }); document.getElementById('add-pack-btn').addEventListener('click', () => this.showInstallModal()); + document.getElementById('add-server-pack-btn').addEventListener('click', () => this.showInstallModal()); document.getElementById('close-modal-btn').addEventListener('click', () => this.hideInstallModal()); document.querySelectorAll('.modal-tab').forEach(t => { t.addEventListener('click', () => this.switchInstallTab(t.dataset.tab)); @@ -673,6 +698,10 @@ class ZernMCLauncher { if (!this.state.instances) return; for (const inst of this.state.instances) { if (!inst.isServerPack) continue; + // Skip disabled packs + if (this.state.serverPacksMap && this.state.serverPacksMap[inst.name] && this.state.serverPacksMap[inst.name].disabled) { + 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; @@ -682,6 +711,10 @@ class ZernMCLauncher { async checkUpdateForPack(inst) { if (!inst || !inst.isServerPack) return; + // Skip disabled packs + if (this.state.serverPacksMap && this.state.serverPacksMap[inst.name] && this.state.serverPacksMap[inst.name].disabled) { + return; + } const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name)); if (r.success && r.data) { if (!this.state.updateMap) this.state.updateMap = {}; @@ -706,9 +739,11 @@ class ZernMCLauncher { this.state.instances.forEach(inst => { const isZern = inst.isServerPack || inst.category === 'zernmc'; const targetList = isZern ? serverList : localList; + const isDisabled = this.state.serverPacksMap && this.state.serverPacksMap[inst.name] && this.state.serverPacksMap[inst.name].disabled; const el = document.createElement('div'); - el.className = 'pack-entry' + (this.state.selectedPack && this.state.selectedPack.name === inst.name ? ' selected' : ''); + el.className = 'pack-entry' + (this.state.selectedPack && this.state.selectedPack.name === inst.name ? ' selected' : '') + (isDisabled ? ' disabled' : ''); + if (isDisabled) el.setAttribute('data-disabled-text', t('pack.disabled')); const hasUpd = this.hasUpdate(inst); el.innerHTML = `
@@ -716,14 +751,16 @@ class ZernMCLauncher { ? '' : '' } - ${hasUpd ? '' : ''} + ${hasUpd && !isDisabled ? '' : ''}
`; - el.addEventListener('click', () => this.selectPack(inst)); + if (!isDisabled) { + el.addEventListener('click', () => this.selectPack(inst)); + } targetList.appendChild(el); }); @@ -965,6 +1002,7 @@ class ZernMCLauncher { if (tab === 'users') this.adminClearUser(); if (tab === 'passes') this.adminLoadPasses(); if (tab === 'news') this.adminLoadNewsList(); + if (tab === 'packs') this.adminLoadPacks(); } // --- Clients --- @@ -1229,6 +1267,48 @@ class ZernMCLauncher { } } + // --- Packs --- + async adminLoadPacks() { + var el = document.getElementById('admin-packs-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/packs'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var packs = r.packs || []; + if (!packs.length) { + el.innerHTML = '
' + t('admin.packsEmpty') + '
'; + return; + } + var html = ''; + packs.forEach(function(p) { + var statusText = p.disabled ? t('admin.packsDisabled') : t('admin.packsEnabled'); + var statusClass = p.disabled ? 'admin-pack-disabled' : 'admin-pack-enabled'; + html += '
' + + '' + app.esc(p.name) + '' + + '' + statusText + '' + + '
' + + '' + + '
'; + }); + el.innerHTML = html; + } + + async adminTogglePack(packName) { + var r = await this.req('/admin/packs/' + encodeURIComponent(packName) + '/toggle', { + method: 'POST', + body: '{}' + }); + if (r.success) { + this.toast((r.disabled ? t('admin.packsDisabled') : t('admin.packsEnabled')) + ': ' + packName, 'success'); + this.adminLoadPacks(); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + // ==================== NEWS ==================== async loadNews() { const r = await this.req('/news'); @@ -1369,10 +1449,13 @@ class ZernMCLauncher { async showInstallModal() { document.getElementById('install-modal').classList.remove('hidden'); const zernmcSel = document.getElementById('zernmc-pack-select'); + const presetContainer = document.getElementById('preset-select-container'); + const presetSel = document.getElementById('preset-select'); const mcSel = document.getElementById('mc-version-select'); if (zernmcSel) { zernmcSel.innerHTML = ''; } + if (presetContainer) presetContainer.classList.add('hidden'); if (mcSel) { mcSel.innerHTML = ''; } @@ -1384,11 +1467,35 @@ class ZernMCLauncher { zernmcSel.innerHTML = ''; zernmcSel.disabled = false; packs.data.forEach(p => { + if (p.disabled) return; // skip disabled packs const o = document.createElement('option'); o.value = p.name; + o.dataset.presets = JSON.stringify(p.presets || []); o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')'; zernmcSel.appendChild(o); }); + // Update preset selector when pack changes + zernmcSel.onchange = function() { + const selected = zernmcSel.options[zernmcSel.selectedIndex]; + const presets = selected && selected.dataset.presets ? JSON.parse(selected.dataset.presets) : []; + if (presetSel) { + presetSel.innerHTML = ''; + presets.forEach(function(pr) { + const o = document.createElement('option'); + o.value = pr; + o.textContent = pr; + presetSel.appendChild(o); + }); + } + if (presetContainer) { + if (presets.length > 0) { + presetContainer.classList.remove('hidden'); + } else { + presetContainer.classList.add('hidden'); + } + } + app.enhanceSelects(); + }; } else { zernmcSel.innerHTML = ''; zernmcSel.disabled = true; @@ -1436,7 +1543,9 @@ class ZernMCLauncher { hideInstallModal() { document.getElementById('install-modal').classList.add('hidden'); document.getElementById('install-progress').classList.add('hidden'); - this.stopProgressPoll(); + if (this.progressPoller) { + document.getElementById('inline-install-progress').classList.remove('hidden'); + } } switchInstallTab(tab) { @@ -1584,12 +1693,16 @@ class ZernMCLauncher { async installZernMCPack() { const packName = document.getElementById('zernmc-pack-select').value; if (!packName) { this.toast(t('select.selectPack'), 'error'); return; } + const preset = document.getElementById('preset-select') ? document.getElementById('preset-select').value : ''; // Use server pack name as instance name (no local naming) const instanceName = packName; + const body = { name: instanceName, version: packName, loader: 'zernmc' }; + if (preset) body.preset = preset; + const r = await this.req('/install', { method: 'POST', - body: JSON.stringify({ name: instanceName, version: packName, loader: 'zernmc' }) + body: JSON.stringify(body) }); if (r.success) { this.toast(t('toast.installing'), 'info'); @@ -1624,23 +1737,43 @@ class ZernMCLauncher { this.progressPoller = setInterval(async () => { const r = await this.req('/install/progress'); if (r.success && r.data) { - const p = document.getElementById('install-progress'); - p.classList.remove('hidden'); - document.getElementById('progress-fill').style.width = (r.data.percent || 0) + '%'; - document.getElementById('progress-label').textContent = r.data.label || 'Installing...'; - const stageInfo = document.getElementById('progress-stage'); + const modalP = document.getElementById('install-progress'); + modalP.classList.remove('hidden'); + const pct = (r.data.percent || 0) + '%'; + const label = r.data.label || 'Installing...'; + + document.getElementById('progress-fill').style.width = pct; + document.getElementById('progress-label').textContent = label; + + const inlineProgress = document.getElementById('inline-install-progress'); + if (!inlineProgress.classList.contains('hidden')) { + document.getElementById('inline-progress-fill').style.width = pct; + document.getElementById('inline-progress-label').textContent = label; + const inlineStage = document.getElementById('inline-progress-stage'); + if (r.data.stageName && r.data.stageCount > 1) { + inlineStage.textContent = tr('install.step', null, {current: String(r.data.stageIndex || 0), total: String(r.data.stageCount), name: r.data.stageName}); + inlineStage.classList.remove('hidden'); + } else { + inlineStage.classList.add('hidden'); + } + } + + const modalStage = document.getElementById('progress-stage'); if (r.data.stageName && r.data.stageCount > 1) { - stageInfo.textContent = tr('install.step', null, {current: String(r.data.stageIndex || 0), total: String(r.data.stageCount), name: r.data.stageName}); - stageInfo.classList.remove('hidden'); + modalStage.textContent = tr('install.step', null, {current: String(r.data.stageIndex || 0), total: String(r.data.stageCount), name: r.data.stageName}); + modalStage.classList.remove('hidden'); } else { - stageInfo.classList.add('hidden'); + modalStage.classList.add('hidden'); } if (!r.data.inProgress) { this.stopProgressPoll(); this.hideInstallModal(); + inlineProgress.classList.add('hidden'); var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && r.data.label.startsWith('Error')); this.toast(failed ? (r.data.label || t('toast.installFailed')) : t('toast.installComplete'), failed ? 'error' : 'success'); - if (!failed) await this.loadInstances(); + if (!failed) { + await this.loadInstances(); + } } } }, 500); diff --git a/launcher/launcher/src/resources/ui/style.css b/launcher/launcher/src/resources/ui/style.css index 5330733..ce25e7b 100644 --- a/launcher/launcher/src/resources/ui/style.css +++ b/launcher/launcher/src/resources/ui/style.css @@ -620,6 +620,7 @@ body { .select-wrap select:focus { border-color: var(--accent); } .install-progress { padding-top: 16px; border-top: 1px solid var(--border); } +.inline-install-progress { padding: 16px 20px; border-top: 1px solid var(--border); background: var(--bg-surface); } .progress-track { height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden; } @@ -881,3 +882,8 @@ body { .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; } + +.pack-entry.disabled { opacity: 0.5; pointer-events: none; } +.pack-entry.disabled .pack-entry-name::after { content: ' (' attr(data-disabled-text) ')'; color: var(--error); font-size: 11px; } +.admin-pack-disabled { color: var(--error) !important; font-weight: 600; } +.admin-pack-enabled { color: var(--success) !important; font-weight: 600; } diff --git a/launcher/pom.xml b/launcher/pom.xml index ea9f5d6..8b9c40d 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -72,31 +72,31 @@ org.openjfx javafx-controls - 21 + 23 win org.openjfx javafx-web - 21 + 23 win org.openjfx javafx-graphics - 21 + 23 win org.openjfx javafx-base - 21 + 23 win org.openjfx javafx-media - 21 + 23 win @@ -142,27 +142,14 @@ - global true - global ZernMC Launcher http://87.120.187.36:1582 - - - - - - - zernmc - - zernmc - ZernMC Private Launcher - http://87.120.187.36:1582 diff --git a/server/admin_router.py b/server/admin_router.py index 12ee682..409a825 100644 --- a/server/admin_router.py +++ b/server/admin_router.py @@ -9,6 +9,8 @@ import hashlib from datetime import datetime from pathlib import Path +from pack_manager import PACKS_DIR, load_packs_config, get_pack_presets, toggle_pack_disabled + 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, @@ -877,6 +879,58 @@ async def admin_delete_news( return {"success": True, "id": target.stem} +# ====================== PACK MANAGEMENT (ELDER+) ====================== + +@router.get("/packs") +async def admin_list_packs( + current_user: dict = Depends(require_role(ROLE_ELDER)), +): + """List all packs with disabled status""" + packs = [] + packs_config = load_packs_config() + + packs_dir = Path("packs") + if packs_dir.exists(): + for pack_dir in packs_dir.iterdir(): + if pack_dir.is_dir(): + entry = packs_config.get(pack_dir.name, {}) + packs.append({ + "name": pack_dir.name, + "disabled": entry.get("disabled", False), + "presets": get_pack_presets(pack_dir.name) + }) + + return {"packs": packs} + + +@router.post("/packs/{pack_name}/toggle") +async def admin_toggle_pack( + pack_name: str, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Toggle pack disabled/enabled status""" + pack_path = PACKS_DIR / pack_name + if not pack_path.exists() or not pack_path.is_dir(): + raise HTTPException(404, "Pack not found") + + new_state = toggle_pack_disabled(pack_name) + + ip = request.client.host if request.client else "unknown" + log_audit( + current_user["id"], + "pack_toggle", + f"{'Disabled' if new_state else 'Enabled'} pack {pack_name}", + ip + ) + + return { + "success": True, + "pack_name": pack_name, + "disabled": new_state + } + + @router.get("/me") async def get_my_info(current_user: dict = Depends(get_current_user)): """Информация о текущем пользователе с правами""" diff --git a/server/main.py b/server/main.py index b486317..e0d64bb 100644 --- a/server/main.py +++ b/server/main.py @@ -19,7 +19,7 @@ from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) -from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR +from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR, is_pack_disabled, get_pack_presets, get_merged_manifest from models import PackMeta from middleware import LoggingMiddleware from cli import parse_args, run_test_mode, run_production_mode, run_development_mode, run_sync_mode @@ -1026,7 +1026,9 @@ async def list_packs( "loader_type": meta.get("loader_type", "vanilla"), "loader_version": meta.get("loader_version"), "asset_index": meta.get("asset_index"), - "description": description + "description": description, + "disabled": is_pack_disabled(pack_dir.name), + "presets": get_pack_presets(pack_dir.name) }) except Exception as e: logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}") @@ -1047,7 +1049,8 @@ async def list_packs( async def get_pack_diff( pack_name: str, request: Request, - current_user: dict = Depends(get_current_user) # Добавляем зависимость + current_user: dict = Depends(get_current_user), + preset: Optional[str] = None ): """Client sends: { "mods/jei.jar": "sha256_hash", ... } Server returns diff information @@ -1060,6 +1063,10 @@ async def get_pack_diff( detail="Для скачивания сборок требуется активная проходка. Обратитесь к администратору." ) + # Check if pack is disabled + if is_pack_disabled(pack_name): + raise HTTPException(403, "Pack is disabled by administrator") + client_ip = request.client.host if request.client else "unknown" # Читаем тело запроса @@ -1071,13 +1078,17 @@ async def get_pack_diff( logger.info("Received diff request", pack=pack_name, + preset=preset, client_files_count=len(body), client_ip=client_ip) try: - meta = get_cached_manifest(pack_name) - if not meta: - meta = await scan_pack(pack_name) + if preset: + meta = get_merged_manifest(pack_name, preset) + else: + meta = get_cached_manifest(pack_name) + if not meta: + meta = await scan_pack(pack_name) except FileNotFoundError: logger.warning("Pack not found", pack=pack_name, client_ip=client_ip) raise HTTPException(404, "Pack not found") @@ -1095,6 +1106,8 @@ async def get_pack_diff( client_hash = body.get(path) if client_hash is None or client_hash != entry.hash: url = f"/pack/{pack_name}/file/{path}" + if preset: + url += f"?preset={preset}" to_download.append({ "path": path, "url": url, @@ -1127,6 +1140,9 @@ async def get_pack_diff( @app.get("/pack/{pack_name}") async def get_pack_manifest(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)): """Get pack manifest with caching""" + if is_pack_disabled(pack_name): + raise HTTPException(403, "Pack is disabled by administrator") + client_ip = request.client.host if request.client else "unknown" cached_meta = get_cached_manifest(pack_name) @@ -1166,13 +1182,34 @@ async def get_pack_manifest(pack_name: str, request: Request, current_user: dict @app.get("/pack/{pack_name}/file/{file_path:path}") -async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user)): - """Get a file from a pack""" +async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user), preset: Optional[str] = None): + """Get a file from a pack. If preset is specified, try preset dir first, fall back to base.""" if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK): raise HTTPException(403, "Requires active pass") - full_path = PACKS_DIR / pack_name / file_path + if is_pack_disabled(pack_name): + raise HTTPException(403, "Pack is disabled by administrator") + client_ip = request.client.host if request.client else None + # If preset specified, try preset path first + if preset: + preset_path = PACKS_DIR / pack_name / "presets" / preset / file_path + preset_resolved = preset_path.resolve() + preset_root = (PACKS_DIR / pack_name).resolve() + try: + if str(preset_resolved).startswith(str(preset_root)) and preset_resolved.is_file(): + full_path = preset_resolved + else: + full_path = None + except (ValueError, OSError): + full_path = None + else: + full_path = None + + # Fall back to base path + if full_path is None: + full_path = PACKS_DIR / pack_name / file_path + # Security: prevent path traversal try: full_path = full_path.resolve() @@ -2245,7 +2282,9 @@ async def proxy_download(request: Request): "resources.download.minecraft.net", "maven.minecraftforge.net", "files.minecraftforge.net", - "maven.neoforged.net" + "maven.neoforged.net", + "api.zernmc.ru", + "api.zernmc.online" ] # Проверяем, что URL ведет на разрешенный домен diff --git a/server/middleware.py b/server/middleware.py index ac809a0..63d6c2b 100644 --- a/server/middleware.py +++ b/server/middleware.py @@ -154,24 +154,27 @@ class LoggingMiddleware(BaseHTTPMiddleware): _stats["blocked"] += 1 return Response(status_code=404, content="") - # Check rate limit - if not check_rate_limit(client_ip): - _stats["rate_limited"] += 1 - # Periodic stats logging instead of every warning - if time.time() - _stats_last_log > STATS_LOG_INTERVAL: - logger.warning(f"Stats: {_stats}") - _stats_last_log = time.time() - return Response(status_code=429, content="Too many requests") + path = request.url.path + + # Skip rate limiting for pack file downloads (direct high-speed serving) + if path.startswith("/pack/") and "/file/" in path: + is_file_download = True + else: + is_file_download = False + + # Check rate limit + if not check_rate_limit(client_ip): + _stats["rate_limited"] += 1 + if time.time() - _stats_last_log > STATS_LOG_INTERVAL: + logger.warning(f"Stats: {_stats}") + _stats_last_log = time.time() + return Response(status_code=429, content="Too many requests") # Check suspicious path (silent 404 for bots) - path = request.url.path if is_suspicious_path(path): # Return 404 without logging - confuse the bots return Response(status_code=404, content="") - # Skip logging for large file downloads (don't spam logs) - is_file_download = path.startswith("/pack/") and "/file/" in path - # Track total requests for stats _stats["total"] += 1 diff --git a/server/pack_manager.py b/server/pack_manager.py index 8002803..66a65ba 100644 --- a/server/pack_manager.py +++ b/server/pack_manager.py @@ -1,9 +1,10 @@ import hashlib import os +import zipfile from datetime import datetime from pathlib import Path import json -from typing import Optional, Dict +from typing import Optional, Dict, List import structlog import asyncio import aiofiles @@ -19,6 +20,46 @@ DATA_DIR.mkdir(exist_ok=True) # Cache for loaded manifests _manifest_cache: Dict[str, PackMeta] = {} +# Pack config cache +_packs_config: Optional[Dict] = None + +def load_packs_config() -> dict: + global _packs_config + if _packs_config is not None: + return _packs_config + config_path = DATA_DIR / "packs_config.json" + if config_path.exists(): + try: + _packs_config = json.loads(config_path.read_text()) + except Exception: + _packs_config = {} + else: + _packs_config = {} + return _packs_config + +def save_packs_config(config: dict): + global _packs_config + _packs_config = config + config_path = DATA_DIR / "packs_config.json" + config_path.write_text(json.dumps(config, indent=2)) + +def is_pack_disabled(pack_name: str) -> bool: + config = load_packs_config() + return config.get(pack_name, {}).get("disabled", False) + +def toggle_pack_disabled(pack_name: str) -> bool: + config = load_packs_config() + entry = config.setdefault(pack_name, {}) + entry["disabled"] = not entry.get("disabled", False) + save_packs_config(config) + return entry["disabled"] + +def get_pack_presets(pack_name: str) -> List[str]: + presets_dir = PACKS_DIR / pack_name / "presets" + if not presets_dir.exists() or not presets_dir.is_dir(): + return [] + return sorted([d.name for d in presets_dir.iterdir() if d.is_dir()]) + def get_cached_manifest(pack_name: str) -> Optional[PackMeta]: """Get manifest from memory cache if available""" return _manifest_cache.get(pack_name) @@ -66,10 +107,11 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: new_files: Dict[str, FileEntry] = {} changed = False - # Get ignored directories - ignored_dirs = current_meta.ignored_dirs if current_meta else [ + # Get ignored directories (always use current list, not cached from meta) + ignored_dirs = [ "resourcepacks", "shaderpacks", "saves", "logs", - "crash-reports", "screenshots", "journeymap", "config" + "crash-reports", "screenshots", "journeymap", + "presets" ] # Walk through pack directory @@ -81,10 +123,19 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: file_path = Path(root) / file rel_path = file_path.relative_to(pack_path).as_posix() + # Skip metadata files that should not be part of the pack + if file in ("description.txt", "instance.json"): + continue + # Skip if in ignored directory if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs): continue + # Validate .jar files — skip corrupted ones + if file.endswith(".jar") and not _is_valid_zip(file_path): + logger.warning(f"Skipping corrupt jar in pack {pack_name}: {rel_path}") + continue + stat = file_path.stat() file_hash = await calculate_sha256(file_path) @@ -153,4 +204,53 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: return current_meta # Should not happen - raise Exception(f"Failed to scan pack {pack_name}") \ No newline at end of file + raise Exception(f"Failed to scan pack {pack_name}") + +def _is_valid_zip(path: Path) -> bool: + """Check if a file is a valid ZIP archive""" + try: + with zipfile.ZipFile(path, 'r') as zf: + return zf.testzip() is None + except zipfile.BadZipFile: + return False + except Exception: + return False + + +def get_merged_manifest(pack_name: str, preset: str) -> PackMeta: + """Get manifest merged with preset files. Preset files override base files.""" + base_meta = get_cached_manifest(pack_name) + if not base_meta: + raise FileNotFoundError(f"Pack {pack_name} not found") + + preset_dir = PACKS_DIR / pack_name / "presets" / preset + if not preset_dir.exists() or not preset_dir.is_dir(): + raise FileNotFoundError(f"Preset {preset} not found for pack {pack_name}") + + merged_files = dict(base_meta.files) + + for root, dirs, files in os.walk(preset_dir): + for file in files: + file_path = Path(root) / file + rel_path = file_path.relative_to(preset_dir).as_posix() + stat = file_path.stat() + file_hash = calculate_sha256_sync(file_path) + merged_files[rel_path] = FileEntry( + path=rel_path, + hash=file_hash, + size=stat.st_size, + added_at=datetime.utcfromtimestamp(stat.st_ctime), + modified_at=datetime.utcfromtimestamp(stat.st_mtime) + ) + + return PackMeta( + pack_name=base_meta.pack_name, + version=base_meta.version, + files=merged_files, + updated_at=base_meta.updated_at, + ignored_dirs=base_meta.ignored_dirs, + minecraft_version=base_meta.minecraft_version, + loader_type=base_meta.loader_type, + loader_version=base_meta.loader_version, + asset_index=base_meta.asset_index + ) \ No newline at end of file