Слияние ui -> main #1
@@ -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())) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+23
-4
@@ -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);
|
||||
|
||||
+51
-30
@@ -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) {
|
||||
|
||||
+116
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user