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>