ForgeFix и новые фичи в интерфейсе

This commit is contained in:
SashegDev
2026-07-13 13:16:51 +00:00
parent 0d61ad1107
commit 201269efea
23 changed files with 1166 additions and 327 deletions
@@ -210,6 +210,10 @@ public class Bootstrap {
if (ui != null) ui.setProgress(current, total); 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) { private static void setVersionInfo(String localVer, String serverVer) {
if (ui != null) ui.setVersionInfo(localVer, serverVer); if (ui != null) ui.setVersionInfo(localVer, serverVer);
} }
@@ -467,13 +471,25 @@ public class Bootstrap {
if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) { if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) {
long elapsed = System.currentTimeMillis() - startTime; 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 downloadedMB = downloaded / 1024.0 / 1024.0;
double totalMB = expectedSize / 1024.0 / 1024.0; double totalMB = expectedSize / 1024.0 / 1024.0;
String etaStr = "";
String progressStr = String.format("%.1f/%.1f MB (%.1f MB/s)", downloadedMB, totalMB, speed); 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); log(progressStr);
setProgress((int) downloaded, (int) Math.max(expectedSize, 1)); setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
setSpeedText(String.format("%.1f / %.1f MB \u2022 %.1f MB/s", downloadedMB, totalMB, speed));
lastUpdate = downloaded; lastUpdate = downloaded;
} }
} }
@@ -698,14 +714,16 @@ public class Bootstrap {
if (total > 0) { if (total > 0) {
int pct = (int) ((long) current * 100 / total); int pct = (int) ((long) current * 100 / total);
progressBar.setValue(Math.min(pct, 100)); progressBar.setValue(Math.min(pct, 100));
speedLabel.setText(String.format("%.1f / %.1f MB",
current / 1024.0 / 1024.0, total / 1024.0 / 1024.0));
} else { } else {
progressBar.setIndeterminate(true); progressBar.setIndeterminate(true);
} }
}); });
} }
void setSpeedText(final String text) {
SwingUtilities.invokeLater(() -> speedLabel.setText(text));
}
void setVersionInfo(final String local, final String server) { void setVersionInfo(final String local, final String server) {
SwingUtilities.invokeLater(() -> SwingUtilities.invokeLater(() ->
versionLabel.setText("v" + local + " \u2192 v" + server)); versionLabel.setText("v" + local + " \u2192 v" + server));
+10 -10
View File
@@ -61,31 +61,31 @@
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>javafx-controls</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId> <artifactId>javafx-web</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId> <artifactId>javafx-graphics</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId> <artifactId>javafx-base</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId> <artifactId>javafx-media</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
@@ -226,27 +226,27 @@
<!-- Копируем JavaFX модули в lib/javafx --> <!-- Копируем JavaFX модули в lib/javafx -->
<mkdir dir="../../server/builds/lib/javafx"/> <mkdir dir="../../server/builds/lib/javafx"/>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/23">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/23">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/23">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/23">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/23">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
@@ -96,41 +96,7 @@ public class Main {
} }
private static void mainLoop() throws Exception { private static void mainLoop() throws Exception {
if (Config.isZernMCBuild()) { globalFlow();
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();
} }
private static void globalFlow() throws Exception { private static void globalFlow() throws Exception {
@@ -26,51 +26,7 @@ import java.util.stream.Collectors;
public class LaunchMenu { public class LaunchMenu {
public void show() throws Exception { public void show() throws Exception {
if (Config.isZernMCBuild()) { showGlobal();
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<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("No packs available on the server."));
ConsoleUtils.pause();
return;
}
List<String> 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);
}
} }
private boolean awaitActivePass() throws Exception { 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 { private void showGlobal() throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
@@ -21,6 +21,7 @@ public class Instance {
private int serverVersion; // версия сборки на сервере private int serverVersion; // версия сборки на сервере
private String serverPackName; // имя пака на сервере private String serverPackName; // имя пака на сервере
private String fabricVersionId; private String fabricVersionId;
private String presetId;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
@@ -88,6 +89,15 @@ public class Instance {
saveMetadata(); saveMetadata();
} }
public String getPresetId() {
return presetId;
}
public void setPresetId(String presetId) {
this.presetId = presetId;
saveMetadata();
}
public String getServerPackName() { public String getServerPackName() {
return serverPackName; return serverPackName;
} }
@@ -130,6 +140,7 @@ public class Instance {
this.isServerPack = meta.isServerPack; this.isServerPack = meta.isServerPack;
this.serverVersion = meta.serverVersion; this.serverVersion = meta.serverVersion;
this.serverPackName = meta.serverPackName; this.serverPackName = meta.serverPackName;
this.presetId = meta.presetId;
} catch (Exception e) { } catch (Exception e) {
System.err.println("[Instance] Failed to load metadata for " + name + ": " + e.getMessage()); 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"); Path metaFile = path.resolve("instance.json");
InstanceMeta meta = new InstanceMeta( InstanceMeta meta = new InstanceMeta(
minecraftVersion, loaderType, loaderVersion, assetIndex, minecraftVersion, loaderType, loaderVersion, assetIndex,
isServerPack, serverVersion, serverPackName isServerPack, serverVersion, serverPackName, presetId
); );
try { try {
Files.writeString(metaFile, GSON.toJson(meta)); Files.writeString(metaFile, GSON.toJson(meta));
@@ -156,12 +167,13 @@ public class Instance {
boolean isServerPack = false; boolean isServerPack = false;
int serverVersion = 0; int serverVersion = 0;
String serverPackName; String serverPackName;
String presetId;
public InstanceMeta(String minecraftVersion, String loaderType, public InstanceMeta(String minecraftVersion, String loaderType,
String loaderVersion, String assetIndex, String loaderVersion, String assetIndex,
boolean isServerPack, int serverVersion, boolean isServerPack, int serverVersion,
String serverPackName) { String serverPackName, String presetId) {
this.minecraftVersion = minecraftVersion; this.minecraftVersion = minecraftVersion;
this.loaderType = loaderType; this.loaderType = loaderType;
this.loaderVersion = loaderVersion; this.loaderVersion = loaderVersion;
@@ -169,6 +181,7 @@ public class Instance {
this.isServerPack = isServerPack; this.isServerPack = isServerPack;
this.serverVersion = serverVersion; this.serverVersion = serverVersion;
this.serverPackName = serverPackName; this.serverPackName = serverPackName;
this.presetId = presetId;
} }
} }
} }
@@ -18,6 +18,8 @@ import java.io.InputStreamReader;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
public class MinecraftLib { public class MinecraftLib {
@@ -110,6 +112,7 @@ public class MinecraftLib {
public void launch(LaunchOptions options) throws Exception { public void launch(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName())); System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
cleanupOldLoaders(); cleanupOldLoaders();
validateJarFiles();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options); List<String> 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() { public Instance getInstance() {
return instance; return instance;
} }
@@ -14,17 +14,15 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.*; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class PackDownloader { public class PackDownloader {
@@ -33,9 +31,10 @@ public class PackDownloader {
void onProgress(String label, int percent, String stageName, int stageIndex, int stageCount); 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 Instance instance;
private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
private final HttpClient httpClient = HttpClient.newHttpClient();
private ProgressCallback progressCallback; private ProgressCallback progressCallback;
//private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; //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 * Install or update a pack from the server
*/ */
public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception { 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); reportProgress("Fetching manifest...", 0, "manifest", 0, 5);
// 1. Get manifest // 1. Get manifest
PackManifest manifest = getPackManifest(packName); 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 // 2. First install Minecraft + Loader via MinecraftLib
MinecraftLib lib = new MinecraftLib(instance); 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 || boolean needsMinecraftInstall = instance.getMinecraftVersion() == null ||
!instance.getMinecraftVersion().equals(manifest.getMinecraftVersion()); !instance.getMinecraftVersion().equals(manifest.getMinecraftVersion());
if (needsMinecraftInstall) { if (needsMinecraftInstall) {
LauncherLogger.info("installOrUpdatePack: needs Minecraft install. loader=" + manifest.getLoaderType() + " loaderVer=" + manifest.getLoaderVersion());
if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) { if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) {
LauncherLogger.info("installOrUpdatePack: installing Fabric mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion());
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success);
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Fabric")); System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
LauncherLogger.error("installOrUpdatePack: Fabric install failed");
reportProgress("Failed to install Fabric", 0, "error", 0, 1); reportProgress("Failed to install Fabric", 0, "error", 0, 1);
return false; return false;
} }
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) { } 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()); boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success);
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install NeoForge")); System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
LauncherLogger.error("installOrUpdatePack: NeoForge install failed");
reportProgress("Failed to install NeoForge", 0, "error", 0, 1); reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { } 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()); boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Forge install result=" + success);
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Forge")); System.err.println(ZAnsi.brightRed("Failed to install Forge"));
LauncherLogger.error("installOrUpdatePack: Forge install failed");
reportProgress("Failed to install Forge", 0, "error", 0, 1); reportProgress("Failed to install Forge", 0, "error", 0, 1);
return false; return false;
} }
} else { } else {
LauncherLogger.info("installOrUpdatePack: installing Vanilla Minecraft " + manifest.getMinecraftVersion());
boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success);
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft")); 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); reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
return false; return false;
} }
} }
LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully");
} else { } else {
LauncherLogger.info("installOrUpdatePack: Minecraft already installed, skipping");
System.out.println(ZAnsi.green("Minecraft already installed, skipping...")); System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
} }
reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5); reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5);
// 3. Scan local files only if there are files to download // 3. Scan local files only if there are files to download
LauncherLogger.info("installOrUpdatePack: scanning local files...");
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
LauncherLogger.info("installOrUpdatePack: scanLocalFiles returned " + localFiles.size() + " files. instancePath=" + instance.getPath());
// If pack has no files (vanilla/loader only), skip diff // If pack has no files (vanilla/loader only), skip diff
if (manifest.files == null || manifest.files.isEmpty()) { 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")); System.out.println(ZAnsi.green("Pack contains no additional files"));
reportProgress("Installing...", 50, "Installing pack files", 4, 5); reportProgress("Installing...", 50, "Installing pack files", 4, 5);
// Update instance metadata saveUserFilesSnapshot(localFiles);
instance.setServerPack(true); instance.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
@@ -228,18 +251,44 @@ public class PackDownloader {
System.out.println(ZAnsi.brightGreen("Pack installed successfully!")); System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
return true; return true;
} }
LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff");
// 4. Send diff request // 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); 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<String> 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); 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) { if (success) {
// 6. Update instance metadata LauncherLogger.info("installOrUpdatePack: saving user files snapshot...");
Map<String, String> updatedHashes = scanLocalFiles();
saveUserFilesSnapshot(updatedHashes);
LauncherLogger.info("installOrUpdatePack: updating instance metadata...");
instance.setServerPack(true); instance.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
@@ -249,6 +298,9 @@ public class PackDownloader {
instance.setAssetIndex(manifest.getAssetIndex()); instance.setAssetIndex(manifest.getAssetIndex());
System.out.println(ZAnsi.brightGreen("Pack installed successfully!")); System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
LauncherLogger.info("installOrUpdatePack: SUCCESS");
} else {
LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception");
} }
return success; return success;
@@ -271,6 +323,10 @@ public class PackDownloader {
* Update an existing server pack * Update an existing server pack
*/ */
public boolean updatePack(String packName) throws Exception { 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() + "...")); System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "..."));
PackManifest manifest = getPackManifest(packName); PackManifest manifest = getPackManifest(packName);
@@ -286,13 +342,24 @@ public class PackDownloader {
// Scan local files // Scan local files
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
// Detect user-modified files (skip overwriting)
Set<String> 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 // Get diff
DiffResponse diff = getDiff(packName, localFiles); DiffResponse diff = getDiff(packName, localFiles, preset);
// Apply changes // Apply changes
boolean success = applyDiff(diff, packName); boolean success = applyDiff(diff, packName, protectedFiles, preset);
if (success) { if (success) {
// Save new user files snapshot
Map<String, String> updatedHashes = scanLocalFiles();
saveUserFilesSnapshot(updatedHashes);
instance.setServerVersion(serverVersion); instance.setServerVersion(serverVersion);
System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion)); System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
} }
@@ -310,7 +377,7 @@ public class PackDownloader {
// Игнорируемые директории // Игнорируемые директории
Set<String> ignoredDirs = Set.of( Set<String> ignoredDirs = Set.of(
"resourcepacks", "shaderpacks", "saves", "logs", "resourcepacks", "shaderpacks", "saves", "logs",
"crash-reports", "screenshots", "journeymap", "config", "crash-reports", "screenshots", "journeymap",
"natives", "assets", "libraries", "versions", "cache" "natives", "assets", "libraries", "versions", "cache"
); );
@@ -324,7 +391,8 @@ public class PackDownloader {
Path relative = instancePath.relativize(file); Path relative = instancePath.relativize(file);
String path = relative.toString().replace("\\", "/"); String path = relative.toString().replace("\\", "/");
// Проверяем, не в игнорируемой ли директории // Skip user files snapshot and ignored directories
if (path.equals(USER_FILES_FILE)) return;
for (String ignored : ignoredDirs) { for (String ignored : ignoredDirs) {
if (path.startsWith(ignored + "/") || path.startsWith(ignored + "\\")) { if (path.startsWith(ignored + "/") || path.startsWith(ignored + "\\")) {
return; return;
@@ -346,6 +414,10 @@ public class PackDownloader {
* Send diff request to server * Send diff request to server
*/ */
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception { private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
return getDiff(packName, localFiles, null);
}
private DiffResponse getDiff(String packName, Map<String, String> localFiles, String preset) throws Exception {
String json = gson.toJson(localFiles); String json = gson.toJson(localFiles);
// Get auth token // Get auth token
@@ -358,6 +430,10 @@ public class PackDownloader {
} }
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff"; 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 // Use HttpURLConnection for full control
java.net.HttpURLConnection connection = null; java.net.HttpURLConnection connection = null;
@@ -381,6 +457,7 @@ public class PackDownloader {
} }
int responseCode = connection.getResponseCode(); int responseCode = connection.getResponseCode();
LauncherLogger.info("getDiff: responseCode=" + responseCode + " packName=" + packName);
// Read response // Read response
StringBuilder response = new StringBuilder(); StringBuilder response = new StringBuilder();
@@ -393,6 +470,7 @@ public class PackDownloader {
} }
String responseBody = response.toString(); String responseBody = response.toString();
LauncherLogger.info("[DEBUG] getDiff response body: " + responseBody.substring(0, Math.min(responseBody.length(), 500)));
if (responseCode == 403) { if (responseCode == 403) {
throw new IOException("Active pass required to download packs. Contact the administrator."); 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)); 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 { } finally {
if (connection != null) { if (connection != null) {
@@ -425,9 +505,21 @@ public class PackDownloader {
* Apply diff (download new files, delete old ones) * Apply diff (download new files, delete old ones)
*/ */
private boolean applyDiff(DiffResponse diff, String packName) { private boolean applyDiff(DiffResponse diff, String packName) {
return applyDiff(diff, packName, new HashSet<>(), null);
}
private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles) {
return applyDiff(diff, packName, protectedFiles, null);
}
private boolean applyDiff(DiffResponse diff, String packName, Set<String> 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(ZAnsi.cyan("\nApplying changes:"));
System.out.println(" Download: " + diff.getToDownload().size() + " files"); System.out.println(" Download: " + diff.getToDownload().size() + " files");
System.out.println(" Delete: " + diff.getToDelete().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 // Create directories if needed
try { try {
@@ -437,8 +529,12 @@ public class PackDownloader {
return false; return false;
} }
// Delete files // Delete files (skip protected)
for (String filePath : diff.getToDelete()) { 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); Path fullPath = instance.getPath().resolve(filePath);
try { try {
if (Files.deleteIfExists(fullPath)) { if (Files.deleteIfExists(fullPath)) {
@@ -449,12 +545,25 @@ public class PackDownloader {
} }
} }
// Download files // Download files (skip protected)
AtomicInteger downloaded = new AtomicInteger(0); AtomicInteger downloaded = new AtomicInteger(0);
int total = diff.getToDownload().size(); 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()) { for (FileInfo file : diff.getToDownload()) {
String path = file.getPath(); 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); Path fullPath = instance.getPath().resolve(path);
try { try {
@@ -462,80 +571,103 @@ public class PackDownloader {
Files.createDirectories(fullPath.getParent()); Files.createDirectories(fullPath.getParent());
// Download file // 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); downloadFile(file, fullPath);
// Verify hash // Verify hash
String actualHash = calculateHash(fullPath); String actualHash = calculateHash(fullPath);
if (!actualHash.equals(file.getHash())) { boolean hashMatch = actualHash.equals(file.getHash());
throw new IOException("Hash mismatch! Expected: " + file.getHash() + LauncherLogger.info("applyDiff: downloaded file=" + path + " hashMatch=" + hashMatch + " expected=" + file.getHash() + " got=" + actualHash);
", 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(); downloaded.incrementAndGet();
downloadedBytes.addAndGet(file.getSize());
if (total > 0) { if (total > 0) {
ProgressBar.show("Download", downloaded.get(), total, "files"); ProgressBar.show("Download", downloaded.get(), total, "files");
int pct = 50 + (downloaded.get() * 45 / total); long elapsed = System.currentTimeMillis() - downloadStartTime;
reportProgress("Downloading " + downloaded.get() + "/" + total + " files...", pct, "Installing pack files", 4, 5); 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) { } 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())); 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) { if (total > 0) {
ProgressBar.finish("Download"); ProgressBar.finish("Download");
} }
if (skipped > 0) {
System.out.println(ZAnsi.cyan(" Skipped " + skipped + " user-modified files"));
}
return true; return true;
} }
/** /**
* Скачать один файл с сервера * Скачать один файл с сервера через временный файл для атомарности.
* Предотвращает появление битых файлов при обрыве соединения.
*/ */
private void downloadFile(FileInfo file, Path destination) throws Exception { private void downloadFile(FileInfo file, Path destination) throws Exception {
String url = ZHttpClient.getBaseUrl() + file.getUrl(); Path tmpFile = destination.resolveSibling(destination.getFileName() + ".tmp");
String accessToken = AuthManager.getAccessToken(); try {
String url = ZHttpClient.getBaseUrl() + file.getUrl();
HttpRequest.Builder builder = HttpRequest.newBuilder() ZHttpClient.downloadFileWithSmartProxy(url, tmpFile);
.uri(java.net.URI.create(url)) Files.move(tmpFile, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
.timeout(Duration.ofSeconds(60)) } catch (Exception e) {
.header("User-Agent", "ZernMC-Launcher/1.0") try { Files.deleteIfExists(tmpFile); } catch (Exception ignored) {}
.GET(); throw e;
if (accessToken != null && !accessToken.equals("0")) {
builder.header("Authorization", "Bearer " + accessToken);
} }
HttpRequest request = builder.build();
HttpResponse<InputStream> 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(); 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<String, String> 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<String, String> loadUserFilesSnapshot() {
Path path = getUserFilesPath();
if (!Files.exists(path)) return new HashMap<>();
try {
String content = Files.readString(path);
Map<String, Object> raw = gson.fromJson(content, Map.class);
Map<String, String> result = new HashMap<>();
if (raw != null) {
for (Map.Entry<String, Object> 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<String> getUserModifiedFiles(Map<String, String> currentHashes) {
Map<String, String> snapshot = loadUserFilesSnapshot();
if (snapshot.isEmpty()) return new HashSet<>();
Set<String> modified = new HashSet<>();
for (Map.Entry<String, String> 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 { public static class PackManifest {
@@ -157,7 +157,8 @@ public class ForgeInstaller {
"java", "java",
"-jar", "-jar",
installerJar.toAbsolutePath().toString(), installerJar.toAbsolutePath().toString(),
"--installClient" "--installClient",
instance.getPath().toAbsolutePath().toString()
); );
// Add JVM args for increased timeouts // Add JVM args for increased timeouts
@@ -164,7 +164,8 @@ public class NeoForgeInstaller {
"java", "java",
"-jar", "-jar",
installerJar.toAbsolutePath().toString(), installerJar.toAbsolutePath().toString(),
"--installClient" "--installClient",
instance.getPath().toAbsolutePath().toString()
); );
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
@@ -5,15 +5,30 @@ import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
public class LaunchCommandBuilder { public class LaunchCommandBuilder {
private static final List<Path> tempFiles = new ArrayList<>();
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
for (Path p : tempFiles) {
try { Files.deleteIfExists(p); } catch (Exception ignored) {}
}
}));
}
private final Instance instance; private final Instance instance;
public LaunchCommandBuilder(Instance instance) { public LaunchCommandBuilder(Instance instance) {
@@ -42,13 +57,68 @@ public class LaunchCommandBuilder {
VersionManifest manifest = resolveVersionManifest(); 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) { if (isModloader) {
System.out.println(ZAnsi.cyan(" Modloader detected (" + loaderType + "), using vanilla classpath")); // For Forge/NeoForge: use recursive scan of libraries/ for classpath,
command.add("-cp"); // always use ModLauncher as main class (version.json might have
command.add(buildVanillaClasspath()); // BootstrapLauncher which breaks with Java module system)
command.add(getVanillaMainClass()); if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
command.addAll(getVanillaGameArguments(options)); // 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<String, String> 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) { } else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest); 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())) { 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")); System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
command.add("-cp"); command.add("-cp");
command.add(buildVanillaClasspath()); command.add(writeClasspathFile(buildVanillaClasspath()));
command.add(getVanillaMainClass()); command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options)); command.addAll(getVanillaGameArguments(options));
} else { } else {
command.add("-cp"); command.add("-cp");
command.add(classpath); command.add(writeClasspathFile(classpath));
String mainClass = resolveMainClass(manifest); String mainClass = resolveMainClass(manifest);
command.add(mainClass); command.add(mainClass);
@@ -70,7 +140,7 @@ public class LaunchCommandBuilder {
} }
} else { } else {
command.add("-cp"); command.add("-cp");
command.add(buildVanillaClasspath()); command.add(writeClasspathFile(buildVanillaClasspath()));
command.add(getVanillaMainClass()); command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options)); command.addAll(getVanillaGameArguments(options));
} }
@@ -85,7 +155,12 @@ public class LaunchCommandBuilder {
String content = Files.readString(versionJson); String content = Files.readString(versionJson);
JSONObject json = new JSONObject(content); JSONObject json = new JSONObject(content);
System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName())); 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 { } else {
System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName())); 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())); 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)) { if ("fabric".equals(loaderType)) {
return "net.fabricmc.loader.impl.launch.knot.KnotClient"; return "net.fabricmc.loader.impl.launch.knot.KnotClient";
} else if ("forge".equals(loaderType)) { } else if ("forge".equals(loaderType)) {
return "net.minecraftforge.client.main.ForgeClient"; return "cpw.mods.bootstraplauncher.BootstrapLauncher";
} else if ("neoforge".equals(loaderType)) { } else if ("neoforge".equals(loaderType)) {
return "cpw.mods.bootstraplauncher.BootstrapLauncher"; return "cpw.mods.bootstraplauncher.BootstrapLauncher";
} }
return "net.minecraft.client.main.Main"; return "net.minecraft.client.main.Main";
} }
private List<String> getModloaderLaunchArgs() {
List<String> 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<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) { private List<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) {
List<String> args = new ArrayList<>(); List<String> args = new ArrayList<>();
Map<String, String> vars = buildVariableMap(options); Map<String, String> vars = buildVariableMap(options);
@@ -321,19 +490,53 @@ public class LaunchCommandBuilder {
List<String> paths = new ArrayList<>(); List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries"); 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()) { String loaderType = instance.getLoaderType().toLowerCase();
Path libPath = librariesDir.resolve(lib.artifactPath); boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType);
if (Files.exists(libPath)) {
paths.add(libPath.toAbsolutePath().toString()); 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 { } else {
String mavenPath = mavenToPath(lib.name); String mavenPath = mavenToPath(lib.name);
Path fallbackPath = librariesDir.resolve(mavenPath); Path fallbackPath = librariesDir.resolve(mavenPath);
if (Files.exists(fallbackPath)) { 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 { } 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(); Path versionJar = findVersionJar();
if (versionJar != null) { if (versionJar != null) {
paths.add(0, versionJar.toAbsolutePath().toString()); if (isValidJar(versionJar)) {
System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName())); 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") ? ";" : ":"; String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
return String.join(separator, paths); 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 { private String buildVanillaClasspath() throws Exception {
List<String> paths = new ArrayList<>(); List<String> paths = new ArrayList<>();
String versionId = getVersionId(); String versionId = getVersionId();
@@ -491,4 +714,69 @@ public class LaunchCommandBuilder {
return jvmArgs; 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<String> manifestJvmArgs) {
if (classpath == null || classpath.isEmpty() || manifestJvmArgs == null || manifestJvmArgs.isEmpty()) {
return classpath;
}
Set<String> 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();
}
} }
@@ -15,13 +15,15 @@ public class VersionManifest {
private final String id; private final String id;
private final String mainClass; private final String mainClass;
private final String assetIndexId; private final String assetIndexId;
private final String inheritsFrom;
private final List<String> jvmArguments; private final List<String> jvmArguments;
private final List<String> gameArguments; private final List<String> gameArguments;
private final List<Library> libraries; private final List<Library> libraries;
private VersionManifest parent;
public VersionManifest(JSONObject json) { public VersionManifest(JSONObject json) {
this.id = json.getString("id"); this.id = json.getString("id");
this.mainClass = json.getString("mainClass"); this.mainClass = json.optString("mainClass", "net.minecraft.client.main.Main");
if (json.has("assetIndex")) { if (json.has("assetIndex")) {
JSONObject ai = json.getJSONObject("assetIndex"); JSONObject ai = json.getJSONObject("assetIndex");
@@ -30,6 +32,7 @@ public class VersionManifest {
this.assetIndexId = "unknown"; this.assetIndexId = "unknown";
} }
this.inheritsFrom = json.optString("inheritsFrom", null);
this.jvmArguments = parseArguments(json, "jvm"); this.jvmArguments = parseArguments(json, "jvm");
this.gameArguments = parseArguments(json, "game"); this.gameArguments = parseArguments(json, "game");
this.libraries = parseLibraries(json); this.libraries = parseLibraries(json);
@@ -38,10 +41,47 @@ public class VersionManifest {
public String getId() { return id; } public String getId() { return id; }
public String getMainClass() { return mainClass; } public String getMainClass() { return mainClass; }
public String getAssetIndexId() { return assetIndexId; } public String getAssetIndexId() { return assetIndexId; }
public String getInheritsFrom() { return inheritsFrom; }
public List<String> getJvmArguments() { return jvmArguments; } public List<String> getJvmArguments() { return jvmArguments; }
public List<String> getGameArguments() { return gameArguments; } public List<String> getGameArguments() { return gameArguments; }
public List<Library> getLibraries() { return libraries; } public List<Library> 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<Library> getAllLibraries() {
List<Library> 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<Library> list, String name) {
for (Library l : list) {
if (l.name.equals(name)) return true;
}
return false;
}
private List<String> parseArguments(JSONObject json, String type) { private List<String> parseArguments(JSONObject json, String type) {
List<String> args = new ArrayList<>(); List<String> args = new ArrayList<>();
if (!json.has("arguments")) return args; if (!json.has("arguments")) return args;
@@ -129,29 +169,30 @@ public class VersionManifest {
JSONArray arr = json.getJSONArray("libraries"); JSONArray arr = json.getJSONArray("libraries");
for (int i = 0; i < arr.length(); i++) { for (int i = 0; i < arr.length(); i++) {
JSONObject libJson = arr.getJSONObject(i); JSONObject libJson = arr.getJSONObject(i);
String name = libJson.getString("name");
String artifactPath = null;
if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) { if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) {
String name = libJson.getString("name"); artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path");
String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path"); }
Library lib = new Library(name, artifactPath); Library lib = new Library(name, artifactPath);
if (libJson.has("natives")) { if (libJson.has("natives")) {
JSONObject natives = libJson.getJSONObject("natives"); JSONObject natives = libJson.getJSONObject("natives");
for (String key : natives.keySet()) { for (String key : natives.keySet()) {
String osKey = key.toLowerCase(); String osKey = key.toLowerCase();
lib.natives.put(osKey, natives.getString(key)); lib.natives.put(osKey, natives.getString(key));
}
} }
}
if (libJson.has("rules")) { boolean allowed = true;
JSONObject dummyObj = new JSONObject(); if (libJson.has("rules")) {
dummyObj.put("rules", libJson.getJSONArray("rules")); JSONObject dummyObj = new JSONObject();
dummyObj.put("value", ""); dummyObj.put("rules", libJson.getJSONArray("rules"));
if (ruleMatches(dummyObj)) { dummyObj.put("value", "");
libs.add(lib); allowed = ruleMatches(dummyObj);
} }
} else { if (allowed) {
libs.add(lib); libs.add(lib);
}
} }
} }
return libs; return libs;
@@ -713,8 +713,9 @@ public class JFXLauncher extends Application {
String version = body.get("version"); String version = body.get("version");
String loader = body.get("loader"); String loader = body.get("loader");
String loaderVersion = body.get("loaderVersion"); 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); var createResult = api.instances().createInstance(name);
if (!createResult.isSuccess()) { if (!createResult.isSuccess()) {
@@ -742,24 +743,35 @@ public class JFXLauncher extends Application {
boolean success = false; boolean success = false;
if ("zernmc".equalsIgnoreCase(loader)) { if ("zernmc".equalsIgnoreCase(loader)) {
log("[DEBUG] Starting zernmc pack install for version=" + version);
setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5); setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5);
PackDownloader downloader = new PackDownloader(instance); PackDownloader downloader = new PackDownloader(instance);
downloader.setProgressCallback((label, pct, stageName, stageIdx, stageCount) -> { downloader.setProgressCallback((label, pct, stageName, stageIdx, stageCount) -> {
setInstallProgressWithStage(label, pct, 100, stageName, stageIdx, stageCount); setInstallProgressWithStage(label, pct, 100, stageName, stageIdx, stageCount);
}); });
log("[DEBUG] Getting available packs...");
var packs = downloader.getAvailablePacks(); var packs = downloader.getAvailablePacks();
log("[DEBUG] Available packs: " + (packs != null ? packs.size() : "null"));
ServerPack pack = null; ServerPack pack = null;
for (ServerPack p : packs) { for (ServerPack p : packs) {
log("[DEBUG] Checking pack: " + p.getName() + " vs " + version);
if (p.getName().equals(version)) { if (p.getName().equals(version)) {
pack = p; pack = p;
break; break;
} }
} }
log("[DEBUG] Selected pack: " + (pack != null ? pack.getName() : "null"));
if (pack != null) { if (pack != null) {
try { 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) { } catch (Exception e) {
log("Pack install error: " + e.getMessage()); log("Pack install error: " + e.getMessage());
e.printStackTrace();
throw e; throw e;
} }
if (success) { if (success) {
@@ -796,7 +808,6 @@ public class JFXLauncher extends Application {
setInstallProgressWithStage("Error: " + e.getMessage(), 0, 100, "Error", 0, 1); setInstallProgressWithStage("Error: " + e.getMessage(), 0, 100, "Error", 0, 1);
} }
}); });
installThread.setDaemon(true);
installThread.start(); installThread.start();
} else { } else {
sendJson(exchange, Map.of("success", false, "error", "Instance not found")); 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("systemBasedJvm", Config.isSystemBasedJvm());
data.put("cpuCores", Config.getSystemCpuCores()); data.put("cpuCores", Config.getSystemCpuCores());
data.put("totalRamMB", Config.getSystemTotalRamMB()); data.put("totalRamMB", Config.getSystemTotalRamMB());
data.put("systemJvmFlags", Config.getSystemJvmFlags());
sendJson(exchange, Map.of("success", true, "data", data)); sendJson(exchange, Map.of("success", true, "data", data));
} catch (Exception e) { } catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage())); sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
@@ -1156,7 +1168,10 @@ public class JFXLauncher extends Application {
for (String pair : query.split("&")) { for (String pair : query.split("&")) {
String[] kv = pair.split("="); String[] kv = pair.split("=");
if (kv.length == 2) { 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) { private void handleCheckUpdates(HttpExchange exchange) {
try { try {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery()); Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
String rawName = params.get("name"); String name = params.get("name");
if (rawName == null || rawName.isBlank()) { if (name == null || name.isBlank()) {
sendJson(exchange, Map.of("success", false, "error", "Missing 'name' parameter")); sendJson(exchange, Map.of("success", false, "error", "Missing 'name' parameter"));
return; return;
} }
String name = URLDecoder.decode(rawName, StandardCharsets.UTF_8);
Instance instance = InstanceManager.getInstance(name); Instance instance = InstanceManager.getInstance(name);
if (instance == null) { if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Instance not found")); sendJson(exchange, Map.of("success", false, "error", "Instance not found"));
@@ -1425,7 +1439,12 @@ public class JFXLauncher extends Application {
} }
PackDownloader downloader = new PackDownloader(instance); PackDownloader downloader = new PackDownloader(instance);
String packName = instance.getServerPackName(); 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(); int localVersion = instance.getServerVersion();
sendJson(exchange, Map.of( sendJson(exchange, Map.of(
"success", true, "success", true,
@@ -1458,7 +1477,14 @@ public class JFXLauncher extends Application {
return; return;
} }
PackDownloader downloader = new PackDownloader(instance); 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) { if (success) {
sendJson(exchange, Map.of("success", true, "data", Map.of("version", instance.getServerVersion()))); sendJson(exchange, Map.of("success", true, "data", Map.of("version", instance.getServerVersion())));
} else { } else {
@@ -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_DIR = Path.of(System.getProperty("user.home"), ".zernmc");
private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties"); 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 final Properties props = new Properties();
private static volatile int maxMemory = 4096; private static volatile int maxMemory = 4096;
@@ -133,14 +131,6 @@ public class Config {
return maxMemory; return maxMemory;
} }
public static boolean isZernMCBuild() {
return "zernmc".equalsIgnoreCase(BUILD_PROFILE);
}
public static boolean isGlobalBuild() {
return !isZernMCBuild();
}
public static void setMaxMemory(int memory) { public static void setMaxMemory(int memory) {
if (memory < 1024) memory = 1536; if (memory < 1024) memory = 1536;
if (memory > 32768) memory = 32768; if (memory > 32768) memory = 32768;
@@ -22,13 +22,13 @@ public class LauncherLogger {
initialized = true; initialized = true;
try { try {
Path logsDir = Paths.get(System.getProperty("user.home"), ".zernmc", "logs"); Path logsDir = Paths.get("").toAbsolutePath().resolve("logs");
Files.createDirectories(logsDir); Files.createDirectories(logsDir);
logFile = logsDir.resolve("launcher.log"); logFile = logsDir.resolve("launcher.log");
Files.writeString(logFile, Files.writeString(logFile,
"=== Launcher Log " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " ===\n", "=== 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()); System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath());
} catch (Exception e) { } catch (Exception e) {
@@ -332,12 +332,20 @@ public class ZHttpClient {
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
if (!shouldUseProxyForUrl(url)) { if (!shouldUseProxyForUrl(url)) {
try { try {
HttpRequest request = HttpRequest.newBuilder() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url)) .uri(URI.create(url))
.timeout(Duration.ofSeconds(40)) .timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET() .GET();
.build();
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<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
@@ -103,6 +103,9 @@
<div class="sidebar-section"> <div class="sidebar-section">
<div class="section-header"> <div class="section-header">
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span> <span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
<button class="btn-icon" id="add-server-pack-btn" title="Install server pack">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div> </div>
<div id="server-packs-list" class="pack-list"></div> <div id="server-packs-list" class="pack-list"></div>
</div> </div>
@@ -198,6 +201,13 @@
</div> </div>
</div> </div>
<div id="inline-install-progress" class="inline-install-progress hidden">
<div class="progress-track">
<div class="progress-fill" id="inline-progress-fill"></div>
</div>
<p class="progress-label" id="inline-progress-label">Installing...</p>
<p class="progress-stage hidden" id="inline-progress-stage"></p>
</div>
<div class="play-bar" id="play-bar"> <div class="play-bar" id="play-bar">
<div class="play-bar-info"> <div class="play-bar-info">
<span id="play-bar-name">Select a pack</span> <span id="play-bar-name">Select a pack</span>
@@ -253,6 +263,7 @@
<button class="admin-tab" data-atab="mods"><span data-i18n="admin.mods">Mods</span></button> <button class="admin-tab" data-atab="mods"><span data-i18n="admin.mods">Mods</span></button>
<button class="admin-tab" data-atab="users"><span data-i18n="admin.users">Users</span></button> <button class="admin-tab" data-atab="users"><span data-i18n="admin.users">Users</span></button>
<button class="admin-tab" data-atab="passes"><span data-i18n="admin.passes">Passes</span></button> <button class="admin-tab" data-atab="passes"><span data-i18n="admin.passes">Passes</span></button>
<button class="admin-tab" data-atab="packs"><span data-i18n="admin.packs">Packs</span></button>
<button class="admin-tab" data-atab="news"><span data-i18n="admin.news">News</span></button> <button class="admin-tab" data-atab="news"><span data-i18n="admin.news">News</span></button>
</div> </div>
<div class="admin-content"> <div class="admin-content">
@@ -315,6 +326,13 @@
<div id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div> <div id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div> </div>
</div> </div>
<!-- Packs -->
<div id="atab-packs" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.packsList">Pack Management</span></div>
<div id="admin-packs-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
</div>
</div>
<!-- News --> <!-- News -->
<div id="atab-news" class="admin-tab-content"> <div id="atab-news" class="admin-tab-content">
<div class="admin-card"> <div class="admin-card">
@@ -506,6 +524,12 @@
<option value="">Loading...</option> <option value="">Loading...</option>
</select> </select>
</div> </div>
<div id="preset-select-container" class="field hidden">
<label data-i18n="install.preset.label">Preset (optional)</label>
<select id="preset-select">
<option value=""><span data-i18n="install.preset.default">Default</span></option>
</select>
</div>
<button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button> <button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
</div> </div>
+148 -15
View File
@@ -163,9 +163,19 @@ const LOCALES = {
'admin.newsCreated': 'News published!', 'admin.newsCreated': 'News published!',
'admin.newsDeleted': 'News deleted', 'admin.newsDeleted': 'News deleted',
'admin.newsEmpty': 'No news', '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.delete': 'Delete',
'admin.loading': 'Loading...', 'admin.loading': 'Loading...',
'admin.error': 'Error', 'admin.error': 'Error',
'install.preset.label': 'Preset (optional)',
'install.preset.default': 'Default',
'pack.disabled': 'Disabled',
}, },
ru: { ru: {
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки', 'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
@@ -329,9 +339,19 @@ const LOCALES = {
'admin.newsCreated': 'Новость опубликована!', 'admin.newsCreated': 'Новость опубликована!',
'admin.newsDeleted': 'Новость удалена', 'admin.newsDeleted': 'Новость удалена',
'admin.newsEmpty': 'Нет новостей', 'admin.newsEmpty': 'Нет новостей',
'admin.packs': 'Сборки',
'admin.packsList': 'Управление сборками',
'admin.packsEmpty': 'Нет сборок',
'admin.packsToggle': 'Переключить',
'admin.packsDisabled': 'Отключена',
'admin.packsEnabled': 'Включена',
'admin.packsStatus': 'Статус',
'admin.delete': 'Удалить', 'admin.delete': 'Удалить',
'admin.loading': 'Загрузка...', 'admin.loading': 'Загрузка...',
'admin.error': 'Ошибка', 'admin.error': 'Ошибка',
'install.preset.label': 'Пресет (опционально)',
'install.preset.default': 'Стандартный',
'pack.disabled': 'Отключена',
} }
}; };
@@ -570,6 +590,10 @@ class ZernMCLauncher {
const r = await this.req('/packs'); const r = await this.req('/packs');
if (r.success && r.data) { if (r.success && r.data) {
this.state.serverPacks = 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-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.getElementById('close-modal-btn').addEventListener('click', () => this.hideInstallModal());
document.querySelectorAll('.modal-tab').forEach(t => { document.querySelectorAll('.modal-tab').forEach(t => {
t.addEventListener('click', () => this.switchInstallTab(t.dataset.tab)); t.addEventListener('click', () => this.switchInstallTab(t.dataset.tab));
@@ -673,6 +698,10 @@ class ZernMCLauncher {
if (!this.state.instances) return; if (!this.state.instances) return;
for (const inst of this.state.instances) { for (const inst of this.state.instances) {
if (!inst.isServerPack) continue; 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)); const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name));
if (r.success && r.data) { if (r.success && r.data) {
this.state.updateMap[inst.name] = r.data.hasUpdate; this.state.updateMap[inst.name] = r.data.hasUpdate;
@@ -682,6 +711,10 @@ class ZernMCLauncher {
async checkUpdateForPack(inst) { async checkUpdateForPack(inst) {
if (!inst || !inst.isServerPack) return; 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)); const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name));
if (r.success && r.data) { if (r.success && r.data) {
if (!this.state.updateMap) this.state.updateMap = {}; if (!this.state.updateMap) this.state.updateMap = {};
@@ -706,9 +739,11 @@ class ZernMCLauncher {
this.state.instances.forEach(inst => { this.state.instances.forEach(inst => {
const isZern = inst.isServerPack || inst.category === 'zernmc'; const isZern = inst.isServerPack || inst.category === 'zernmc';
const targetList = isZern ? serverList : localList; 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'); 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); const hasUpd = this.hasUpdate(inst);
el.innerHTML = ` el.innerHTML = `
<div class="pack-entry-icon ${isZern ? 'server' : 'local'}"> <div class="pack-entry-icon ${isZern ? 'server' : 'local'}">
@@ -716,14 +751,16 @@ class ZernMCLauncher {
? '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>' ? '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>'
: '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>' : '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>'
}</svg> }</svg>
${hasUpd ? '<span class="update-badge"></span>' : ''} ${hasUpd && !isDisabled ? '<span class="update-badge"></span>' : ''}
</div> </div>
<div class="pack-entry-info"> <div class="pack-entry-info">
<div class="pack-entry-name">${this.esc(inst.name)}${hasUpd ? ' <span class="update-label">' + t('pack.update') + '</span>' : ''}</div> <div class="pack-entry-name">${this.esc(inst.name)}${hasUpd && !isDisabled ? ' <span class="update-label">' + t('pack.update') + '</span>' : ''}</div>
<div class="pack-entry-meta">${inst.version || '?'}${inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : ''}</div> <div class="pack-entry-meta">${inst.version || '?'}${inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : ''}</div>
</div> </div>
`; `;
el.addEventListener('click', () => this.selectPack(inst)); if (!isDisabled) {
el.addEventListener('click', () => this.selectPack(inst));
}
targetList.appendChild(el); targetList.appendChild(el);
}); });
@@ -965,6 +1002,7 @@ class ZernMCLauncher {
if (tab === 'users') this.adminClearUser(); if (tab === 'users') this.adminClearUser();
if (tab === 'passes') this.adminLoadPasses(); if (tab === 'passes') this.adminLoadPasses();
if (tab === 'news') this.adminLoadNewsList(); if (tab === 'news') this.adminLoadNewsList();
if (tab === 'packs') this.adminLoadPacks();
} }
// --- Clients --- // --- Clients ---
@@ -1229,6 +1267,48 @@ class ZernMCLauncher {
} }
} }
// --- Packs ---
async adminLoadPacks() {
var el = document.getElementById('admin-packs-list');
if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
var r = await this.req('/admin/packs');
if (!r.success) {
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
return;
}
var packs = r.packs || [];
if (!packs.length) {
el.innerHTML = '<div class="admin-empty">' + t('admin.packsEmpty') + '</div>';
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 += '<div class="admin-item">'
+ '<span class="admin-item-name">' + app.esc(p.name) + '</span>'
+ '<span class="admin-item-meta ' + statusClass + '">' + statusText + '</span>'
+ '<div class="admin-item-actions">'
+ '<button class="btn-secondary btn-sm" onclick="app.adminTogglePack(\'' + app.esc(p.name) + '\')">' + t('admin.packsToggle') + '</button>'
+ '</div></div>';
});
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 ==================== // ==================== NEWS ====================
async loadNews() { async loadNews() {
const r = await this.req('/news'); const r = await this.req('/news');
@@ -1369,10 +1449,13 @@ class ZernMCLauncher {
async showInstallModal() { async showInstallModal() {
document.getElementById('install-modal').classList.remove('hidden'); document.getElementById('install-modal').classList.remove('hidden');
const zernmcSel = document.getElementById('zernmc-pack-select'); 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'); const mcSel = document.getElementById('mc-version-select');
if (zernmcSel) { if (zernmcSel) {
zernmcSel.innerHTML = '<option>' + t('select.loading') + '</option>'; zernmcSel.innerHTML = '<option>' + t('select.loading') + '</option>';
} }
if (presetContainer) presetContainer.classList.add('hidden');
if (mcSel) { if (mcSel) {
mcSel.innerHTML = '<option>' + t('select.loading') + '</option>'; mcSel.innerHTML = '<option>' + t('select.loading') + '</option>';
} }
@@ -1384,11 +1467,35 @@ class ZernMCLauncher {
zernmcSel.innerHTML = '<option value="">' + t('select.selectPack') + '</option>'; zernmcSel.innerHTML = '<option value="">' + t('select.selectPack') + '</option>';
zernmcSel.disabled = false; zernmcSel.disabled = false;
packs.data.forEach(p => { packs.data.forEach(p => {
if (p.disabled) return; // skip disabled packs
const o = document.createElement('option'); const o = document.createElement('option');
o.value = p.name; o.value = p.name;
o.dataset.presets = JSON.stringify(p.presets || []);
o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')'; o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')';
zernmcSel.appendChild(o); 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 = '<option value="">' + t('install.preset.default') + '</option>';
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 { } else {
zernmcSel.innerHTML = '<option value="">' + t('select.passRequired') + '</option>'; zernmcSel.innerHTML = '<option value="">' + t('select.passRequired') + '</option>';
zernmcSel.disabled = true; zernmcSel.disabled = true;
@@ -1436,7 +1543,9 @@ class ZernMCLauncher {
hideInstallModal() { hideInstallModal() {
document.getElementById('install-modal').classList.add('hidden'); document.getElementById('install-modal').classList.add('hidden');
document.getElementById('install-progress').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) { switchInstallTab(tab) {
@@ -1584,12 +1693,16 @@ class ZernMCLauncher {
async installZernMCPack() { async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value; const packName = document.getElementById('zernmc-pack-select').value;
if (!packName) { this.toast(t('select.selectPack'), 'error'); return; } 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) // Use server pack name as instance name (no local naming)
const instanceName = packName; const instanceName = packName;
const body = { name: instanceName, version: packName, loader: 'zernmc' };
if (preset) body.preset = preset;
const r = await this.req('/install', { const r = await this.req('/install', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name: instanceName, version: packName, loader: 'zernmc' }) body: JSON.stringify(body)
}); });
if (r.success) { if (r.success) {
this.toast(t('toast.installing'), 'info'); this.toast(t('toast.installing'), 'info');
@@ -1624,23 +1737,43 @@ class ZernMCLauncher {
this.progressPoller = setInterval(async () => { this.progressPoller = setInterval(async () => {
const r = await this.req('/install/progress'); const r = await this.req('/install/progress');
if (r.success && r.data) { if (r.success && r.data) {
const p = document.getElementById('install-progress'); const modalP = document.getElementById('install-progress');
p.classList.remove('hidden'); modalP.classList.remove('hidden');
document.getElementById('progress-fill').style.width = (r.data.percent || 0) + '%'; const pct = (r.data.percent || 0) + '%';
document.getElementById('progress-label').textContent = r.data.label || 'Installing...'; const label = r.data.label || 'Installing...';
const stageInfo = document.getElementById('progress-stage');
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) { 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}); modalStage.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.classList.remove('hidden');
} else { } else {
stageInfo.classList.add('hidden'); modalStage.classList.add('hidden');
} }
if (!r.data.inProgress) { if (!r.data.inProgress) {
this.stopProgressPoll(); this.stopProgressPoll();
this.hideInstallModal(); this.hideInstallModal();
inlineProgress.classList.add('hidden');
var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && r.data.label.startsWith('Error')); 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'); 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); }, 500);
@@ -620,6 +620,7 @@ body {
.select-wrap select:focus { border-color: var(--accent); } .select-wrap select:focus { border-color: var(--accent); }
.install-progress { padding-top: 16px; border-top: 1px solid var(--border); } .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 { .progress-track {
height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden; 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-info { flex: 1; min-width: 0; }
.admin-news-title { font-size: 13px; font-weight: 500; } .admin-news-title { font-size: 13px; font-weight: 500; }
.admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } .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; }
+5 -18
View File
@@ -72,31 +72,31 @@
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>javafx-controls</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId> <artifactId>javafx-web</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId> <artifactId>javafx-graphics</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId> <artifactId>javafx-base</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId> <artifactId>javafx-media</artifactId>
<version>21</version> <version>23</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
</dependencies> </dependencies>
@@ -142,27 +142,14 @@
</plugins> </plugins>
</build> </build>
<profiles> <profiles>
<!-- ==================== GLOBAL ==================== -->
<profile> <profile>
<id>global</id> <id>global</id>
<activation> <activation>
<activeByDefault>true</activeByDefault> <activeByDefault>true</activeByDefault>
</activation> </activation>
<properties> <properties>
<build.profile>global</build.profile>
<launcher.title>ZernMC Launcher</launcher.title> <launcher.title>ZernMC Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url> <server.url>http://87.120.187.36:1582</server.url>
<!-- Можно добавить флаги для отключения некоторых фич -->
</properties>
</profile>
<!-- ==================== ZERNMC ==================== -->
<profile>
<id>zernmc</id>
<properties>
<build.profile>zernmc</build.profile>
<launcher.title>ZernMC Private Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url>
</properties> </properties>
</profile> </profile>
</profiles> </profiles>
+54
View File
@@ -9,6 +9,8 @@ import hashlib
from datetime import datetime from datetime import datetime
from pathlib import Path 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 auth import get_db, require_role, log_audit, get_current_user, hash_password
from roles import ( from roles import (
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions, ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
@@ -877,6 +879,58 @@ async def admin_delete_news(
return {"success": True, "id": target.stem} 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") @router.get("/me")
async def get_my_info(current_user: dict = Depends(get_current_user)): async def get_my_info(current_user: dict = Depends(get_current_user)):
"""Информация о текущем пользователе с правами""" """Информация о текущем пользователе с правами"""
+49 -10
View File
@@ -19,7 +19,7 @@ from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").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 models import PackMeta
from middleware import LoggingMiddleware from middleware import LoggingMiddleware
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode, run_sync_mode 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_type": meta.get("loader_type", "vanilla"),
"loader_version": meta.get("loader_version"), "loader_version": meta.get("loader_version"),
"asset_index": meta.get("asset_index"), "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: except Exception as e:
logger.error(f"Failed to load pack meta for {pack_dir.name}: {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( async def get_pack_diff(
pack_name: str, pack_name: str,
request: Request, 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", ... } """Client sends: { "mods/jei.jar": "sha256_hash", ... }
Server returns diff information Server returns diff information
@@ -1060,6 +1063,10 @@ async def get_pack_diff(
detail="Для скачивания сборок требуется активная проходка. Обратитесь к администратору." 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" client_ip = request.client.host if request.client else "unknown"
# Читаем тело запроса # Читаем тело запроса
@@ -1071,13 +1078,17 @@ async def get_pack_diff(
logger.info("Received diff request", logger.info("Received diff request",
pack=pack_name, pack=pack_name,
preset=preset,
client_files_count=len(body), client_files_count=len(body),
client_ip=client_ip) client_ip=client_ip)
try: try:
meta = get_cached_manifest(pack_name) if preset:
if not meta: meta = get_merged_manifest(pack_name, preset)
meta = await scan_pack(pack_name) else:
meta = get_cached_manifest(pack_name)
if not meta:
meta = await scan_pack(pack_name)
except FileNotFoundError: except FileNotFoundError:
logger.warning("Pack not found", pack=pack_name, client_ip=client_ip) logger.warning("Pack not found", pack=pack_name, client_ip=client_ip)
raise HTTPException(404, "Pack not found") raise HTTPException(404, "Pack not found")
@@ -1095,6 +1106,8 @@ async def get_pack_diff(
client_hash = body.get(path) client_hash = body.get(path)
if client_hash is None or client_hash != entry.hash: if client_hash is None or client_hash != entry.hash:
url = f"/pack/{pack_name}/file/{path}" url = f"/pack/{pack_name}/file/{path}"
if preset:
url += f"?preset={preset}"
to_download.append({ to_download.append({
"path": path, "path": path,
"url": url, "url": url,
@@ -1127,6 +1140,9 @@ async def get_pack_diff(
@app.get("/pack/{pack_name}") @app.get("/pack/{pack_name}")
async def get_pack_manifest(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)): async def get_pack_manifest(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)):
"""Get pack manifest with caching""" """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" client_ip = request.client.host if request.client else "unknown"
cached_meta = get_cached_manifest(pack_name) 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}") @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)): 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""" """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): if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
raise HTTPException(403, "Requires active pass") 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 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 # Security: prevent path traversal
try: try:
full_path = full_path.resolve() full_path = full_path.resolve()
@@ -2245,7 +2282,9 @@ async def proxy_download(request: Request):
"resources.download.minecraft.net", "resources.download.minecraft.net",
"maven.minecraftforge.net", "maven.minecraftforge.net",
"files.minecraftforge.net", "files.minecraftforge.net",
"maven.neoforged.net" "maven.neoforged.net",
"api.zernmc.ru",
"api.zernmc.online"
] ]
# Проверяем, что URL ведет на разрешенный домен # Проверяем, что URL ведет на разрешенный домен
+15 -12
View File
@@ -154,24 +154,27 @@ class LoggingMiddleware(BaseHTTPMiddleware):
_stats["blocked"] += 1 _stats["blocked"] += 1
return Response(status_code=404, content="") return Response(status_code=404, content="")
# Check rate limit path = request.url.path
if not check_rate_limit(client_ip):
_stats["rate_limited"] += 1 # Skip rate limiting for pack file downloads (direct high-speed serving)
# Periodic stats logging instead of every warning if path.startswith("/pack/") and "/file/" in path:
if time.time() - _stats_last_log > STATS_LOG_INTERVAL: is_file_download = True
logger.warning(f"Stats: {_stats}") else:
_stats_last_log = time.time() is_file_download = False
return Response(status_code=429, content="Too many requests")
# 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) # Check suspicious path (silent 404 for bots)
path = request.url.path
if is_suspicious_path(path): if is_suspicious_path(path):
# Return 404 without logging - confuse the bots # Return 404 without logging - confuse the bots
return Response(status_code=404, content="") 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 # Track total requests for stats
_stats["total"] += 1 _stats["total"] += 1
+104 -4
View File
@@ -1,9 +1,10 @@
import hashlib import hashlib
import os import os
import zipfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import json import json
from typing import Optional, Dict from typing import Optional, Dict, List
import structlog import structlog
import asyncio import asyncio
import aiofiles import aiofiles
@@ -19,6 +20,46 @@ DATA_DIR.mkdir(exist_ok=True)
# Cache for loaded manifests # Cache for loaded manifests
_manifest_cache: Dict[str, PackMeta] = {} _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]: def get_cached_manifest(pack_name: str) -> Optional[PackMeta]:
"""Get manifest from memory cache if available""" """Get manifest from memory cache if available"""
return _manifest_cache.get(pack_name) 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] = {} new_files: Dict[str, FileEntry] = {}
changed = False changed = False
# Get ignored directories # Get ignored directories (always use current list, not cached from meta)
ignored_dirs = current_meta.ignored_dirs if current_meta else [ ignored_dirs = [
"resourcepacks", "shaderpacks", "saves", "logs", "resourcepacks", "shaderpacks", "saves", "logs",
"crash-reports", "screenshots", "journeymap", "config" "crash-reports", "screenshots", "journeymap",
"presets"
] ]
# Walk through pack directory # 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 file_path = Path(root) / file
rel_path = file_path.relative_to(pack_path).as_posix() 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 # Skip if in ignored directory
if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs): if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs):
continue 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() stat = file_path.stat()
file_hash = await calculate_sha256(file_path) file_hash = await calculate_sha256(file_path)
@@ -154,3 +205,52 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
# Should not happen # Should not happen
raise Exception(f"Failed to scan pack {pack_name}") 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
)