diff --git a/.gitignore b/.gitignore index 6794f1e21d9..5db70adf139 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ language-subtag-registry /HMCL/build/ /HMCLCore/build/ /HMCLBoot/build/ +/HMCLMonitor/build/ /minecraft/libraries/HMCLTransformerDiscoveryService/build/ /minecraft/libraries/HMCLMultiMCBootstrap/build/ /buildSrc/build/ @@ -42,6 +43,7 @@ language-subtag-registry /out/ /HMCL/out/ /HMCLCore/out/ +/HMCLMonitor/out/ /minecraft/libraries/HMCLTransformerDiscoveryService/out/ /minecraft/libraries/HMCLMultiMCBootstrap/out/ @@ -50,6 +52,7 @@ language-subtag-registry /HMCL/bin/ /HMCLBoot/bin/ /HMCLCore/bin/ +/HMCLMonitor/bin/ /minecraft/libraries/HMCLTransformerDiscoveryService/bin/ /minecraft/libraries/HMCLMultiMCBootstrap/bin/ .classpath diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index dfc63b06094..88d36dc91b1 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -59,6 +59,7 @@ val embedResources = configurations.register("embedResources") dependencies { implementation(project(":HMCLCore")) implementation(project(":HMCLBoot")) + implementation(project(":HMCLMonitor")) implementation("libs:JFoenix") implementation(libs.jwebp) implementation(libs.fxsvgimage) @@ -212,6 +213,7 @@ tasks.shadowJar { exclude(dependency("net.java.dev.jna:jna:.*")) exclude(dependency("libs:JFoenix:.*")) exclude(project(":HMCLBoot")) + exclude(project(":HMCLMonitor")) } manifest.attributes( diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java index 3ba178e2599..05116c39937 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.SelfDependencyPatcher; import org.jackhuang.hmcl.util.SwingUtils; +import org.jackhuang.hmcl.monitor.MonitorSupervisor; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; @@ -50,6 +51,11 @@ public static void main(String[] args) { createHMCLDirectories(); LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs")); + // start HMCL monitor before JavaFX initialized + if (MonitorSupervisor.processArguments(args)) { + exit(0); + } + checkWine(); if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index 229a48a363d..4a753bd4b76 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -34,7 +34,9 @@ import javafx.stage.Screen; import javafx.stage.Stage; import javafx.util.Duration; +import org.jackhuang.hmcl.monitor.MonitorCrashReporter; import org.jackhuang.hmcl.game.HMCLCacheRepository; +import org.jackhuang.hmcl.monitor.MonitorSupervisor; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.AsyncTaskExecutor; import org.jackhuang.hmcl.task.Schedulers; @@ -145,6 +147,10 @@ public void start(Stage primaryStage) { UpdateChecker.init(); primaryStage.show(); + + // check whether to show log from HMCL monitor + if (MonitorSupervisor.isCrashed()) + MonitorCrashReporter.show(); }); } catch (Throwable e) { CRASH_REPORTER.uncaughtException(Thread.currentThread(), e); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index e13cd3d5d31..97b8af730e6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -21,16 +21,21 @@ import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.launch.DefaultLauncher; import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.monitor.MonitorClient; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.LauncherVisibility; import org.jackhuang.hmcl.util.NativePatcher; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.CommandBuilder; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.Charset; import java.nio.file.*; import java.util.*; import java.util.stream.Stream; @@ -154,11 +159,41 @@ private static String normalizedLanguageTag(Locale locale, GameVersionNumber gam } @Override - public ManagedProcess launch() throws IOException, InterruptedException { + public ProcessInfo launch() throws IOException, InterruptedException { generateOptionsTxt(); return super.launch(); } + @Override + protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Path nativeFolder) throws IOException { + if (!MonitorClient.isEnabled()) + return super.startProcess(builder, encoding, nativeFolder); + + String gameDirectoryId = null; + MonitorClient.RelaunchPolicy relaunchPolicy = MonitorClient.RelaunchPolicy.NEVER; + if (instance instanceof HMCLGameInstance hmclGameInstance) { + gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); + + GameSettings.Effective setting = hmclGameInstance.getEffectiveSettings(); + LauncherVisibility x = setting.getInheritable(GameSettings::launcherVisibilityProperty); + boolean showLogs = setting.getInheritable(GameSettings::showLogsProperty); + relaunchPolicy = showLogs + ? MonitorClient.RelaunchPolicy.NEVER + : switch (x) { + case HIDE_AND_REOPEN -> MonitorClient.RelaunchPolicy.ALWAYS; + case HIDE -> MonitorClient.RelaunchPolicy.ON_CRASH; + default -> MonitorClient.RelaunchPolicy.NEVER; + }; + } + + List postExitCommand = null; + if (StringUtils.isNotBlank(options.getPostExitCommand())) + postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder)); + + return MonitorClient.launchNewMonitor(new MonitorClient.MonitorLaunchContext(builder, listener, + encoding, options, gameDirectoryId, postExitCommand, relaunchPolicy)); + } + @Override public void makeLaunchScript(Path scriptFile) throws IOException { generateOptionsTxt(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index a1ce26124e2..ba52f316431 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.monitor.MonitorClient; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.game.*; @@ -836,7 +837,7 @@ private final class HMCLProcessListener implements ProcessListener { private final ReentrantLock lock = new ReentrantLock(); private final LaunchOptions launchOptions; - private ManagedProcess process; + private ProcessInfo process; private volatile boolean lwjgl; private LogWindow logWindow; private final boolean detectWindow; @@ -855,7 +856,7 @@ public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, Count } @Override - public void setProcess(ManagedProcess process) { + public void setProcess(ProcessInfo process) { this.process = process; String command = new CommandBuilder().addAll(process.getCommands()).toString(); @@ -993,7 +994,14 @@ public void onLog(String log, boolean isErrorStream) { try { if (!lwjgl) { lwjgl = true; - finishLaunch(); + if (MonitorClient.isEnabled() && !showLogs + && (launcherVisibility == LauncherVisibility.HIDE + || launcherVisibility == LauncherVisibility.HIDE_AND_REOPEN)) { + launchingLatch.countDown(); + runLater(Launcher::stopApplication); + } else { + finishLaunch(); + } } } finally { lock.unlock(); @@ -1040,11 +1048,11 @@ public void onExit(int exitCode, ExitType exitType) { } - private static final Queue> PROCESSES = new ConcurrentLinkedQueue<>(); + private static final Queue> PROCESSES = new ConcurrentLinkedQueue<>(); public static int countMangedProcesses() { PROCESSES.removeIf(it -> { - ManagedProcess process = it.get(); + ProcessInfo process = it.get(); return process == null || !process.isRunning(); }); return PROCESSES.size(); @@ -1052,6 +1060,6 @@ public static int countMangedProcesses() { public static void stopManagedProcesses() { while (!PROCESSES.isEmpty()) - Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ManagedProcess::stop); + Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ProcessInfo::stop); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java new file mode 100644 index 00000000000..26e4ca263ba --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -0,0 +1,112 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; +import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.Log; +import org.jackhuang.hmcl.setting.GameDirectory; +import org.jackhuang.hmcl.setting.GameDirectoryID; +import org.jackhuang.hmcl.setting.GameDirectoryManager; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.GameCrashWindow; +import org.jackhuang.hmcl.util.CircularArrayList; +import org.jackhuang.hmcl.util.Log4jLevel; +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Presents a supervised game's crash window from the result retained in the monitor JVM after +/// that JVM continues into the launcher UI. +@NotNullByDefault +public final class MonitorCrashReporter { + + private MonitorCrashReporter() { + } + + public static void show() { + ResultSpec result = MonitorSupervisor.getResultSpec(); + if (result == null || result.gameDirectoryId() == null) + return; + + // find repository + GameDirectoryID id = GameDirectoryID.parse(result.gameDirectoryId()); + Optional match = GameDirectoryManager.getGameDirectories().stream() + .filter(directory -> directory.getId().equals(id)) + .findFirst(); + if (match.isEmpty()) + return; + GameDirectoryManager.setSelectedGameDirectory(match.get()); + HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); + + GameInstanceID instanceId = result.launchOptions().getInstanceId(); + HMCLGameInstance instance = repository.findInstance(instanceId); + + if (instance != null) { // instance found + presentCrashWindow(result, instance); + return; + } + + LOG.info("Waiting for the game repository to load before showing the crash window"); + repository.refreshAsync().thenRunAsync(Schedulers.javafx(), () -> { + HMCLGameInstance loaded = repository.findInstance(instanceId); + if (loaded != null) + presentCrashWindow(result, loaded); + else // TODO 把这里的日志改短一点 + LOG.warning("Cannot resolve the launched instance " + instanceId + " (" + + repository.getSnapshot().getInstanceCount() + " instances, loaded=" + + repository.isLoaded() + ", base directory " + + repository.getLayout().getBaseDirectory() + + "), skip showing the crash window." + + " The session log is at " + result.logFile()); + }).start(); + } + + private static void presentCrashWindow(ResultSpec result, HMCLGameInstance instance) { + Task.supplyAsync(() -> { + // limit the lines of log + int limit = Log.getLogLines(); + CircularArrayList tail = new CircularArrayList<>(limit); + try (Stream lines = Files.lines(result.logFile(), MonitorProtocol.CHARSET)) { + lines.forEach(line -> { + if (tail.size() == limit) + tail.removeFirst(); + tail.addLast(line); + }); + } + + Files.deleteIfExists(result.logFile()); + return tail.stream() + .map(line -> new Log(line, Log4jLevel.guessLevel(line))) + .toList(); + }).whenComplete(Schedulers.javafx(), (logs, exception) -> { + if (exception != null) + LOG.warning("Failed to read the session log file " + result.logFile(), exception); + new GameCrashWindow(result.process(), result.exitType(), instance, Objects.requireNonNull(result.launchOptions()), + exception == null ? logs : List.of()).show(); + }).start(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index 380357070c9..d26fae33de1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -51,12 +51,10 @@ import org.jackhuang.hmcl.util.platform.*; import java.io.IOException; -import java.lang.management.ManagementFactory; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.FileTime; -import java.time.Instant; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -79,7 +77,7 @@ public class GameCrashWindow extends Stage { private final BooleanProperty loading = new SimpleBooleanProperty(); private final TextFlow feedbackTextFlow = new TextFlow(); - private final ManagedProcess managedProcess; + private final ProcessInfo managedProcess; private final ProcessListener.ExitType exitType; private final LaunchOptions launchOptions; private final View view; @@ -87,7 +85,7 @@ public class GameCrashWindow extends Stage { private final List logs; - public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, HMCLGameInstance gameInstance, LaunchOptions launchOptions, List logs) { + public GameCrashWindow(ProcessInfo managedProcess, ProcessListener.ExitType exitType, HMCLGameInstance gameInstance, LaunchOptions launchOptions, List logs) { Themes.applyNativeDarkMode(this); this.managedProcess = managedProcess; @@ -277,16 +275,7 @@ private CompletableFuture exportGameCrashInfo() { return CompletableFuture.supplyAsync(() -> logs.stream().map(Log::getLog).collect(Collectors.joining("\n"))) .thenComposeAsync(logs -> { - long processStartTime = managedProcess.getProcess().info() - .startInstant() - .map(Instant::toEpochMilli).orElseGet(() -> { - try { - return ManagementFactory.getRuntimeMXBean().getStartTime(); - } catch (Throwable e) { - LOG.warning("Failed to get process start time", e); - return 0L; - } - }); + long processStartTime = managedProcess.getProcessStartTime(); return LogExporter.exportLogs(logFile, gameInstance, launchOptions, logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString(), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java index 6ac9d2daf2b..86b2f877ec3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java @@ -42,7 +42,7 @@ import org.jackhuang.hmcl.util.CircularArrayList; import org.jackhuang.hmcl.util.Log4jLevel; import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jackhuang.hmcl.util.platform.SystemUtils; import java.io.IOException; @@ -79,13 +79,13 @@ public final class LogWindow extends Stage { } private final LogWindowImpl impl; - private final ManagedProcess gameProcess; + private final ProcessInfo gameProcess; - public LogWindow(ManagedProcess gameProcess) { + public LogWindow(ProcessInfo gameProcess) { this(gameProcess, new CircularArrayList<>()); } - public LogWindow(ManagedProcess gameProcess, CircularArrayList logs) { + public LogWindow(ProcessInfo gameProcess, CircularArrayList logs) { Themes.applyNativeDarkMode(this); this.logs = logs; @@ -235,7 +235,7 @@ private void onExportDump(SpinnerPane pane) { try { if (gameProcess.isRunning()) { - GameDumpGenerator.writeDumpTo(gameProcess.getProcess().pid(), dumpFile); + GameDumpGenerator.writeDumpTo(gameProcess.getPid(), dumpFile); FXUtils.showFileInExplorer(dumpFile); } } catch (Throwable e) { @@ -251,7 +251,7 @@ private void onExportDump(SpinnerPane pane) { }); } - private ManagedProcess getGameProcess() { + private ProcessInfo getGameProcess() { return gameProcess; } @@ -433,12 +433,16 @@ protected void updateItem(Log item, boolean empty) { clearButton.setOnAction(e -> getSkinnable().onClear()); hBox.getChildren().setAll(autoScrollCheckBox, wrapTextCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton); - control.getGameProcess().getProcess() - .onExit() - .thenRunAsync(() -> { - terminateButton.setDisable(true); - exportDumpButton.setDisable(true); - }, Schedulers.javafx()); + var handle = control.getGameProcess().getHandle(); + if (handle != null) { + handle.onExit().thenRunAsync(() -> { + terminateButton.setDisable(true); + exportDumpButton.setDisable(true); + }, Schedulers.javafx()); + } else { + terminateButton.setDisable(true); + exportDumpButton.setDisable(true); + } vbox.getChildren().add(bottom); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java index 9d2e23ac89c..e6d80b9a0e4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java @@ -23,7 +23,6 @@ import org.jackhuang.hmcl.EntryPoint; import org.jackhuang.hmcl.Main; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; @@ -35,10 +34,10 @@ import org.jackhuang.hmcl.util.SwingUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.io.IOException; -import java.lang.management.ManagementFactory; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -47,7 +46,6 @@ import java.util.concurrent.CountDownLatch; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; import static org.jackhuang.hmcl.ui.FXUtils.checkFxUserThread; import static org.jackhuang.hmcl.util.Lang.thread; @@ -198,54 +196,7 @@ private static void requestUpdate(Path updateTo, Path self) throws IOException { } public static void startJava(Path jar, String... appArgs) throws IOException { - List commandline = new ArrayList<>(); - commandline.add(JavaRuntime.getDefault().getBinary().toString()); - - try { - for (String inputArgument : ManagementFactory.getRuntimeMXBean().getInputArguments()) { - if (inputArgument.startsWith("-D") || inputArgument.startsWith("-X")) { - commandline.add(inputArgument); - } - } - } catch (Throwable ignored) { - // ManagementFactory not available - for (Map.Entry entry : System.getProperties().entrySet()) { - if (entry.getKey() instanceof String key && key.startsWith("hmcl.")) { - commandline.add("-D" + key + "=" + entry.getValue()); - } - } - } - - commandline.add("-jar"); - commandline.add(jar.toAbsolutePath().toString()); - commandline.addAll(Arrays.asList(appArgs)); - LOG.info("Starting process: " + maskCommandline(commandline)); - new ProcessBuilder(commandline) - .directory(Paths.get("").toAbsolutePath().toFile()) - .inheritIO() - .start(); - } - - private static String maskCommandline(List commandline) { - return commandline.stream().map(str -> { - if (str.startsWith("-D")) { - int eqIdx = str.indexOf('='); - if (eqIdx != -1) { - String key = str.substring(2, eqIdx); - String value = str.substring(eqIdx + 1); - if (key.contains("http.proxy") || - key.startsWith("https.proxy") || - key.startsWith("socksProxy") || - key.equals("hmcl.microsoft.auth.id") || - key.equals("hmcl.curseforge.apikey") - ) { - return "-D" + key + "=" + (value.isEmpty() ? "" : value.charAt(0) + "*".repeat(value.length() - 1)); - } - } - } - - return str; - }).collect(Collectors.joining(" ")); + JavaProcessLauncher.startJava(jar, appArgs); } private static Optional tryRename(Path path, String newVersion) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/JVMLaunchFailedEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/JVMLaunchFailedEvent.java index 4907b802865..c05d65eb5b1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/JVMLaunchFailedEvent.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/JVMLaunchFailedEvent.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.event; import org.jackhuang.hmcl.util.ToStringBuilder; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; /** * This event gets fired when we launch the JVM and it got crashed. @@ -29,7 +29,7 @@ */ public class JVMLaunchFailedEvent extends Event { - private final ManagedProcess process; + private final ProcessInfo process; /** * Constructor. @@ -37,12 +37,12 @@ public class JVMLaunchFailedEvent extends Event { * @param source {@link org.jackhuang.hmcl.launch.ExitWaiter} * @param process the crashed process. */ - public JVMLaunchFailedEvent(Object source, ManagedProcess process) { + public JVMLaunchFailedEvent(Object source, ProcessInfo process) { super(source); this.process = process; } - public ManagedProcess getProcess() { + public ProcessInfo getProcess() { return process; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessExitedAbnormallyEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessExitedAbnormallyEvent.java index c0931da977d..5b7ef89fafe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessExitedAbnormallyEvent.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessExitedAbnormallyEvent.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.event; import org.jackhuang.hmcl.util.ToStringBuilder; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; /** * This event gets fired when a JavaProcess exited abnormally and the exit code is not zero. @@ -29,7 +29,7 @@ */ public final class ProcessExitedAbnormallyEvent extends Event { - private final ManagedProcess process; + private final ProcessInfo process; /** * Constructor. @@ -37,12 +37,12 @@ public final class ProcessExitedAbnormallyEvent extends Event { * @param source {@link org.jackhuang.hmcl.launch.ExitWaiter} * @param process The process that exited abnormally. */ - public ProcessExitedAbnormallyEvent(Object source, ManagedProcess process) { + public ProcessExitedAbnormallyEvent(Object source, ProcessInfo process) { super(source); this.process = process; } - public ManagedProcess getProcess() { + public ProcessInfo getProcess() { return process; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessStoppedEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessStoppedEvent.java index 328dfe45e35..bcb899b5cd2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessStoppedEvent.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessStoppedEvent.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.event; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; /** * This event gets fired when minecraft process exited successfully and the exit code is 0. @@ -28,7 +28,7 @@ */ public class ProcessStoppedEvent extends Event { - private final ManagedProcess process; + private final ProcessInfo process; /** * Constructor. @@ -36,12 +36,12 @@ public class ProcessStoppedEvent extends Event { * @param source {@link org.jackhuang.hmcl.launch.ExitWaiter} * @param process minecraft process */ - public ProcessStoppedEvent(Object source, ManagedProcess process) { + public ProcessStoppedEvent(Object source, ProcessInfo process) { super(source); this.process = process; } - public ManagedProcess getProcess() { + public ProcessInfo getProcess() { return process; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 6725d4e318e..1c7aef2077e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -128,10 +128,9 @@ protected void flushPendingInstanceWrites() throws IOException { /// @throws IllegalStateException if a draft is active public void setBaseDirectory(Path baseDirectory) { checkNoActiveDraft("set base directory"); - // Mark unloaded before publishing so snapshot listeners do not treat the empty snapshot as ready. - this.loaded = false; DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); - publishSnapshot(initial); + // The empty snapshot is published as not loaded so snapshot listeners do not treat it as ready. + publishSnapshot(initial, false); } /// {@inheritDoc} @@ -171,6 +170,22 @@ protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { }); } + /// Publishes `newSnapshot` together with the repository's loaded state as one atomic step. + /// + /// Both are updated in one JavaFX-thread step, so snapshot listeners observe a ready + /// repository when the published state is loaded, and observers that see the new loaded state + /// also observe the new snapshot instead of the previous one. + /// + /// @param newSnapshot the snapshot to publish + /// @param loaded the loaded state to publish together with the snapshot + protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot, boolean loaded) { + newSnapshot.seal(); + runOnFxThreadAndWait(() -> { + this.loaded = loaded; + snapshot.set(newSnapshot); + }); + } + /// Publishes the immutable successor snapshot of the repository's active draft. /// /// @param draft the active draft @@ -299,9 +314,9 @@ private void refreshRepository() { } } - // Mark loaded before publishing so snapshot listeners observe a ready repository. - loaded = true; - publishSnapshot(newSnapshot); + // Publish the scanned snapshot as loaded; the flag must become visible together with the + // snapshot so FX-thread consumers never act on the loaded flag with a stale snapshot. + publishSnapshot(newSnapshot, true); } /// Loads one instance directory without renaming on-disk JSON or jar files. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 86d358044a2..f6258b35115 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -662,7 +662,7 @@ private Path getNativeFolder() { } @Override - public ManagedProcess launch() throws IOException, InterruptedException { + public ProcessInfo launch() throws IOException, InterruptedException { Path nativeFolder = getNativeFolder(); final Command command = generateCommandLine(nativeFolder); @@ -694,7 +694,6 @@ public ManagedProcess launch() throws IOException, InterruptedException { SystemUtils.callExternalProcess(builder); } - Process process; try { ProcessBuilder builder = new ProcessBuilder(rawCommandLine).directory(runDirectory.toFile()); if (listener == null) { @@ -704,18 +703,22 @@ public ManagedProcess launch() throws IOException, InterruptedException { if (appdata != null) builder.environment().put("APPDATA", appdata.toString()); builder.environment().putAll(getEnvVars(nativeFolder)); - process = builder.start(); + + return startProcess(builder, command.encoding, nativeFolder); } catch (IOException e) { throw new ProcessCreationException(e); } + } - ManagedProcess p = new ManagedProcess(process, rawCommandLine); + protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Path nativeFolder) throws IOException { + Process process = builder.start(); + ManagedProcess p = new ManagedProcess(process, builder.command()); if (listener != null) - startMonitors(p, nativeFolder, listener, command.encoding, daemon); + startMonitors(p, nativeFolder, listener, encoding, daemon); return p; } - private Map getEnvVars(Path nativeFolder) { + protected Map getEnvVars(Path nativeFolder) { String versionName = Optional.ofNullable(options.getVersionName()).orElse(manifest.id().toString()); Map env = new LinkedHashMap<>(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ExitWaiter.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ExitWaiter.java index 5ebcea32071..e68e86deb5d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ExitWaiter.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ExitWaiter.java @@ -33,7 +33,7 @@ /** * @author huangyuhui */ -final class ExitWaiter implements Runnable { +public final class ExitWaiter implements Runnable { private final ManagedProcess process; private final Collection joins; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java index 51a4f53ea6a..4eae1ec39d7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java @@ -21,7 +21,7 @@ import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; import java.io.IOException; import java.nio.file.Path; @@ -84,9 +84,9 @@ public GameInstance getInstance() { /// Starts the game process. /// - /// @return the managed process + /// @return the information of the launched game process /// @throws IOException if the process cannot be created or launch preparation fails /// @throws InterruptedException if interrupted while preparing or starting the process - public abstract ManagedProcess launch() throws IOException, InterruptedException; + public abstract ProcessInfo launch() throws IOException, InterruptedException; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ProcessListener.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ProcessListener.java index 523b9cf93c8..7c5fcb66c7b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ProcessListener.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ProcessListener.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.launch; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; /** * @@ -29,7 +29,7 @@ public interface ProcessListener { * When a game launched, this method will be called to get the new process. * You should not override this method when your ProcessListener is shared with all processes. */ - default void setProcess(ManagedProcess process) { + default void setProcess(ProcessInfo process) { } /** diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java new file mode 100644 index 00000000000..e083e4f7893 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java @@ -0,0 +1,116 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.platform; + +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jetbrains.annotations.NotNullByDefault; + +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Starts new JVM processes for a jar file, inheriting the current JVM's arguments. +@NotNullByDefault +public final class JavaProcessLauncher { + + private JavaProcessLauncher() { + } + + /// Builds the command line that starts a new JVM running the given jar with the given arguments. + /// + ///

The current JVM's {@code -D} and {@code -X} input arguments are inherited; when the + /// Management API is not available, only the {@code hmcl.}-prefixed system properties are + /// carried over as {@code -D} arguments. + /// + /// @param jar the jar file to run + /// @param appArgs the application arguments passed to the new process + /// @return the command line, one argument per element + public static List buildJavaCommandLine(Path jar, String... appArgs) { + List commandline = new ArrayList<>(); + commandline.add(JavaRuntime.getDefault().getBinary().toString()); + + try { + for (String inputArgument : ManagementFactory.getRuntimeMXBean().getInputArguments()) { + if (inputArgument.startsWith("-D") || inputArgument.startsWith("-X")) { + commandline.add(inputArgument); + } + } + } catch (Throwable ignored) { + // ManagementFactory not available + for (Map.Entry entry : System.getProperties().entrySet()) { + if (entry.getKey() instanceof String key && key.startsWith("hmcl.")) { + commandline.add("-D" + key + "=" + entry.getValue()); + } + } + } + + commandline.add("-jar"); + commandline.add(jar.toAbsolutePath().toString()); + commandline.addAll(Arrays.asList(appArgs)); + return commandline; + } + + /// Masks the values of sensitive {@code -D} arguments in the given command line, so that it can + /// be logged safely. + /// + /// @param commandline the command line, one argument per element + /// @return the human-readable, masked single-line representation + public static String maskCommandLine(List commandline) { + return commandline.stream().map(str -> { + if (str.startsWith("-D")) { + int eqIdx = str.indexOf('='); + if (eqIdx != -1) { + String key = str.substring(2, eqIdx); + String value = str.substring(eqIdx + 1); + if (key.contains("http.proxy") || key.startsWith("https.proxy") || key.startsWith("socksProxy") + || key.equals("hmcl.microsoft.auth.id") || key.equals("hmcl.curseforge.apikey")) { + return "-D" + key + "=" + (value.isEmpty() ? "" : + value.charAt(0) + "*".repeat(value.length() - 1)); + } + } + } + + return str; + }).collect(Collectors.joining(" ")); + } + + /// Starts a new JVM process running the given jar with the given arguments. + /// + ///

Fire-and-forget: the process inherits this process's I/O and is never waited for. The + /// masked command line is written to the log before the process is started. + /// + /// @param jar the jar file to run + /// @param appArgs the application arguments passed to the new process + /// @throws IOException if the process cannot be created + public static void startJava(Path jar, String... appArgs) throws IOException { + List commandline = buildJavaCommandLine(jar, appArgs); + LOG.info("Starting process: " + maskCommandLine(commandline)); + new ProcessBuilder(commandline) + .directory(Paths.get("").toAbsolutePath().toFile()) + .inheritIO() + .start(); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java index 858d14e9f32..646fb8071d6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java @@ -19,23 +19,26 @@ import org.jackhuang.hmcl.launch.StreamPump; import org.jackhuang.hmcl.util.Lang; +import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.time.Instant; import java.util.*; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import java.util.function.Predicate; -/// The managed process. +/// The game process spawned and owned by this JVM process. /// /// @author huangyuhui /// /// @see org.jackhuang.hmcl.launch.StreamPump -public final class ManagedProcess { +public final class ManagedProcess implements ProcessInfo { private final ReentrantLock lock = new ReentrantLock(); private final Process process; private final List commands; private final String classpath; + private final long processStartTime; private final Map properties = new HashMap<>(); private final List lines = new ArrayList<>(); private final List relatedThreads = new ArrayList<>(); @@ -44,6 +47,7 @@ public ManagedProcess(ProcessBuilder processBuilder) throws IOException { this.process = processBuilder.start(); this.commands = processBuilder.command(); this.classpath = null; + this.processStartTime = process.info().startInstant().map(Instant::toEpochMilli).orElseGet(System::currentTimeMillis); } /** @@ -56,6 +60,7 @@ public ManagedProcess(Process process, List commands) { this.process = process; this.commands = List.copyOf(commands); this.classpath = null; + this.processStartTime = process.info().startInstant().map(Instant::toEpochMilli).orElseGet(System::currentTimeMillis); } /** @@ -69,6 +74,7 @@ public ManagedProcess(Process process, List commands, String classpath) this.process = process; this.commands = List.copyOf(commands); this.classpath = classpath; + this.processStartTime = process.info().startInstant().map(Instant::toEpochMilli).orElseGet(System::currentTimeMillis); } /** @@ -80,11 +86,27 @@ public Process getProcess() { return process; } + @Override + public long getPid() { + return process.pid(); + } + + @Override + public ProcessHandle getHandle() { + return process.toHandle(); + } + + @Override + public long getProcessStartTime() { + return processStartTime; + } + /** * The command line. * * @return the list of each part of command line separated by spaces. */ + @Override public List getCommands() { return commands; } @@ -94,7 +116,8 @@ public List getCommands() { * * @return classpath */ - public String getClasspath() { + @Override + public @Nullable String getClasspath() { return classpath; } @@ -163,6 +186,7 @@ public void pumpErrorStream(Consumer onLogLine) { /** * True if the managed process is running. */ + @Override public boolean isRunning() { try { process.exitValue(); @@ -182,6 +206,7 @@ public int getExitCode() { /** * Destroys the raw process and other related threads that are monitoring this raw process. */ + @Override public void stop() { process.destroy(); destroyRelatedThreads(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java new file mode 100644 index 00000000000..75c623d0b1b --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java @@ -0,0 +1,52 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.platform; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/// The information about a process that process listeners and UIs can rely on, regardless of +/// whether the process is owned by this JVM ([ManagedProcess]) or supervised by another process, +/// e.g. the HMCL monitor. +@NotNullByDefault +public interface ProcessInfo { + + /// The process id of the process. + long getPid(); + + /// The handle of the process, or `null` if it cannot be resolved anymore, e.g. a dead + /// process whose id has already been reaped by the operating system. + @Nullable ProcessHandle getHandle(); + + /// The command line of the process. + List getCommands(); + + /// The classpath of the process, or `null` when unknown. + @Nullable String getClasspath(); + + /// The epoch millisecond at which the process started. + long getProcessStartTime(); + + /// Returns whether the process is still running. + boolean isRunning(); + + /// Destroys the process. + void stop(); +} diff --git a/HMCLMonitor/build.gradle.kts b/HMCLMonitor/build.gradle.kts new file mode 100644 index 00000000000..8e890532001 --- /dev/null +++ b/HMCLMonitor/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `java-library` +} + +tasks.withType { + sourceCompatibility = "17" + targetCompatibility = "17" +} + +dependencies { + api(project(":HMCLCore")) + + compileOnlyApi(libs.jetbrains.annotations) +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java new file mode 100644 index 00000000000..0f191e75ed9 --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -0,0 +1,243 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.monitor; + +import com.google.gson.ExclusionStrategy; +import com.google.gson.FieldAttributes; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.jackhuang.hmcl.event.EventBus; +import org.jackhuang.hmcl.event.JVMLaunchFailedEvent; +import org.jackhuang.hmcl.event.ProcessExitedAbnormallyEvent; +import org.jackhuang.hmcl.event.ProcessStoppedEvent; +import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.game.ProxyOption; +import org.jackhuang.hmcl.game.Renderer; +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.gson.*; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// The main launcher process role of the monitor feature: launching the game process through a +/// freshly spawned monitor process and consuming its protocol stream. +@NotNullByDefault +public final class MonitorClient { + + private static final boolean MONITOR_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; + + private MonitorClient() { + } + + public static boolean isEnabled() { + return MONITOR_ENABLED; + } + + public record MonitorLaunchContext( + ProcessBuilder builder, + @Nullable ProcessListener listener, + Charset encoding, + LaunchOptions options, + @Nullable String gameDirectoryId, + @Nullable List postExitCommand, + RelaunchPolicy relaunchPolicy) {} + + public enum RelaunchPolicy { + /// Never continue into the launcher UI after the game exits. + NEVER, + /// Continue into the launcher UI after a non-normal exit if the protocol stream has failed. + ON_CRASH, + /// Continue into the launcher UI after any exit if the protocol stream has failed. + ALWAYS + } + + private static final Gson gsonForGameOptions = new GsonBuilder() + .enableComplexMapKeySerialization() + .setPrettyPrinting() + .registerTypeAdapter(Instant.class, InstantTypeAdapter.INSTANCE) + .registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE) + .registerTypeHierarchyAdapter(Path.class, PathTypeAdapter.INSTANCE) // fix Path + .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) + .registerTypeAdapterFactory(LowerCaseEnumTypeAdapter.FACTORY) + .registerTypeAdapterFactory(JsonTypeAdapterFactory.INSTANCE) + .addSerializationExclusionStrategy(new ExclusionStrategy() { + @Override + public boolean shouldSkipField(FieldAttributes f) { + return false; + } + + @Override + public boolean shouldSkipClass(Class clazz) { + return ProxyOption.class.isAssignableFrom(clazz) + || Renderer.class.isAssignableFrom(clazz); // ignoring these + } + }) + .create(); + + public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) throws IOException { + Path thisJar = JarUtils.thisJarPath(); + if (thisJar == null) + throw new IOException("Failed to find current HMCL location"); + + Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); + try (var writer = Files.newBufferedWriter(specFile)) { + gsonForGameOptions.toJson(new ProcessSpec(context), writer); + } + + List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, + "--monitor", specFile.toAbsolutePath().toString()); + LOG.info("Starting monitor process: " + JavaProcessLauncher.maskCommandLine(commandline)); + Process monitor = new ProcessBuilder(commandline) + .directory(Paths.get("").toAbsolutePath().toFile()) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(monitor.getErrorStream(), MonitorProtocol.CHARSET)); + + String[] handshake = readHandshake(monitor, reader); + long pid; + long processStartTime; + try { + pid = Long.parseLong(handshake[1]); + processStartTime = Long.parseLong(handshake[2]); + } catch (NumberFormatException e) { + throw new IOException("Malformed handshake message from HMCL monitor: " + String.join("\t", handshake), e); + } + + ProcessHandle gameHandle = ProcessHandle.of(pid).orElse(null); + MonitorGameProcess process = new MonitorGameProcess(gameHandle, context.builder().command(), processStartTime, specFile); + + ProcessListener listener = context.listener(); + if (listener != null) + listener.setProcess(process); + + // The reader thread must drain the protocol stream until the monitor exits even without a + // listener: the exit message of a canceled launch is dispatched here, and an undrained + // pipe would eventually block the monitor. + Lang.thread(() -> { + try { + String line; + while ((line = reader.readLine()) != null) { + dispatchProtocolMessage(line, listener, process); + } + } catch (IOException e) { + LOG.error("Failed to read from the HMCL monitor", e); + } + }, "hmcl-monitor-reader", true); + + return process; + } + + /// Dispatches one protocol message received from the monitor. + private static void dispatchProtocolMessage(String line, @Nullable ProcessListener listener, MonitorGameProcess process) { + String[] parts = line.split("\t", 3); + switch (parts[0]) { + case MonitorProtocol.TAG_LOG -> { + String log = parts.length > 2 ? parts[2] : ""; + if (listener != null) + listener.onLog(log, "1".equals(parts[1])); + } + case MonitorProtocol.TAG_EXIT -> { + if (listener != null && parts.length >= 3) { + try { + int exitCode = Integer.parseInt(parts[1]); + ProcessListener.ExitType exitType = ProcessListener.ExitType.valueOf(parts[2]); + + if (exitType == ProcessListener.ExitType.JVM_ERROR) + EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorClient.class, process)); + else if (exitType != ProcessListener.ExitType.NORMAL) + EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorClient.class, process)); + EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorClient.class, process)); + + listener.onExit(exitCode, exitType); + } catch (IllegalArgumentException e) { + LOG.warning("Malformed exit message from HMCL monitor: " + line, e); + } + } + } + default -> LOG.warning("Unknown protocol message from HMCL monitor: " + line); + } + } + + /// Reads the handshake message `P\t\t` from the monitor. + /// + /// @throws IOException if the monitor exits or fails before the handshake, sends a malformed + /// message, or does not answer within [MonitorProtocol#HANDSHAKE_TIMEOUT] + private static String[] readHandshake(Process monitor, BufferedReader reader) throws IOException { + CompletableFuture future = new CompletableFuture<>(); + Thread waiter = Lang.thread(() -> { + try { + String line = reader.readLine(); + if (line == null) { + future.completeExceptionally(new IOException("The HMCL monitor exited before the handshake")); + return; + } + String[] parts = line.split("\t"); + if (!MonitorProtocol.TAG_PID.equals(parts[0]) || parts.length < 3) { + future.completeExceptionally(new IOException("Unexpected handshake message from HMCL monitor: " + line)); + } else { + future.complete(parts); + } + } catch (IOException e) { + future.completeExceptionally(e); + } + }, "hmcl-monitor-handshake", true); + + boolean handshakeCompleted = false; + try { + String[] handshake = future.get(MonitorProtocol.HANDSHAKE_TIMEOUT, TimeUnit.MILLISECONDS); + handshakeCompleted = true; + return handshake; + } catch (TimeoutException e) { + throw new IOException("Timed out waiting for the HMCL monitor handshake", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for the HMCL monitor handshake", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + throw new IOException(cause.getMessage(), cause); + } finally { + waiter.interrupt(); + // The monitor must not outlive a failed handshake: it would create and supervise the + // game process on its own, leaving behind an orphaned game, e.g. when the user cancels + // the launch while the handshake is still pending. + if (!handshakeCompleted) + monitor.destroy(); + } + } +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java new file mode 100644 index 00000000000..68c33185a87 --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -0,0 +1,104 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.util.platform.ProcessInfo; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// A view of the game process supervised by the HMCL monitor. +/// +/// Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], the game process is not spawned nor +/// owned by this JVM. Its handle is resolved from the monitor's handshake and may be unavailable +/// if the game has already exited. The original command line is supplied by the launch context. +@NotNullByDefault +public final class MonitorGameProcess implements ProcessInfo { + + private final @Nullable ProcessHandle handle; + private final long pid; + private final List commands; + private final long processStartTime; + /// The file the spec was exchanged with the monitor through, or `null` when unknown. + private final @Nullable Path specFile; + + /// Creates a view of the monitor-supervised game process. + /// + /// @param handle the handle of the game process, or `null` if it cannot be resolved anymore + /// @param commands the command line of the game process + /// @param processStartTime the epoch millisecond at which the game process started + /// @param specFile the file the spec was exchanged with the monitor through, or `null` if unknown + MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime, + @Nullable Path specFile) { + this.handle = handle; + this.pid = handle != null ? handle.pid() : -1; + this.commands = List.copyOf(commands); + this.processStartTime = processStartTime; + this.specFile = specFile; + } + + @Override + public long getPid() { + return pid; + } + + @Override + public @Nullable ProcessHandle getHandle() { + return handle; + } + + @Override + public List getCommands() { + return commands; + } + + @Override + public @Nullable String getClasspath() { + return null; + } + + @Override + public long getProcessStartTime() { + return processStartTime; + } + + @Override + public boolean isRunning() { + ProcessHandle handle = this.handle; + return handle != null && handle.isAlive(); + } + + @Override + public void stop() { + if (specFile != null) + try { + // notify the monitor + Files.writeString(specFile, "CANCELED"); + } catch (IOException e) { + LOG.warning("Failed to rewrite the monitor spec file " + specFile + " to mark the cancellation", e); + } + if (handle != null) + handle.destroy(); + } +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java new file mode 100644 index 00000000000..53f324b2fbd --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -0,0 +1,73 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jetbrains.annotations.NotNullByDefault; + +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.PrintStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/// The line-based protocol spoken on the monitor process's stderr. +/// +///

Messages are UTF-8 encoded, one per line, with tab-separated fields: +///

    +///
  • {@code P\t\t} — handshake, sent once the game process has been created;
  • +///
  • {@code L\t\t} — one line of game output;
  • +///
  • {@code E\t\t} — the game process has exited.
  • +///
+@NotNullByDefault +public final class MonitorProtocol { + + /// The charset of the protocol stream. + public static final Charset CHARSET = StandardCharsets.UTF_8; + + /// How long to wait for the monitor's handshake before giving up, in milliseconds. + static final long HANDSHAKE_TIMEOUT = 20_000; + + /// The shared protocol output on raw stderr, also used to detect failures when writing messages. + static final PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); + + /// Message tag of the handshake message. + static final String TAG_PID = "P"; + /// Message tag of a game output line. + static final String TAG_LOG = "L"; + /// Message tag of the game exit message. + static final String TAG_EXIT = "E"; + + private MonitorProtocol() { + } + + /// Writes and flushes the handshake message carrying the game process id and its start time. + static void pidMessage(long pid, long processStartTime) { + protocol.println(TAG_PID + "\t" + pid + "\t" + processStartTime); + } + + /// Writes and flushes a game output line message. + static void logMessage(boolean isErrorStream, String line) { + protocol.println(TAG_LOG + "\t" + (isErrorStream ? 1 : 0) + "\t" + line); + } + + /// Writes and flushes the game exit message carrying the exit code and its classified type. + static void exitMessage(int exitCode, ProcessListener.ExitType exitType) { + protocol.println(TAG_EXIT + "\t" + exitCode + "\t" + exitType.name()); + } +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java new file mode 100644 index 00000000000..c1d31927c88 --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -0,0 +1,197 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.monitor; + +import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.launch.ExitWaiter; +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.launch.StreamPump; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.SystemUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// The monitor process role of the monitor feature: creating the game process, supervising it until +/// it exits, and reporting the result. +/// +/// The monitor owns the game process and collects its output in a session log while forwarding it +/// to the main launcher over [the protocol][MonitorProtocol]. After the game exits, it runs the +/// post-exit command and stores the result in memory. When the protocol stream has failed and the +/// relaunch policy requests it, the entry point continues into the launcher UI in this same JVM. +/// No crash result file is written; the crash window reads the stored result and session log. +@NotNullByDefault +public final class MonitorSupervisor { + private static boolean shouldRestart = false; + private static boolean crashed = false; + private static @Nullable ResultSpec resultSpec; + + private MonitorSupervisor() { + } + + public static @Nullable ResultSpec getResultSpec() { + return resultSpec; + } + + public static boolean isCrashed() { + return crashed; + } + + /// Handles the `--monitor` entry point. + /// + /// @param args the process arguments + /// @return `true` when monitor mode has finished and the caller should exit; `false` for + /// non-monitor arguments or when this JVM should continue into the launcher UI + public static boolean processArguments(String[] args) { + if (args.length != 2 || !args[0].equals("--monitor")) + return false; + try { + start(Path.of(args[1])); + } catch (IOException | InterruptedException e) { + LOG.error("Failed to supervise the game process", e); + } + return !shouldRestart; + } + + /// Reads the [ProcessSpec] from `specPath`, creates the process it describes, supervises it + /// until it exits, and reports the result. + /// + /// @param specPath the file containing the [ProcessSpec] + /// @throws IOException if the spec file is malformed or the game process cannot be created + /// @throws InterruptedException if interrupted while waiting for the game process + public static void start(Path specPath) throws IOException, InterruptedException { + LOG.info("*** HMCL MONITOR ***"); + ProcessSpec spec; + try { + spec = JsonUtils.fromJsonFile(specPath, ProcessSpec.class); + } catch (JsonParseException e) { + throw new IOException("Malformed process spec file " + specPath, e); + } + if (spec == null || spec.command == null || spec.command.isEmpty()) + throw new IOException("Malformed process spec file " + specPath); + Files.deleteIfExists(specPath); + + ProcessBuilder pb = new ProcessBuilder(spec.command).directory(new File(spec.directory)); + pb.environment().putAll(spec.environment); + if (spec.inheritStdin) + pb.redirectInput(ProcessBuilder.Redirect.INHERIT); + ManagedProcess gameProcess = new ManagedProcess(pb.start(), spec.command); + + LOG.info("Game process created, pid " + gameProcess.getPid() + ", spec " + specPath); + MonitorProtocol.pidMessage(gameProcess.getPid(), gameProcess.getProcessStartTime()); + + Path logFile = Files.createTempFile("hmcl-monitor-game-", ".log"); + + + // Forward one game output line to the log file, the managed process (feeding the exit + // classification) and the protocol stream. A broken protocol stream (the launcher exited + // early) only stops forwarding; the log file keeps collecting the remaining output. + try (BufferedWriter logWriter = Files.newBufferedWriter(logFile, MonitorProtocol.CHARSET)) { + startMonitors(gameProcess, logWriter, spec, logFile, specPath); + } + } + + private static void startMonitors(ManagedProcess gameProcess, BufferedWriter logWriter, + ProcessSpec spec, Path logFile, Path specPath) throws InterruptedException { + AtomicBoolean parentGone = new AtomicBoolean(false); + BiConsumer sink = (line, isErrorStream) -> { + try { + synchronized (logWriter) { + logWriter.write(line); + logWriter.newLine(); + logWriter.flush(); + } + } catch (IOException e) { + LOG.warning("Failed to write the session log", e); + } + gameProcess.addLine(line); + if (!parentGone.get()) { + MonitorProtocol.logMessage(isErrorStream, line); + if (MonitorProtocol.protocol.checkError()) + parentGone.set(true); + } + }; + + Charset encoding = Charset.forName(Objects.requireNonNull(spec.encoding)); + Thread stdoutPump = Lang.thread(new StreamPump(gameProcess.getProcess().getInputStream(), + line -> sink.accept(line, false), encoding), "monitor-stdout-pump", true); + Thread stderrPump = Lang.thread(new StreamPump(gameProcess.getProcess().getErrorStream(), + line -> sink.accept(line, true), encoding), "monitor-stderr-pump", true); + gameProcess.addRelatedThread(stdoutPump); + gameProcess.addRelatedThread(stderrPump); + + Lang.thread(new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { + LOG.info("Game exited with code " + exitCode + "(" + Integer.toHexString(exitCode) + "), type " + exitType); + ProcessListener.ExitType reportedType = readCanceled(specPath) ? ProcessListener.ExitType.INTERRUPTED : exitType; + crashed = reportedType != ProcessListener.ExitType.NORMAL; + + if (MonitorProtocol.protocol.checkError()) + parentGone.set(true); + + shouldRestart = parentGone.get() && switch (spec.relaunchPolicy) { + case ALWAYS -> true; + case ON_CRASH -> crashed; + case NEVER -> false; + }; + + MonitorProtocol.exitMessage(exitCode, reportedType); + + if (spec.postExitCommand != null && !spec.postExitCommand.isEmpty()) { + try { + ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand).directory(new File(spec.directory)); + builder.environment().putAll(spec.environment); + SystemUtils.callExternalProcess(builder); + } catch (Throwable e) { + LOG.warning("An Exception happened while running exit command.", e); + } + } + + resultSpec = new ResultSpec(spec, gameProcess, reportedType, logFile); + }), "exit-waiter", true).join(); + } + + /// Returns whether the main launcher rewrote the spec file to mark the game process as manually + /// canceled. A successfully read file is deleted before checking for the `CANCELED` prefix. + /// + /// [MonitorGameProcess#stop] writes the marker before requesting process termination. The + /// caller reports a recognized cancellation as [ProcessListener.ExitType#INTERRUPTED]. + private static boolean readCanceled(Path specPath) { + if (!Files.exists(specPath)) + return false; + try { + String canceled = Files.readString(specPath); + Files.deleteIfExists(specPath); + return canceled.startsWith("CANCELED"); + } catch (IOException e) { + LOG.warning("Failed to check whether to cancel " + specPath, e); + return false; + } + } +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java new file mode 100644 index 00000000000..b8d5ee0e651 --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -0,0 +1,70 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.game.LaunchOptions; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// The specification of the game process to supervise, exchanged with the monitor process as JSON. +@NotNullByDefault +public final class ProcessSpec { + /// The command line to execute, passed to [ProcessBuilder] verbatim. + public final @Nullable List command; + /// The absolute working directory used for the game process and the post-exit command. + public final String directory; + /// Environment variables overlaid on the monitor's environment for both commands. + public final Map environment; + /// The name of the charset used to decode the game process output. + public final String encoding; + /// Whether the game process should inherit the monitor's standard input; set when no process + /// listener is present. + public final boolean inheritStdin; + /// The post-exit command to run after the game exits, already tokenized, or `null`. + public final @Nullable List postExitCommand; + + /// The policy for opening the launcher UI in the monitor JVM after the protocol stream fails. + public final MonitorClient.RelaunchPolicy relaunchPolicy; + /// The persistent id of the registered game directory owning the launched instance, or `null` + /// when unknown. + public final @Nullable String gameDirectoryId; + + /// The launch options retained for presenting the game crash window. + public final LaunchOptions launchOptions; + + /// Creates a process specification from a launch context whose builder has a working directory. + public ProcessSpec(MonitorClient.MonitorLaunchContext context) { + ProcessBuilder builder = context.builder(); + + command = new ArrayList<>(builder.command()); + directory = builder.directory().getAbsolutePath(); + environment = builder.environment(); + + encoding = context.encoding().name(); + inheritStdin = context.listener() == null; + postExitCommand = context.postExitCommand(); + relaunchPolicy = context.relaunchPolicy(); + gameDirectoryId = context.gameDirectoryId(); + + launchOptions = context.options(); + } +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java new file mode 100644 index 00000000000..6830f9ac99f --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java @@ -0,0 +1,46 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; +import java.util.List; + +/// The in-memory result of a supervised game process, retained when the monitor JVM continues +/// into the launcher UI to present the game crash window. +/// +/// @param process The supervised game process, which has already exited. +/// @param exitType The reported exit type, including `INTERRUPTED` for a recognized cancellation. +/// @param logFile The session log file containing the game output. +/// @param gameDirectoryId The persistent id of the registered game directory owning the launched +/// instance, or `null` when unknown. +/// @param commands The command line of the game process, or `null` when unknown. +/// @param launchOptions The launch options used to identify the instance and present its crash window. +@NotNullByDefault +public record ResultSpec(ManagedProcess process, ProcessListener.ExitType exitType, Path logFile, + @Nullable String gameDirectoryId, @Nullable List commands, LaunchOptions launchOptions) { + /// Combines the launch specification with the supervised process and its reported exit result. + public ResultSpec(ProcessSpec spec, ManagedProcess process, ProcessListener.ExitType exitType, Path logFile) { + this(process, exitType, logFile, spec.gameDirectoryId, spec.command, spec.launchOptions); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a1a710b39c0..4a347b87c9e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,7 +2,8 @@ rootProject.name = "HMCL3" include( "HMCL", "HMCLCore", - "HMCLBoot" + "HMCLBoot", + "HMCLMonitor" ) val minecraftLibraries = listOf("HMCLTransformerDiscoveryService", "HMCLMultiMCBootstrap")