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