Слияние ui -> main #1

Merged
sasheg merged 145 commits from ui into main 2026-08-20 13:54:02 +00:00
9 changed files with 405 additions and 104 deletions
Showing only changes of commit 7d30caba74 - Show all commits
@@ -32,6 +32,7 @@ public class Bootstrap {
private static final String JAR_NAME = "zernmclauncher.jar";
private static final String BASE_URL = "https://api.zernmc.ru";
private static List<String> MIRRORS = new ArrayList<>();
private static final List<String> stagedSelfUpdates = new ArrayList<>();
private static volatile boolean jfxChildExiting = false;
private static Path baseDir;
@@ -86,10 +87,15 @@ public class Bootstrap {
downloadUpdate(serverVersion);
} else {
log("Version is up to date");
if (hasMissingFiles(serverVersion)) {
log("Repairing missing files...");
downloadUpdate(serverVersion);
}
}
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
log("Shutdown signal received...");
applyStagedSelfUpdates();
}));
if (ui != null) {
@@ -100,6 +106,12 @@ public class Bootstrap {
ui.close();
}
if (!stagedSelfUpdates.isEmpty()) {
notifyRestartRequired();
System.exit(0);
return;
}
launchMain(argList.toArray(new String[0]));
}
@@ -188,7 +200,12 @@ public class Bootstrap {
log("JFX process exited with code: " + code);
if (code != 0 && !GraphicsEnvironment.isHeadless()) {
if (code == 240) {
// Deliberate exit requested for applying a staged self-update.
// Run the shutdown hook (swaps the .update file via the detached
// helper) and exit silently - this is not a crash.
System.exit(240);
} else if (code != 0 && !GraphicsEnvironment.isHeadless()) {
String stderr = errorOutput.toString();
SwingUtilities.invokeLater(() -> showCrashDialog(code, stderr));
} else {
@@ -327,8 +344,7 @@ public class Bootstrap {
int downloaded = 0;
int skipped = 0;
int failed = 0;
String selfName = getSelfFileName();
int staged = 0;
for (Map.Entry<String, FileMeta> entry : serverFiles.entrySet()) {
String filePath = entry.getKey();
@@ -342,13 +358,6 @@ public class Bootstrap {
continue;
}
// Skip self-update (can't overwrite running executable)
if (selfName != null && (filePath.equalsIgnoreCase(selfName) || filePath.endsWith("/" + selfName))) {
log("Skipping self-update: " + filePath + " (file in use)");
skipped++;
continue;
}
if (localHash != null) {
log("Updating: " + filePath);
} else {
@@ -356,6 +365,24 @@ public class Bootstrap {
}
try {
// Self-update: a running .exe cannot be overwritten on Windows,
// so download to a temp name and swap it after exit.
if (isSelfExe(filePath)) {
downloadFile(newVersion, filePath, serverMeta.size, filePath + ".update");
Path updatePath = baseDir.resolve(filePath + ".update");
Path targetPath = baseDir.resolve(filePath);
try {
Files.move(updatePath, targetPath, StandardCopyOption.REPLACE_EXISTING);
log("Self-update applied: " + filePath);
} catch (Exception e) {
stagedSelfUpdates.add(filePath);
staged++;
log("Self-update staged (applied on exit): " + filePath);
}
downloaded++;
continue;
}
downloadFile(newVersion, filePath, serverMeta.size);
downloaded++;
} catch (Exception e) {
@@ -364,22 +391,86 @@ public class Bootstrap {
}
}
log("Updated files: " + downloaded + ", skipped: " + skipped + ", failed: " + failed);
String summary = "Updated files: " + downloaded + ", skipped: " + skipped + ", failed: " + failed;
if (staged > 0) summary += ", staged self-update: " + staged;
log(summary);
log("Updated to v" + newVersion);
}
private static String getSelfFileName() {
try {
String classPath = Bootstrap.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
if (classPath != null) {
String fn = Paths.get(classPath).getFileName().toString();
// If running from a JAR, the exe has the same stem
if (fn.endsWith(".jar")) {
return fn.replace(".jar", ".exe");
}
private static boolean isSelfExe(String filePath) {
String lower = filePath.toLowerCase().replace("\\", "/");
return lower.equals("zernmc.exe") || lower.equals("zernmc-cli.exe")
|| lower.endsWith("/zernmc.exe") || lower.endsWith("/zernmc-cli.exe");
}
/**
* Checks whether any file from the current version's meta is missing
* locally (e.g. a deleted zernmc.exe). Only presence is checked, no
* hashing, so this stays cheap on every launch.
*/
private static boolean hasMissingFiles(String version) {
Map<String, FileMeta> serverFiles = fetchServerMeta(version);
if (serverFiles.isEmpty()) {
log("Warning: Could not fetch meta for repair check");
return false;
}
for (String filePath : serverFiles.keySet()) {
if (!Files.exists(baseDir.resolve(filePath))) {
log("Missing file: " + filePath);
return true;
}
} catch (Exception ignored) {}
return null;
}
return false;
}
/**
* Applies self-updates that were staged because the target .exe was
* locked (the launcher itself was running). A detached helper waits a
* few seconds for this process to fully exit, then swaps the file.
*/
private static void applyStagedSelfUpdates() {
String os = System.getProperty("os.name", "").toLowerCase();
if (!os.contains("win") || stagedSelfUpdates.isEmpty()) return;
for (String filePath : stagedSelfUpdates) {
String targetName = new File(filePath).getName();
String updateName = targetName + ".update";
Path updatePath = baseDir.resolve(updateName);
if (!Files.exists(updatePath)) continue;
Path bat = baseDir.resolve("apply-update-" + System.currentTimeMillis() + ".cmd");
try {
String content = "@echo off\r\n"
+ "ping -n 4 127.0.0.1 > nul\r\n"
+ "move /y \"" + updatePath.toAbsolutePath() + "\" \"" + baseDir.resolve(targetName).toAbsolutePath() + "\" > nul\r\n"
+ "del /q \"%~f0\"\r\n";
Files.writeString(bat, content, StandardCharsets.UTF_8);
log("Applying staged self-update after exit: " + targetName);
new ProcessBuilder("cmd.exe", "/c", "start", "", "/min", bat.toAbsolutePath().toString()).start();
} catch (Exception e) {
log("Warning: Could not stage self-update for " + targetName + " - " + e.getMessage());
}
}
}
/**
* Informs the user that a staged self-update was downloaded and needs a
* launcher restart to take effect, then lets the JVM exit so the
* detached helper can swap the .exe.
*/
private static void notifyRestartRequired() {
String message = "Обновление лаунчера загружено.\n"
+ "Перезапустите лаунчер для применения изменений в обновлении.";
log("Self-update staged - restart required to apply changes");
if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
try {
SwingUtilities.invokeAndWait(() ->
JOptionPane.showMessageDialog(null, message,
"ZernMC Launcher", JOptionPane.INFORMATION_MESSAGE));
} catch (Exception e) {
log("Warning: could not show restart dialog: " + e.getMessage());
}
}
}
private static Map<String, FileMeta> fetchServerMeta(String version) {
@@ -447,6 +538,10 @@ public class Bootstrap {
}
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception {
downloadFile(version, filePath, expectedSize, filePath);
}
private static void downloadFile(String version, String filePath, long expectedSize, String saveAs) throws Exception {
List<String> servers = new ArrayList<>();
if (isServerReachable(BASE_URL)) servers.add(BASE_URL);
servers.addAll(MIRRORS);
@@ -455,17 +550,17 @@ public class Bootstrap {
Exception lastError = null;
for (String server : servers) {
try {
downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, saveAs);
return;
} catch (Exception e) {
lastError = e;
}
}
downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, saveAs);
}
private static void downloadFileFromServer(String urlStr, long expectedSize, String fileName) throws Exception {
private static void downloadFileFromServer(String urlStr, long expectedSize, String saveAs) throws Exception {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000);
@@ -479,13 +574,13 @@ public class Bootstrap {
expectedSize = conn.getContentLengthLong();
}
Path outPath = baseDir.resolve(fileName);
Path outPath = baseDir.resolve(saveAs);
Files.createDirectories(outPath.getParent());
long downloaded = 0;
long lastUpdate = 0;
long startTime = System.currentTimeMillis();
setTitle("Downloading " + fileName);
setTitle("Downloading " + new File(saveAs).getName());
try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(outPath.toFile())) {
+9
View File
@@ -99,6 +99,15 @@
</dependencies>
<build>
<resources>
<resource>
<directory>src/resources</directory>
<targetPath>assets</targetPath>
<includes>
<include>ui/**</include>
</includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -67,6 +67,7 @@ public class LaunchService {
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
long t0 = System.currentTimeMillis();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions();
options.setUsername(AuthManager.getUsername());
@@ -74,9 +75,11 @@ public class LaunchService {
options.setUuid(AuthManager.getUuid());
List<String> command = builder.build(options);
LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms");
LauncherLogger.info("Generated command for " + instanceName + ":");
command.forEach(arg -> LauncherLogger.debug(" " + arg));
long t1 = System.currentTimeMillis();
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile());
processBuilder.redirectErrorStream(true);
@@ -87,6 +90,7 @@ public class LaunchService {
Process process = processBuilder.start();
long pid = process.pid();
LauncherLogger.info("Launch: process started in " + (System.currentTimeMillis() - t1) + " ms (total " + (System.currentTimeMillis() - t0) + " ms), pid=" + pid);
runningProcesses.put(pid, process);
LauncherLogger.info("Process started, pid=" + pid);
@@ -113,10 +117,25 @@ public class LaunchService {
logReader.setDaemon(true);
logReader.start();
process.onExit().thenRun(() -> {
runningProcesses.remove(pid);
JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
});
// Watch the child on a DAEMON thread via Process.waitFor() instead of
// process.onExit().thenRun(...): Process.onExit() completes its action on
// the common ForkJoinPool, whose worker threads are non-daemon and keep
// the launcher JVM alive on exit (the v1.0.14.2 "JFX hang (ForkJoinPool)"
// regression, re-introduced in v1.0.14.3's sendBounded). A daemon watcher
// does not prevent JVM shutdown and avoids pinning the common pool.
Thread processWatcher = new Thread(() -> {
try {
int exitCode = process.waitFor();
runningProcesses.remove(pid);
JFXLauncher.appendGameLog("[Minecraft exited with code: " + exitCode + "]");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
JFXLauncher.appendGameLog("[Error watching process: " + e.getMessage() + "]");
}
}, "ProcessWatcher-" + instanceName);
processWatcher.setDaemon(true);
processWatcher.start();
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
return ApiResponse.success(info);
@@ -64,35 +64,49 @@ public class LaunchCommandBuilder {
// instead of "_1._20._1" (automatic module from filename 1.20.1.jar).
ensureVersionJarForForge();
// Build classpath from manifest libraries + client jar
String classpath;
if (manifest != null) {
classpath = buildClasspathFromManifest(manifest, false);
} else {
classpath = "";
}
// Add client jar to classpath (first position)
Path clientJar = findVersionJar();
if (clientJar != null && isValidJar(clientJar)) {
String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
classpath = clientJar.toAbsolutePath().toString() + (classpath.isEmpty() ? "" : sep + classpath);
System.out.println(ZAnsi.green(" Added client jar: " + clientJar.getFileName()));
} else {
System.out.println(ZAnsi.yellow(" Client jar not found, falling back to vanilla classpath"));
classpath = buildVanillaClasspath();
}
String cpFile = writeClasspathFile(classpath);
// Build variable map for placeholder substitution
Map<String, String> vars = buildVariableMap(options);
vars.put("classpath", cpFile);
// 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.
// Parse version.json JVM args (child only) and game args (merged with parent).
// 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<String> allJvmArgs = manifest != null ? manifest.getJvmArguments() : new ArrayList<>();
List<String> allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>();
// Build variable map for placeholder substitution (${classpath},
// ${natives_directory}, ${library_directory}, ${version_name}, ...)
Map<String, String> vars = buildVariableMap(options);
// Forge/NeoForge version.json defines its own -p / --module-path /
// --add-modules / --add-opens and references ${classpath}. We substitute
// ${classpath} once (only when referenced) and let the version.json
// arguments drive the classpath/module path exactly as AstralRinth and the
// 1.0.13 launcher did. Do NOT inject a separate -DlegacyClassPath:
// modern Forge's BootstrapLauncher derives the MC-BOOTSTRAP layer from the
// module path, and a redundant -DlegacyClassPath duplicates every library
// and can trigger split-package / duplicate-entry failures.
boolean needsClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs);
if (needsClasspath) {
String classpath;
if (manifest != null) {
classpath = buildClasspathFromManifest(manifest, false);
} else {
classpath = "";
}
Path clientJar = findVersionJar();
if (clientJar != null && isValidJar(clientJar)) {
String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
classpath = clientJar.toAbsolutePath().toString() + (classpath.isEmpty() ? "" : sep + classpath);
System.out.println(ZAnsi.green(" Added client jar: " + clientJar.getFileName()));
} else {
System.out.println(ZAnsi.yellow(" Client jar not found, falling back to vanilla classpath"));
classpath = buildVanillaClasspath();
}
vars.put("classpath", writeClasspathFile(classpath));
System.out.println(ZAnsi.green(" ${classpath} referenced, classpath built for substitution"));
} else {
System.out.println(ZAnsi.cyan(" ${classpath} not referenced by version.json, skipping classpath build"));
}
if (!allJvmArgs.isEmpty()) {
for (String arg : allJvmArgs) {
String resolved = resolveVariable(arg, vars);
@@ -141,7 +155,6 @@ public class LaunchCommandBuilder {
// Parse version.json game args (merged with parent) with placeholder
// substitution. Parent provides --username, --version, etc.;
// child provides --launchTarget, --fml.*.
List<String> allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>();
if (!allGameArgs.isEmpty()) {
for (String arg : allGameArgs) {
String resolved = resolveVariable(arg, vars);
@@ -503,7 +516,7 @@ public class LaunchCommandBuilder {
Path nativesDir = gameDir.resolve("natives");
Path librariesDir = gameDir.resolve("libraries");
vars.put("version_name", instance.getName());
vars.put("version_name", getVersionId());
vars.put("game_directory", gameDir.toString());
vars.put("assets_root", assetsDir.toString());
vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion());
@@ -543,6 +556,14 @@ public class LaunchCommandBuilder {
return result;
}
private boolean referencesClasspath(List<String> args) {
if (args == null) return false;
for (String arg : args) {
if (arg != null && arg.contains("${classpath}")) return true;
}
return false;
}
private String buildClasspathFromManifest(VersionManifest manifest, boolean includeVersionJar) throws Exception {
List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries");
@@ -186,7 +186,7 @@ public class JFXLauncher extends Application {
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[JFX] Shutdown hook triggered");
LauncherLogger.info("[JFX] Shutdown signal received...");
LaunchService.killAllProcesses();
}));
launch(args);
@@ -348,14 +348,16 @@ public class JFXLauncher extends Application {
log("No saved session found, will show login screen");
}
log("Starting background network init...");
new Thread(() -> {
Thread netInitThread = new Thread(() -> {
try {
ZHttpClient.checkAllServicesOnStartup();
log("Network init complete");
} catch (Exception e) {
log("Network init warning: " + e.getMessage());
}
}, "network-init").start();
}, "network-init");
netInitThread.setDaemon(true);
netInitThread.start();
startServer();
WebView webView = new WebView();
@@ -441,11 +443,17 @@ public class JFXLauncher extends Application {
log("Window displayed");
stage.setOnCloseRequest(e -> {
log("Closing...");
LaunchService.killAllProcesses();
if (server != null) server.stop(0);
log("Shutdown signal received...");
shutdown();
Platform.exit();
System.exit(0);
Thread watchdog = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException ignored) {}
System.exit(0);
}, "force-exit");
watchdog.setDaemon(true);
watchdog.start();
});
} catch (Exception e) {
@@ -508,7 +516,11 @@ public class JFXLauncher extends Application {
server.createContext("/api/admin", this::handleAdmin);
server.createContext("/assets/", this::handleStatic);
server.setExecutor(Executors.newCachedThreadPool());
server.setExecutor(Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "http-server-" + server.getAddress().getPort());
t.setDaemon(true);
return t;
}));
server.start();
log("HTTP server on port " + PORT);
@@ -814,6 +826,7 @@ 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"));
@@ -1194,28 +1207,43 @@ public class JFXLauncher extends Application {
private void handleShutdown(HttpExchange exchange) {
log("Shutdown request received...");
LaunchService.killAllProcesses();
if (server != null) server.stop(0);
Platform.exit();
shutdown();
System.exit(0);
}
private void handleExit(HttpExchange exchange) {
log("Exiting...");
LaunchService.killAllProcesses();
if (mainStage != null) mainStage.close();
Platform.exit();
shutdown();
System.exit(0);
}
private void handleExitParent(HttpExchange exchange) {
log("Terminating parent process...");
LaunchService.killAllProcesses();
if (mainStage != null) mainStage.close();
Platform.exit();
shutdown();
System.exit(240);
}
/**
* Releases launcher resources on shutdown: kills any running game
* processes and stops the embedded HTTP server.
*/
private void shutdown() {
LaunchService.killAllProcesses();
if (server != null) {
server.stop(0);
java.util.concurrent.ExecutorService exec =
(java.util.concurrent.ExecutorService) server.getExecutor();
if (exec != null) exec.shutdownNow();
}
}
@Override
public void stop() {
LauncherLogger.info("[JFX] Application stop() called, releasing resources...");
shutdown();
LauncherLogger.info("[JFX] Clean shutdown complete");
}
private void handleStatic(HttpExchange exchange) {
try {
String path = exchange.getRequestURI().getPath();
@@ -1232,15 +1260,31 @@ public class JFXLauncher extends Application {
return;
}
if (!Files.exists(file) || Files.isDirectory(file)) {
log("[UI] File not found: " + file);
byte[] content = null;
String source = null;
if (Files.exists(file) && !Files.isDirectory(file)) {
content = Files.readAllBytes(file);
source = "disk";
} else {
// Fallback to the UI bundled inside the launcher jar, so the
// UI never breaks when the on-disk assets are missing/stale.
try (InputStream in = getClass().getResourceAsStream("/" + relativePath)) {
if (in != null) {
content = in.readAllBytes();
source = "jar";
}
}
}
if (content == null) {
log("[UI] File not found: " + file + " (not bundled in jar either)");
exchange.sendResponseHeaders(404, 0);
exchange.close();
return;
}
byte[] content = Files.readAllBytes(file);
log("[UI] Loaded " + content.length + " bytes: " + path);
log("[UI] Loaded " + content.length + " bytes from " + source + ": " + path);
String ct = getContentType(path);
if (ct.startsWith("text/")) {
@@ -20,8 +20,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
@@ -30,7 +28,6 @@ public class ZHttpClient {
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.version(HttpClient.Version.HTTP_1_1)
.executor(Executors.newCachedThreadPool())
.build();
private static String BASE_URL = "https://api.zernmc.ru";
@@ -303,20 +300,19 @@ public class ZHttpClient {
}
}
/**
* Synchronous HTTP send with a hard timeout. Uses client.send() (blocking on the
* calling thread) rather than sendAsync(...).get(...) — the latter submits work
* to the HttpClient's executor and, when that was a non-daemon CachedThreadPool,
* kept the JVM alive / could starve the common ForkJoinPool (the v1.0.14.2
* "JFX hang (ForkJoinPool)" regression, re-introduced in v1.0.14.3).
* The request-level .timeout(Duration) bounds the actual network I/O.
*/
private static <T> HttpResponse<T> sendBounded(HttpRequest request, HttpResponse.BodyHandler<T> handler, long timeoutSeconds) throws IOException, InterruptedException {
try {
return client.sendAsync(request, handler).get(timeoutSeconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause() != null ? e.getCause() : e;
if (cause instanceof IOException) throw (IOException) cause;
if (cause instanceof InterruptedException) throw (InterruptedException) cause;
throw new IOException(cause);
} catch (java.util.concurrent.TimeoutException e) {
throw new IOException("Request timed out after " + timeoutSeconds + "s", e);
}
HttpRequest timed = request.newBuilder()
.timeout(Duration.ofSeconds(timeoutSeconds))
.build();
return client.send(timed, handler);
}
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
@@ -495,7 +495,8 @@ class ZernMCLauncher {
}
if (typeof fetch !== 'undefined') {
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 15000);
var timeoutMs = opts.timeout || 15000;
var timeoutId = setTimeout(function() { controller.abort(); }, timeoutMs);
try {
var r = await fetch(API + endpoint, {
method: opts.method || 'GET',
@@ -522,7 +523,7 @@ class ZernMCLauncher {
};
xhr.onerror = function() { resolve({ success: false, error: 'Network error' }); };
xhr.ontimeout = function() { resolve({ success: false, error: 'Request timeout' }); };
xhr.timeout = 30000;
xhr.timeout = opts.timeout || 30000;
xhr.send(opts.body || null);
});
} catch (e) {
@@ -1545,7 +1546,7 @@ class ZernMCLauncher {
return;
}
this.toast(tr('toast.launching', null, {name: inst.name}), 'info');
const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }) });
const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }), timeout: 120000 });
if (r.success) {
this.toast(tr('toast.launched', null, {pid: String(r.data?.pid || '')}), 'success');
this.startPlaytimeTracking(inst.name);
@@ -1558,7 +1559,7 @@ class ZernMCLauncher {
const inst = this.state.selectedPack;
if (!inst) return;
this.setPlayBtnText('playBar.updating', true);
const r = await this.req('/update', { method: 'POST', body: JSON.stringify({ name: inst.name }) });
const r = await this.req('/update', { method: 'POST', body: JSON.stringify({ name: inst.name }), timeout: 120000 });
if (r.success) {
this.toast(tr('toast.updated', null, {name: inst.name, version: String(r.data?.version || '')}), 'success');
if (this.state.updateMap) {
@@ -272,6 +272,122 @@ class LaunchCommandBuilderTest {
"Classpath must include vanilla client library");
}
@Test
void forge_noClasspathRef_buildsLegacyClassPath() throws Exception {
// Regression guard for the 1.0.14.x regression: the launcher must NOT inject the
// self-invented `-DlegacyClassPath` flag. Modern Forge/NeoForge derive the MC-BOOTSTRAP
// module layer from the module path / modlauncher classpath (the version.json `-p
// ${classpath}` placeholder, resolved by the launcher), NOT from `-DlegacyClassPath`
// (a 1.12-era mechanism that, when duplicated here, causes split-package / duplicate-
// entry failures and prevents the game from starting). This matches the 1.0.13
// behaviour and AstralRinth's real Forge launch, where the classpath is supplied
// through `${classpath}` substitution only.
String mcVer = "1.20.1";
String forgeVer = "47.3.0";
String versionId = mcVer + "-forge-" + forgeVer;
String vanillaJson = """
{
"id": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {"game": ["--username", "${auth_player_name}"]},
"assetIndex": {"id": "1.20.1"},
"libraries": [
{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}},
{"name": "com.google.guava:guava:31.1-jre", "downloads": {"artifact": {"path": "com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"}}}
]
}
""";
writeVersionJson(tempDir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
createJar(tempDir.resolve("versions/1.20.1/1.20.1.jar"));
createJar(tempDir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
createJar(tempDir.resolve("libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"));
// Realistic Forge child: references ${classpath} (serves as module path + classpath,
// exactly like AstralRinth / 1.0.13), plus the standard Forge bootstrap args.
String forgeJson = """
{
"id": "1.20.1-forge-47.3.0",
"inheritsFrom": "1.20.1",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"arguments": {
"jvm": ["-DlibraryDirectory=${library_directory}",
"-DignoreList=bootstraplauncher,forge-,${version_name}.jar",
"-p", "${classpath}",
"--add-modules", "ALL-MODULE-PATH"],
"game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0",
"--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge"]
},
"libraries": [
{"name": "cpw.mods:modlauncher:10.0.9", "downloads": {"artifact": {"path": "cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar"}}},
{"name": "net.minecraftforge:fmlloader:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/fmlloader/1.20.1-47.3.0/fmlloader-1.20.1-47.3.0.jar"}}}
]
}
""";
writeVersionJson(tempDir.resolve("versions/" + versionId + "/" + versionId + ".json"), forgeJson);
Files.createDirectories(tempDir.resolve("natives"));
createJar(tempDir.resolve("libraries/cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar"));
createJar(tempDir.resolve("libraries/net/minecraftforge/fmlloader/1.20.1-47.3.0/fmlloader-1.20.1-47.3.0.jar"));
Instance instance = new Instance("test-forge-realjson", tempDir);
instance.setMinecraftVersion(mcVer);
instance.setLoaderType("forge");
instance.setLoaderVersion(forgeVer);
instance.setAssetIndex(mcVer);
LaunchOptions options = new LaunchOptions();
List<String> command = new LaunchCommandBuilder(instance).build(options);
// No self-invented -DlegacyClassPath must ever be emitted for Forge.
assertFalse(command.stream().anyMatch(a -> a != null && a.startsWith("-DlegacyClassPath=")),
"Forge must NOT have a -DlegacyClassPath (1.0.13/AstralRinth rely on ${classpath})");
// ${classpath} must have been substituted into the -p module path, carrying all libs.
int pIdx = command.indexOf("-p");
assertTrue(pIdx >= 0, "Forge must have -p");
assertTrue(pIdx + 1 < command.size(), "-p must have a value");
String modulePath = command.get(pIdx + 1);
assertNotNull(modulePath, "-p value must not be null");
assertTrue(modulePath.contains("modlauncher-10.0.9.jar"),
"-p (resolved classpath) must contain modlauncher, got: " + modulePath);
assertTrue(modulePath.contains("fmlloader-1.20.1-47.3.0.jar"),
"-p (resolved classpath) must contain forge libraries, got: " + modulePath);
// version_name resolves to the version id (not the instance name) in -DignoreList.
String ignoreList = null;
for (String arg : command) {
if (arg != null && arg.startsWith("-DignoreList=")) ignoreList = arg;
}
assertNotNull(ignoreList, "Must have -DignoreList");
assertTrue(ignoreList.contains("1.20.1-forge-47.3.0.jar"),
"-DignoreList must use version id, got: " + ignoreList);
// --add-modules ALL-MODULE-PATH as two args (forge format)
int amIdx = command.indexOf("--add-modules");
assertTrue(amIdx >= 0, "Must have --add-modules");
assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1));
// Main class + game args
assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"));
assertTrue(command.contains("--launchTarget"));
}
@Test
void forge_versionNameIsVersionIdNotInstanceName() throws Exception {
Instance instance = createForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
List<String> command = new LaunchCommandBuilder(instance).build(options);
String ignoreList = null;
for (String arg : command) {
if (arg != null && arg.startsWith("-DignoreList=")) ignoreList = arg;
}
if (ignoreList != null) {
assertTrue(ignoreList.contains("1.20.1-forge-47.3.0.jar"),
"-DignoreList must use version id, got: " + ignoreList);
}
}
// ================================================================
// NeoForge tests
// ================================================================
+2 -2
View File
@@ -18,8 +18,8 @@
</modules>
<properties>
<revision>1.0.14</revision>
<hotfix>5</hotfix>
<revision>1.0.15</revision>
<hotfix>0</hotfix>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>