diff --git a/launcher/bootstrap/pom.xml b/launcher/bootstrap/pom.xml index 728a00f..b7f8108 100644 --- a/launcher/bootstrap/pom.xml +++ b/launcher/bootstrap/pom.xml @@ -7,7 +7,7 @@ me.sashegdev ZernMCLauncher - 1.0.10 + 1.0.11 zernmc-bootstrap diff --git a/launcher/launcher/pom.xml b/launcher/launcher/pom.xml index 36f5f9b..cbe5118 100644 --- a/launcher/launcher/pom.xml +++ b/launcher/launcher/pom.xml @@ -7,7 +7,7 @@ me.sashegdev ZernMCLauncher - 1.0.10 + 1.0.11 zernmclauncher diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java index 0fde470..3bc1f3c 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java @@ -1,8 +1,7 @@ package me.sashegdev.zernmc.launcher.minecraft; import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller; -import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller; -import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller; +import me.sashegdev.zernmc.launcher.minecraft.installer.ModLoaderInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; @@ -45,13 +44,13 @@ public class MinecraftLib { } public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception { - ForgeInstaller installer = new ForgeInstaller(instance); - return installer.install(minecraftVersion, forgeVersion); + return new ModLoaderInstaller(instance) + .install(minecraftVersion, forgeVersion, ModLoaderInstaller.LoaderType.FORGE); } public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception { - NeoForgeInstaller installer = new NeoForgeInstaller(instance); - return installer.install(minecraftVersion, neoforgeVersion); + return new ModLoaderInstaller(instance) + .install(minecraftVersion, neoforgeVersion, ModLoaderInstaller.LoaderType.NEOFORGE); } public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception { @@ -123,6 +122,15 @@ public class MinecraftLib { ProcessBuilder pb = new ProcessBuilder(command); pb.directory(instance.getPath().toFile()); + // Write launch command to file for debugging + Path cmdLogFile = instance.getPath().resolve("launch-command.log"); + try { + Files.writeString(cmdLogFile, String.join(" \\\n ", command)); + System.out.println(ZAnsi.green(" Launch command written to " + cmdLogFile.toAbsolutePath())); + } catch (Exception e) { + System.out.println(ZAnsi.yellow(" Failed to write launch command log: " + e.getMessage())); + } + System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n")); ConsoleUtils.clearScreen(); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java new file mode 100644 index 0000000..fb61490 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java @@ -0,0 +1,319 @@ +package me.sashegdev.zernmc.launcher.minecraft.installer; + +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.utils.ProgressBar; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; + +import java.io.*; +import java.net.URI; +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.StandardOpenOption; +import java.time.Duration; +import java.util.*; + +public class ModLoaderInstaller { + + public enum LoaderType { + FORGE("forge", "net.minecraftforge", "forge", "https://maven.minecraftforge.net"), + NEOFORGE("neoforge", "net.neoforged", null, "https://maven.neoforged.net/releases"); + + final String loaderName; + final String mavenGroup; + final String fixedArtifact; + final String mavenBase; + + LoaderType(String loaderName, String mavenGroup, String fixedArtifact, String mavenBase) { + this.loaderName = loaderName; + this.mavenGroup = mavenGroup; + this.fixedArtifact = fixedArtifact; + this.mavenBase = mavenBase; + } + + public String artifact(String mcVersion) { + if (this == NEOFORGE && !"1.20.1".equals(mcVersion)) return "neoforge"; + return "forge"; + } + } + + private final Instance instance; + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build(); + + public ModLoaderInstaller(Instance instance) { + this.instance = instance; + } + + public boolean install(String mcVersion, String loaderVersion, LoaderType type) throws Exception { + System.out.println(ZAnsi.cyan("Installing " + type.loaderName + " " + loaderVersion + " for Minecraft " + mcVersion)); + + System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "...")); + VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); + String assetIndex = vanillaInstaller.install(mcVersion); + + if (assetIndex == null || assetIndex.isEmpty()) { + System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version")); + return false; + } + + instance.setAssetIndex(assetIndex); + createLauncherProfile(); + + String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type); + String jarName = type.loaderName + "-installer.jar"; + Path installerJar = instance.getPath().resolve(jarName); + + System.out.println(ZAnsi.cyan("Downloading " + type.loaderName + " Installer...")); + downloadFileWithProgress(installerUrl, installerJar, type); + + System.out.println(ZAnsi.cyan("Running " + type.loaderName + " Installer...")); + System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n")); + + boolean success = runInstaller(installerJar, type); + + if (success) { + try { + downloadMissingLibraries(type); + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage())); + } + + System.out.println(ZAnsi.brightGreen("\n" + type.loaderName + " " + loaderVersion + " installed successfully!")); + instance.setMinecraftVersion(mcVersion); + instance.setLoaderType(type.loaderName); + instance.setLoaderVersion(loaderVersion); + + Files.deleteIfExists(installerJar); + return true; + } else { + System.out.println(ZAnsi.brightRed("\nError installing " + type.loaderName + "!")); + return false; + } + } + + private String buildInstallerUrl(String mcVersion, String loaderVersion, LoaderType type) { + if (type == LoaderType.FORGE) { + return type.mavenBase + "/" + type.mavenGroup.replace('.', '/') + + "/" + type.fixedArtifact + "/" + mcVersion + "-" + loaderVersion + + "/" + type.fixedArtifact + "-" + mcVersion + "-" + loaderVersion + "-installer.jar"; + } + + String artifact = type.artifact(mcVersion); + return type.mavenBase + "/" + type.mavenGroup.replace('.', '/') + + "/" + artifact + "/" + loaderVersion + + "/" + artifact + "-" + loaderVersion + "-installer.jar"; + } + + private void createLauncherProfile() throws IOException { + Path profilePath = instance.getPath().resolve("launcher_profiles.json"); + if (Files.exists(profilePath)) return; + + String minimalProfile = """ + { + "profiles": {}, + "selectedProfile": "Default" + } + """; + Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + System.out.println(ZAnsi.yellow("Created launcher_profiles.json")); + } + + private void downloadFileWithProgress(String url, Path target, LoaderType type) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1); + + try (InputStream in = response.body(); + FileOutputStream out = new FileOutputStream(target.toFile())) { + + byte[] buffer = new byte[8192]; + int bytesRead; + long totalRead = 0; + int lastPercent = -1; + + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + totalRead += bytesRead; + + if (contentLength > 0) { + int percent = (int) ((totalRead * 100) / contentLength); + if (percent != lastPercent) { + ProgressBar.show(type.loaderName + " Installer", percent, 100, + "% (" + ProgressBar.formatBytes(totalRead) + "/" + ProgressBar.formatBytes(contentLength) + ")"); + lastPercent = percent; + } + } else { + char[] spinner = {'|', '/', '-', '\\'}; + int idx = (int) (totalRead / 1024) % 4; + System.out.print("\rDownloading " + type.loaderName + " Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]); + } + } + } + + ProgressBar.finish(type.loaderName + " Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")"); + } + + private boolean runInstaller(Path installerJar, LoaderType type) throws IOException, InterruptedException { + int maxRetries = 3; + int attempt = 1; + + while (attempt <= maxRetries) { + System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries)); + + ProcessBuilder pb = new ProcessBuilder( + "java", + "-jar", + installerJar.toAbsolutePath().toString(), + "--installClient", + instance.getPath().toAbsolutePath().toString() + ); + + pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); + pb.directory(instance.getPath().toFile()); + pb.redirectErrorStream(true); + + Process process = pb.start(); + + StringBuilder output = new StringBuilder(); + boolean hasErrors = false; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + + if (line.contains("Downloading") || line.contains("Extracting")) { + System.out.println(ZAnsi.blue(" -> " + line)); + } else if (line.contains("SUCCESS") || line.contains("successfully")) { + System.out.println(ZAnsi.brightGreen(" + " + line)); + } else if (line.contains("WARNING") || line.contains("warning")) { + System.out.println(ZAnsi.yellow(" ! " + line)); + } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) { + System.out.println(ZAnsi.brightRed(" X " + line)); + if (line.contains("timed out") || line.contains("failed to download")) { + hasErrors = true; + } + } else if (!line.isBlank()) { + System.out.println(" " + line); + } + } + } + + int exitCode = process.waitFor(); + + if (exitCode == 0 && !hasErrors) { + return true; + } + + if (attempt < maxRetries) { + System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds...")); + Thread.sleep(5000); + + Path librariesDir = instance.getPath().resolve("libraries"); + if (Files.exists(librariesDir)) { + try (var stream = Files.walk(librariesDir)) { + stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar")) + .forEach(p -> { + try { Files.deleteIfExists(p); } + catch (IOException e) { /* ignore */ } + }); + } + } + } else { + System.out.println(ZAnsi.brightRed(type.loaderName + " Installer exited with error code: " + exitCode)); + + if (output.toString().contains("timed out")) { + System.out.println(ZAnsi.yellow("\nPossible solutions:")); + System.out.println(ZAnsi.yellow("1. Check your internet connection")); + System.out.println(ZAnsi.yellow("2. Run the launcher as administrator")); + System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall")); + System.out.println(ZAnsi.yellow("4. Try installing a different version")); + } + } + + attempt++; + } + + return false; + } + + private void downloadMissingLibraries(LoaderType type) throws Exception { + System.out.println(ZAnsi.cyan("Checking and downloading missing libraries...")); + + Map> alternativeUrls = new LinkedHashMap<>(); + alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", List.of( + "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar", + "https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar" + )); + + if (type == LoaderType.NEOFORGE) { + alternativeUrls.put("org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar", List.of( + "https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar" + )); + alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar", List.of( + "https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar" + )); + } + + Path librariesDir = instance.getPath().resolve("libraries"); + + for (Map.Entry> entry : alternativeUrls.entrySet()) { + Path target = librariesDir.resolve(entry.getKey()); + if (!Files.exists(target)) { + Files.createDirectories(target.getParent()); + System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName())); + + boolean downloaded = false; + for (String mirrorUrl : entry.getValue()) { + for (int attempt = 1; attempt <= 3; attempt++) { + try { + downloadDirect(mirrorUrl, target); + downloaded = true; + break; + } catch (Exception e) { + if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e; + System.out.println(ZAnsi.yellow("Retry " + attempt + "/3...")); + Thread.sleep(2000); + } + } + if (downloaded) break; + } + } + } + } + + private void downloadDirect(String url, Path target) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + try (InputStream in = response.body(); + FileOutputStream out = new FileOutputStream(target.toFile())) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java index 4326675..a1cb42d 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java @@ -85,23 +85,14 @@ public class LaunchCommandBuilder { String cpFile = writeClasspathFile(classpath); - // DEBUG: print classpath entries to identify split-package sources - String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; - System.out.println(ZAnsi.cyan(" === CLASSPATH ENTRIES (" + classpath.split(sep.replace("\\","\\\\")).length + " jars) ===")); - for (String entry : classpath.split(sep.replace("\\","\\\\"))) { - String fileName = entry.contains("/") ? entry.substring(entry.lastIndexOf('/') + 1) : entry.substring(entry.lastIndexOf('\\') + 1); - System.out.println(ZAnsi.cyan(" CP: " + fileName)); - } - System.out.println(ZAnsi.cyan(" === END CLASSPATH ===")); - // Build variable map for placeholder substitution Map vars = buildVariableMap(options); vars.put("classpath", cpFile); - // Parse ALL version.json JVM args (merged with parent) with placeholder - // substitution. This includes -Djava.library.path, -cp, --add-modules, - // --add-opens, etc. — everything the launcher needs. - List allJvmArgs = manifest != null ? manifest.getAllJvmArguments() : new ArrayList<>(); + // Parse version.json JVM args (child only). Forge/NeoForge defines its own + // -p, --add-modules, --add-opens. -Djava.library.path is NOT in child args, + // so it's added manually below. + List allJvmArgs = manifest != null ? manifest.getJvmArguments() : new ArrayList<>(); if (!allJvmArgs.isEmpty()) { for (String arg : allJvmArgs) { String resolved = resolveVariable(arg, vars); @@ -113,11 +104,11 @@ public class LaunchCommandBuilder { } else { System.out.println(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback")); command.addAll(getJvmArguments(options)); - command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); - command.add("-cp"); - command.add(cpFile); } + // Forge/NeoForge child version.json doesn't include -Djava.library.path + command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); + // Append memory/GC args (always after version.json args, like AstralRinth) int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096; command.add("-Xmx" + ramMB + "M"); @@ -147,9 +138,9 @@ public class LaunchCommandBuilder { } command.add(mainClass); - // Parse ALL version.json game args (merged with parent) with placeholder - // substitution. This includes --launchTarget, --fml.forgeVersion, - // --fml.mcVersion, --version, --gameDir, --assetsDir, etc. + // Parse version.json game args (merged with parent) with placeholder + // substitution. Parent provides --username, --version, etc.; + // child provides --launchTarget, --fml.*. List allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>(); if (!allGameArgs.isEmpty()) { for (String arg : allGameArgs) { diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html index bce8137..2dd3787 100644 --- a/launcher/launcher/src/resources/ui/index.html +++ b/launcher/launcher/src/resources/ui/index.html @@ -27,7 +27,7 @@

ZernMC

-

Launcher 1.0.9

+

Launcher 1.0.11