ForgeFix и новые фичи в интерфейсе
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+252
-67
@@ -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 {
|
||||
|
||||
+2
-1
@@ -157,7 +157,8 @@ public class ForgeInstaller {
|
||||
"java",
|
||||
"-jar",
|
||||
installerJar.toAbsolutePath().toString(),
|
||||
"--installClient"
|
||||
"--installClient",
|
||||
instance.getPath().toAbsolutePath().toString()
|
||||
);
|
||||
|
||||
// Add JVM args for increased timeouts
|
||||
|
||||
+2
-1
@@ -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");
|
||||
|
||||
+308
-20
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+61
-20
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
+104
-4
@@ -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)
|
||||
|
||||
@@ -154,3 +205,52 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
|
||||
# Should not happen
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user