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..4aa01d77693 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.Monitor; 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 (Monitor.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..babbe8b5e3a 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.Monitor; 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 (Monitor.wasCrashed() && Monitor.getResult() != null) + MonitorCrashReporter.show(Monitor.getResult()); }); } 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..9c55818397a 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,18 @@ import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.launch.DefaultLauncher; import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.monitor.MonitorLauncher; import org.jackhuang.hmcl.util.NativePatcher; 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 +156,20 @@ 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(List commandLine, Map envVars, Charset encoding) throws IOException { + if (!MonitorLauncher.MONITOR_ENABLED || !(instance instanceof HMCLGameInstance hmclGameInstance)) + return super.startProcess(commandLine, envVars, encoding); + + /// using monitor to launch game if enabled + return MonitorLauncher.launchNewMonitor(commandLine, envVars, encoding, listener, hmclGameInstance, options); + } + @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..54e266f93fc 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,9 @@ import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.monitor.Monitor; +import org.jackhuang.hmcl.monitor.MonitorGameProcess; +import org.jackhuang.hmcl.monitor.MonitorLauncher; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.game.*; @@ -836,7 +839,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,14 +858,14 @@ 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(); + String command = new CommandBuilder().addAll(process.commands()).toString(); LOG.info("Launched process: " + command); - String classpath = process.getClasspath(); + String classpath = process.classpath(); if (classpath != null) { LOG.info("Process ClassPath: " + classpath); } @@ -970,7 +973,7 @@ public void onLog(String log, boolean isErrorStream) { if (forbiddenAccessToken != null) log = log.replace(forbiddenAccessToken, ""); - Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null; + @Nullable Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null; if (showLogs) { if (level == null) level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO); @@ -993,7 +996,14 @@ public void onLog(String log, boolean isErrorStream) { try { if (!lwjgl) { lwjgl = true; - finishLaunch(); + if (process instanceof MonitorGameProcess monitorProcess + && MonitorLauncher.relaunchPolicy(launcherVisibility, showLogs) != Monitor.RelaunchPolicy.NEVER) { + monitorProcess.detach(); + launchingLatch.countDown(); + runLater(Launcher::stopApplication); + } else { + finishLaunch(); + } } } finally { lock.unlock(); @@ -1040,11 +1050,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 +1062,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..f42ee028014 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -0,0 +1,90 @@ +/* + * 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 javafx.application.Platform; +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.Log4jLevel; +import org.jackhuang.hmcl.util.logging.Logger; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.List; +import java.util.Optional; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// @see show +@NotNullByDefault +public final class MonitorCrashReporter { + + private MonitorCrashReporter() { + } + + /// find [HMCLGameInstance] from [Monitor.Result] and show [GameCrashWindow] + public static void show(Monitor.Result result) { + findInstanceAsync(result).thenAcceptAsync(Schedulers.javafx(), instance -> { + if (instance == null) { + LOG.warning("Cannot resolve the launched instance " + result.launchOptions().getInstanceId() + ", skip showing the crash window"); + return; + } + instance.markLaunchedAbnormally(); + @Unmodifiable List logs = result.process().getLines(null); + Platform.runLater(() -> new GameCrashWindow( + result.process(), + result.exitType(), + instance, + result.launchOptions(), + logs.subList(Math.max(0, logs.size() - Log.getLogLines()), logs.size()).stream() + .map(line -> new Log(Logger.filterForbiddenToken(line), Log4jLevel.guessLevel(line))) + .toList()).show()); + }).start(); + } + + /// find [HMCLGameInstance] from [Monitor.Result] + private static Task<@Nullable HMCLGameInstance> findInstanceAsync(Monitor.Result result) { + // find repository first + GameDirectoryID id = GameDirectoryID.parse(result.gameDirectoryId()); + Optional match = GameDirectoryManager.getGameDirectories().stream() + .filter(directory -> directory.getId().equals(id)) + .findFirst(); + if (match.isEmpty()) + return Task.completed(null); + GameDirectoryManager.setSelectedGameDirectory(match.get()); + HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); + + GameInstanceID instanceId = result.launchOptions().getInstanceId(); + HMCLGameInstance instance = repository.findInstance(instanceId); + + if (instance != null) // instance found + return Task.completed(instance); + + LOG.info("Waiting for the game repository to load before showing the crash window"); + return repository.refreshAsync().thenSupplyAsync(() -> repository.findInstance(instanceId)); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java new file mode 100644 index 00000000000..ffb5b5b9861 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -0,0 +1,198 @@ +/* + * 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 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.*; +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.LauncherVisibility; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; +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.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Launch HMCL Monitor Process +/// +/// @see launchNewMonitor +@NotNullByDefault +public final class MonitorLauncher { + /// Whether the monitor-based game launch is enabled via the `HMCL_USE_HMCLMONITOR` environment variable. + public static final boolean MONITOR_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; + /// fix problem serializing [LaunchOptions] (interfaces cannot be serialized easily, adapters needed) + private static final Gson gsonForGameOptions = JsonUtils.defaultGsonBuilder() + .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) + || QuickPlayOption.class.isAssignableFrom(clazz); + } + }).create(); + + private MonitorLauncher() { + } + + /// Maps launcher visibility and log-window preference to the monitor relaunch policy. + public static Monitor.RelaunchPolicy relaunchPolicy(LauncherVisibility visibility, boolean showLogs) { + if (showLogs) + return Monitor.RelaunchPolicy.NEVER; + return switch (visibility) { + case HIDE_AND_REOPEN -> Monitor.RelaunchPolicy.ALWAYS; + case HIDE -> Monitor.RelaunchPolicy.ON_CRASH; + default -> Monitor.RelaunchPolicy.NEVER; + }; + } + + /// start a new monitor and use it to launch game process + /// + /// @see Monitor + /// @return the game process monitor launched + public static MonitorGameProcess launchNewMonitor(List commandLine, + Map envVars, + Charset encoding, + @Nullable ProcessListener listener, + HMCLGameInstance hmclGameInstance, + LaunchOptions options) throws IOException { + Path specFile = writeSpecFile(commandLine, envVars, encoding, hmclGameInstance, options); + return startProcess(commandLine, listener, specFile); + } + + /// Writes the one-time launch specification + private static Path writeSpecFile(List commandLine, + Map envVars, + Charset encoding, + HMCLGameInstance hmclGameInstance, + LaunchOptions options) throws IOException { + Path runDirectory = hmclGameInstance.getRunDirectory().toAbsolutePath(); + + Map gameEnv = new HashMap<>(); + @Nullable Path appdata = options.getGameDir().toAbsolutePath().getParent(); + if (appdata != null) gameEnv.put("APPDATA", appdata.toString()); + gameEnv.putAll(envVars); + + String gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); + List postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), envVars); + + GameSettings.Effective setting = hmclGameInstance.getEffectiveSettings(); + Monitor.RelaunchPolicy relaunchPolicy = relaunchPolicy( + setting.getInheritable(GameSettings::launcherVisibilityProperty), + setting.getInheritable(GameSettings::showLogsProperty)); + + Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); + try (var writer = Files.newBufferedWriter(specFile)) { + gsonForGameOptions.toJson(new Monitor.MonitorLaunchSpec( + commandLine, + runDirectory, + gameEnv, + encoding.name(), + options, + gameDirectoryId, + postExitCommand, + relaunchPolicy + ), writer); + } + return specFile; + } + + /// Starts the monitor and attach listener + /// + /// @see MonitorProtocol for more detail reading message from monitor + private static MonitorGameProcess startProcess(List commandLine, @Nullable ProcessListener listener, Path specFile) throws IOException { + @Nullable Path thisJar = JarUtils.thisJarPath(); + if (thisJar == null) + throw new IOException("Failed to find current HMCL location"); + + Process monitor = JavaProcessLauncher.buildJava(thisJar, "--monitor", specFile.toAbsolutePath().toString()) + .redirectInput(ProcessBuilder.Redirect.PIPE) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.PIPE).start(); + + // read handshake (pid message) from monitor + BufferedReader reader = new BufferedReader(new InputStreamReader(monitor.getErrorStream(), MonitorProtocol.CHARSET)); + MonitorProtocol.MonitorMessage.Pid handshake = MonitorProtocol.readHandshake(reader, () -> { // clean up when handshake failed + monitor.destroy(); + try { + Files.deleteIfExists(specFile); + } catch (IOException e) { + LOG.warning("Failed to clean specFile" + e); + } + }); + + // get the game process handle from pid message + @Nullable ProcessHandle gameHandle = ProcessHandle.of(handshake.pid()).orElse(null); + MonitorGameProcess process = new MonitorGameProcess(gameHandle, commandLine, handshake.processStartTime(), monitor); + + if (listener != null) + listener.setProcess(process); // attach listener + else + process.detach(); // we no longer need any message from monitor + + Lang.thread(() -> { + try (reader) { + MonitorProtocol.readMonitorMessage(reader, msg -> { + if (listener == null) + return; + + if (msg instanceof MonitorProtocol.MonitorMessage.Log log) { + listener.onLog(log.line(), log.isErrorStream()); + } else if (msg instanceof MonitorProtocol.MonitorMessage.Exit exit) { + process.handleExit(() -> { // if we are still alive and can handle exit + if (exit.exitType() == ProcessListener.ExitType.JVM_ERROR) + EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorLauncher.class, process)); + else if (exit.exitType() != ProcessListener.ExitType.NORMAL) + EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorLauncher.class, process)); + EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorLauncher.class, process)); + + listener.onExit(exit.exitCode(), exit.exitType()); + }); + } + }); + } catch (IOException e) { + LOG.error("IOException occurred during communicating with HMCL monitor.", e); + } + }, "hmcl-monitor-reader", true); + return process; + } +} 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..ab36dfaf61a 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; @@ -264,9 +262,9 @@ private String parseFabricModId(String modName) { private void showLogWindow() { LogWindow logWindow = new LogWindow(managedProcess); - logWindow.logLine(new Log(Logger.filterForbiddenToken("Command: " + new CommandBuilder().addAll(managedProcess.getCommands())), Log4jLevel.INFO)); - if (managedProcess.getClasspath() != null) - logWindow.logLine(new Log("ClassPath: " + managedProcess.getClasspath(), Log4jLevel.INFO)); + logWindow.logLine(new Log(Logger.filterForbiddenToken("Command: " + new CommandBuilder().addAll(managedProcess.commands())), Log4jLevel.INFO)); + if (managedProcess.classpath() != null) + logWindow.logLine(new Log("ClassPath: " + managedProcess.classpath(), Log4jLevel.INFO)); logWindow.logLines(logs); logWindow.show(); } @@ -277,19 +275,10 @@ 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.processStartTime(); return LogExporter.exportLogs(logFile, gameInstance, launchOptions, logs, - new CommandBuilder().addAll(managedProcess.getCommands()).toString(), + new CommandBuilder().addAll(managedProcess.commands()).toString(), path -> { try { FileTime lastModifiedTime = Files.getLastModifiedTime(path); 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..f6ceb73eef0 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.pid(), 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().handle(); + 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..c38fdbededf 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.buildJava(jar, appArgs).inheritIO().start(); } 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..8f8e2431333 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,24 +694,28 @@ public ManagedProcess launch() throws IOException, InterruptedException { SystemUtils.callExternalProcess(builder); } - Process process; try { - ProcessBuilder builder = new ProcessBuilder(rawCommandLine).directory(runDirectory.toFile()); - if (listener == null) { - builder.inheritIO(); - } - Path appdata = options.getGameDir().toAbsolutePath().getParent(); - if (appdata != null) builder.environment().put("APPDATA", appdata.toString()); - - builder.environment().putAll(getEnvVars(nativeFolder)); - process = builder.start(); + return startProcess(rawCommandLine, getEnvVars(nativeFolder), command.encoding); } catch (IOException e) { throw new ProcessCreationException(e); } + } + + /// start game process from the final commandline and envVars and attach listeners + /// + /// @return the game process + protected ProcessInfo startProcess(List commandLine, Map envVars, Charset encoding) throws IOException { + ProcessBuilder builder = new ProcessBuilder(commandLine).directory(instance.getRunDirectory().toFile()); + if (listener == null) { + builder.inheritIO(); + } + Path appdata = options.getGameDir().toAbsolutePath().getParent(); + if (appdata != null) builder.environment().put("APPDATA", appdata.toString()); + builder.environment().putAll(envVars); - ManagedProcess p = new ManagedProcess(process, rawCommandLine); + ManagedProcess p = new ManagedProcess(builder); if (listener != null) - startMonitors(p, nativeFolder, listener, command.encoding, daemon); + startMonitors(p, envVars, listener, encoding, daemon); return p; } @@ -1019,7 +1023,7 @@ else if (!isWindows && !(scriptExtension.equalsIgnoreCase("sh") || scriptExtensi throw new ExecutionPolicyLimitException(); } - private void startMonitors(ManagedProcess managedProcess, Path nativeFolder, ProcessListener processListener, Charset encoding, boolean isDaemon) { + private void startMonitors(ManagedProcess managedProcess, Map envVars, ProcessListener processListener, Charset encoding, boolean isDaemon) { processListener.setProcess(managedProcess); Thread stdout = Lang.thread(new StreamPump(managedProcess.getProcess().getInputStream(), it -> { processListener.onLog(it, false); @@ -1036,8 +1040,8 @@ private void startMonitors(ManagedProcess managedProcess, Path nativeFolder, Pro if (StringUtils.isNotBlank(options.getPostExitCommand())) { try { - ProcessBuilder builder = new ProcessBuilder(StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder))).directory(options.getGameDir().toFile()); - builder.environment().putAll(getEnvVars(nativeFolder)); + ProcessBuilder builder = new ProcessBuilder(StringUtils.tokenize(options.getPostExitCommand(), envVars)).directory(options.getGameDir().toFile()); + builder.environment().putAll(envVars); SystemUtils.callExternalProcess(builder); } catch (Throwable e) { LOG.warning("An Exception happened while running exit command.", e); 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/gson/JsonUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java index 3e6d66b625e..b705edd51ac 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java @@ -43,7 +43,7 @@ /// - [GSON] — pretty-printing instance with all standard HMCL type adapters registered. /// - [UGLY_GSON] — compact (non-pretty) instance with the same adapter set minus /// complex-map-key serialization and the built-in type adapters for [java.time.Instant], -/// [java.util.UUID] and [java.nio.file.Path]. +/// [java.util.UUID] and the [java.nio.file.Path] hierarchy. /// /// All `fromJson` / `fromJsonFile` / `fromJsonFully` overloads return `null` when the /// JSON literal `null` is encountered. The `fromNonNull*` variants throw @@ -61,13 +61,14 @@ public final class JsonUtils { /// Configured with: /// - Pretty printing enabled. /// - Complex map key serialization enabled. - /// - Type adapters for [java.time.Instant], [java.util.UUID], and [java.nio.file.Path]. + /// - Type adapters for [java.time.Instant] and [java.util.UUID]. + /// - A type-hierarchy adapter for [java.nio.file.Path] and its subtypes. /// - [ValidationTypeAdapterFactory], [LowerCaseEnumTypeAdapter#FACTORY], and /// [JsonTypeAdapterFactory]. public static final Gson GSON = defaultGsonBuilder().create(); /// A compact [Gson] instance without pretty printing and without the extra - /// type adapters for [java.time.Instant], [java.util.UUID], and [java.nio.file.Path]. + /// type adapters for [java.time.Instant], [java.util.UUID], and the [java.nio.file.Path] hierarchy. /// /// Configured with: /// - [JsonTypeAdapterFactory] @@ -650,7 +651,7 @@ public static void writeToJsonFile(Path file, @Nullable Object value) throws IOE /// - Pretty printing enabled. /// - [InstantTypeAdapter] registered for [java.time.Instant]. /// - [UUIDTypeAdapter] registered for [java.util.UUID]. - /// - [PathTypeAdapter] registered for [java.nio.file.Path]. + /// - [PathTypeAdapter] registered as a type-hierarchy adapter for [java.nio.file.Path] and its subtypes. /// - [ValidationTypeAdapterFactory], [LowerCaseEnumTypeAdapter#FACTORY], and /// [JsonTypeAdapterFactory] registered as type adapter factories. /// @@ -664,7 +665,7 @@ public static GsonBuilder defaultGsonBuilder() { .setPrettyPrinting() .registerTypeAdapter(Instant.class, InstantTypeAdapter.INSTANCE) .registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE) - .registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE) + .registerTypeHierarchyAdapter(Path.class, PathTypeAdapter.INSTANCE) .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) .registerTypeAdapterFactory(LowerCaseEnumTypeAdapter.FACTORY) .registerTypeAdapterFactory(JsonTypeAdapterFactory.INSTANCE); 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..4c7c40c7c50 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.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.util.platform; + +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jetbrains.annotations.NotNullByDefault; + +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(" ")); + } + + public static ProcessBuilder buildJava(Path jar, String... appArgs) { + List commandline = buildJavaCommandLine(jar, appArgs); + LOG.info("Starting process: " + maskCommandLine(commandline)); + return new ProcessBuilder(commandline).directory(Paths.get("").toAbsolutePath().toFile()); + } +} 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..b03dd69a020 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,28 @@ import org.jackhuang.hmcl.launch.StreamPump; import org.jackhuang.hmcl.util.Lang; +import org.jetbrains.annotations.NotNullByDefault; +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 { +@NotNullByDefault +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 @Nullable 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 +49,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 +62,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 +76,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,12 +88,28 @@ public Process getProcess() { return process; } + @Override + public long pid() { + return process.pid(); + } + + @Override + public ProcessHandle handle() { + return process.toHandle(); + } + + @Override + public long processStartTime() { + return processStartTime; + } + /** * The command line. * * @return the list of each part of command line separated by spaces. */ - public List getCommands() { + @Override + public List commands() { return commands; } @@ -94,7 +118,8 @@ public List getCommands() { * * @return classpath */ - public String getClasspath() { + @Override + public @Nullable String classpath() { return classpath; } @@ -111,7 +136,7 @@ public Map getProperties() { * * @see #addLine */ - public List getLines(Predicate lineFilter) { + public List getLines(@Nullable Predicate lineFilter) { lock.lock(); try { if (lineFilter == null) @@ -163,6 +188,7 @@ public void pumpErrorStream(Consumer onLogLine) { /** * True if the managed process is running. */ + @Override public boolean isRunning() { try { process.exitValue(); @@ -182,6 +208,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..e79f05021e2 --- /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 pid(); + + /// 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 handle(); + + /// The command line of the process. + List commands(); + + /// The classpath of the process, or `null` when unknown. + @Nullable String classpath(); + + /// The epoch millisecond at which the process started. + long processStartTime(); + + /// 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/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java new file mode 100644 index 00000000000..9e44bdcb482 --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -0,0 +1,196 @@ +/* + * 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.game.LaunchOptions; +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.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Lightweight monitor to watch game process +@NotNullByDefault +public final class Monitor { + /// the result of the latest launched game process + private static @Nullable Monitor.Result result; + + private Monitor() { + } + + /// @return the [result] of the latest launched game process, or `null` when no game was launched by monitor + public static @Nullable Monitor.Result getResult() { + return result; + } + + /// the latest launched game was crashed + public static boolean wasCrashed() { + return result != null && result.crashed; + } + + /// @return `true` if we should exit, or `false` if we should continue and launch HMCL + public static boolean processArguments(String[] args) { + if (args.length != 2 || !args[0].equals("--monitor")) + return false; + try { + return start(Path.of(args[1])); + } catch (IOException | InterruptedException e) { + LOG.error("Failed to start the game process", e); + return true; + } + } + + /// Launch game process from spec file. + /// + /// @see MonitorProtocol for more detail sending message and reading control with launcher + /// @return `true` if we should exit, or `false` if we should continue and launch HMCL + public static boolean start(Path specPath) throws IOException, InterruptedException { + LOG.info("*** HMCL MONITOR ***"); + + // read spec + MonitorLaunchSpec spec; + try { + spec = JsonUtils.fromNonNullJson(Files.readString(specPath), MonitorLaunchSpec.class); + } catch (JsonParseException e) { + throw new IOException("Malformed process spec file " + specPath, e); + } + Files.deleteIfExists(specPath); + + // start game process + ProcessBuilder pb = new ProcessBuilder(spec.command).directory(spec.directory.toFile()); + pb.environment().putAll(spec.environment); + ManagedProcess gameProcess = new ManagedProcess(pb); + LOG.info("Game process created, pid " + gameProcess.pid()); + + // Read commands from launcher while the game is running. + AtomicBoolean canceled = new AtomicBoolean(); + CompletableFuture launcherDetached = new CompletableFuture<>(); + Lang.thread(() -> { + try { + MonitorProtocol.readLauncherControl( + new BufferedReader(new InputStreamReader(System.in, MonitorProtocol.CHARSET)), (command) -> { + switch (command) { + case CANCEL -> { + canceled.set(true); + gameProcess.getProcess().destroy(); + } + case DETACH -> launcherDetached.complete(true); + case HANDLED -> launcherDetached.complete(false); + } + }); + } catch (IOException e) { + LOG.warning("Lost launcher control channel; taking over exit handling", e); + } + launcherDetached.complete(true); // launcher died + }, "monitor-control-reader", true); + + + // shake hand & send pid + PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); + protocol.println(new MonitorProtocol.MonitorMessage.Pid(gameProcess.pid(), gameProcess.processStartTime()).encode()); + + // pump game io + BiConsumer sink = (line, isErrorStream) -> { + gameProcess.addLine(line); + protocol.println(new MonitorProtocol.MonitorMessage.Log(isErrorStream, line).encode()); + }; + Charset encoding = Charset.forName(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); + + // wait for stop + AtomicBoolean shouldRelaunch = new AtomicBoolean(); + new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { + LOG.info("Game exited with code " + exitCode + "(" + Integer.toHexString(exitCode) + "), type " + exitType); + + // send exit msg to launcher + ProcessListener.ExitType reportedType = canceled.get() ? ProcessListener.ExitType.INTERRUPTED : exitType; + protocol.println(new MonitorProtocol.MonitorMessage.Exit(exitCode, reportedType).encode()); + + boolean crashed = reportedType != ProcessListener.ExitType.NORMAL && reportedType != ProcessListener.ExitType.INTERRUPTED; + // Exit message may overtake the launcher's handling of the last LWJGL log. + // So an explicit decision from launcher is needed. + shouldRelaunch.set(launcherDetached.join() && switch (spec.relaunchPolicy) { + case ALWAYS -> true; + case ON_CRASH -> crashed; + case NEVER -> false; + }); + result = new Result(gameProcess, reportedType, spec.gameDirectoryId, crashed, spec.options); + + // run post exit command + if (!spec.postExitCommand.isEmpty()) + try { + ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand).directory(spec.options.getGameDir().toFile()); + builder.environment().putAll(spec.environment()); + SystemUtils.callExternalProcess(builder); + } catch (Throwable e) { + LOG.warning("An Exception happened while running exit command.", e); + } + }).run(); + return !shouldRelaunch.get(); + } + + 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 launcher relinquishes exit handling. + ON_CRASH, + /// Continue into the launcher UI after any exit if the launcher relinquishes exit handling. + ALWAYS + } + + /// launch result of the latest game process + /// + /// @param process The game process, which has already exited. + /// @param exitType The reported exit type, including [ProcessListener.ExitType#INTERRUPTED] for a recognized cancellation. + /// @param gameDirectoryId The persistent id of the registered game directory owning the launched instance. + /// @param crashed Whether the game crashed. + /// @param launchOptions The launch options used to identify the instance and present its crash window. + @NotNullByDefault + public record Result(ManagedProcess process, ProcessListener.ExitType exitType, String gameDirectoryId, + boolean crashed, LaunchOptions launchOptions) { + } + + /// One-time game configuration, consumed and deleted before the game starts. + public record MonitorLaunchSpec(List command, Path directory, Map environment, + String encoding, LaunchOptions options, + String gameDirectoryId, List postExitCommand, + RelaunchPolicy relaunchPolicy) { + } +} 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..203af3ad067 --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -0,0 +1,188 @@ +/* + * 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.task.Task; +import org.jackhuang.hmcl.util.platform.ProcessInfo; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.io.PrintStream; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// A view of the game process launched by the HMCL monitor. +/// +/// Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], this process may not be resolved when it exited before +/// monitor's handshake. +@NotNullByDefault +public final class MonitorGameProcess implements ProcessInfo { + /// the handle of the game process, or `null` if it cannot be resolved. + private final @Nullable ProcessHandle handle; + /// the command line of the game process + private final @Unmodifiable List commands; + /// the epoch millisecond at which the game process started + private final long processStartTime; + /// the monitor process + private final Process monitor; + /// the channel to send command to monitor, e.g. transfer responsibility for handling game exit / cancel launching + private final MonitorController controller; + + /// Creates a view of the monitor-supervised game process. + public MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime, Process monitor) { + this.handle = handle; + this.commands = List.copyOf(commands); + this.processStartTime = processStartTime; + this.monitor = monitor; + this.controller = new MonitorController(monitor); + } + + /// Transfers exit handling to the monitor before the launcher begins shutting down. + public void detach() { + controller.detach(); + } + + /// Handles exit when monitor is attached and acknowledges that the exit callback has completed. + public void handleExit(Runnable handler) { + if (controller.isDetached()) + return; + handler.run(); + controller.handled(); // mark handled after handler run for the case that launcher was closed during handling + } + + @Override + public boolean isRunning() { + return handle != null && handle.isAlive(); + } + + /// Requests cancellation over the control channel; the monitor owns game termination. + @Override + public void stop() { + if (handle == null) + return; + Task.runAsync(() -> { + if (!controller.cancel().await(MonitorProtocol.TIMEOUT, TimeUnit.MILLISECONDS)) + handle.destroy(); + }).whenComplete(exception -> { + if (exception != null) + handle.destroy(); + }).start(); + } + + @Override + public @Nullable ProcessHandle handle() { + return handle; + } + + @Override + public long pid() { + return handle != null ? handle.pid() : -1; + } + + @Override + public @Unmodifiable List commands() { + return commands; + } + + @Override + public long processStartTime() { + return processStartTime; + } + + @Override + public @Nullable String classpath() { + return null; + } + + public Process getMonitor() { + return monitor; + } + + /// Serializes cancellation and exit decisions onto monitor stdin. + /// + /// @see MonitorProtocol for more detail sending control to monitor + @NotNullByDefault + private static final class MonitorController { + /// UTF-8 control writer, kept open after DETACH so cancellation remains available. + private final PrintStream writer; + private final CountDownLatch exited = new CountDownLatch(1); + /// Current owner of exit handling; all transitions are protected by this object's monitor. + private State state = State.ATTACHED; + + /// Creates a control channel backed by the monitor process's stdin. + MonitorController(Process monitor) { + writer = new PrintStream(monitor.getOutputStream(), true, MonitorProtocol.CHARSET); + } + + /// Sends DETACH exactly once, before launcher shutdown is scheduled. + synchronized void detach() { + if (state != State.ATTACHED) + return; + state = State.DETACHED; + send(MonitorProtocol.ControlCommand.DETACH); + } + + /// Returns whether the monitor owns exit handling. + synchronized boolean isDetached() { + return state == State.DETACHED; + } + + /// Sends HANDLED only if the launcher still owns exit handling. + synchronized void handled() { + if (state != State.ATTACHED) + return; + state = State.HANDLED; + exited.countDown(); + send(MonitorProtocol.ControlCommand.HANDLED); + writer.close(); + } + + /// Asks the monitor to record cancellation before terminating the game. + /// + /// @return [CountDownLatch] when SUCCESSFULLY canceled + synchronized CountDownLatch cancel() { + switch (state) { + case ATTACHED, DETACHED -> send(MonitorProtocol.ControlCommand.CANCEL); + case HANDLED -> { // The game exit has already been handled. + } + } + return exited; + } + + /// Writes and flushes one command while the caller holds the state lock. + private void send(MonitorProtocol.ControlCommand command) { + writer.println(command.name()); + if (writer.checkError()) + LOG.warning("Failed to send monitor control command: " + command); + } + + /// Exit ownership is independent of cancellation, which is allowed until HANDLED. + private enum State { + /// The launcher owns exit handling and may cancel or transfer ownership. + ATTACHED, + /// The monitor owns exit handling; the launcher may still cancel the game. + DETACHED, + /// The launcher completed exit handling and closed the control pipe. + HANDLED + } + } +} 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..5d8e9ba88c3 --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -0,0 +1,167 @@ +/* + * 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.jackhuang.hmcl.util.Lang; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; + +/// The wire protocol between the launcher JVM and the monitor JVM. +@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 TIMEOUT = 20_000; + + private MonitorProtocol() { + } + + /// read and parse control from launcher forever + static void readLauncherControl(BufferedReader control, Consumer commandConsumer) throws IOException { + @Nullable String line; + while ((line = control.readLine()) != null) { + try { + commandConsumer.accept(ControlCommand.valueOf(line)); + } catch (IllegalArgumentException e) { + throw new IOException("Unexpected launcher control message: " + line, e); + } + } + } + + /// read and parse message from monitor forever + public static void readMonitorMessage(BufferedReader reader, Consumer messageConsumer) throws IOException { + @Nullable String line; + while ((line = reader.readLine()) != null) { + try { + messageConsumer.accept(MonitorMessage.valueOf(line)); + } catch (IllegalArgumentException e) { + // ignore regular err messages + } + } + } + + /// read handshake (pid message) from monitor + /// + /// @throws IOException if launcher cannot shake hand with monitor within [MonitorProtocol#TIMEOUT] + public static MonitorMessage.Pid readHandshake(BufferedReader reader, Runnable failClean) throws IOException { + CompletableFuture future = new CompletableFuture<>(); + Thread waiter = Lang.thread(() -> { + try { + @Nullable String line; + while ((line = reader.readLine()) != null) { + try { + if (MonitorMessage.valueOf(line) instanceof MonitorMessage.Pid pid) { + future.complete(pid); + return; + } + throw new IOException("HMCL monitor sent message before handshake: " + line); + } catch (IllegalArgumentException e) { + // ignore regular err messages + } + } + throw new IOException("The HMCL monitor exited before handshake"); + } catch (Exception e) { + future.completeExceptionally(e); + } + }, "hmcl-monitor-handshake", true); + + boolean handshakeCompleted = false; + try { + MonitorMessage.Pid handshake = future.get(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 { + if (!handshakeCompleted) { + failClean.run(); + waiter.interrupt(); + } + } + } + + /// Commands sent from the launcher to monitor stdin, serialized using their enum names. + enum ControlCommand { + /// Request cancellation before the monitor terminates the game. + CANCEL, + /// Transfer exit handling to the monitor while keeping cancellation available. + DETACH, + /// Confirm that the launcher has finished handling game exit. + HANDLED + } + + /// Messages sent from the monitor stderr to launcher, serialized using [encode()]. + public sealed interface MonitorMessage permits MonitorMessage.Pid, MonitorMessage.Log, MonitorMessage.Exit { + static MonitorMessage valueOf(String message) throws IllegalArgumentException { + String[] parts = message.split("\t", 3); + try { + return switch (parts[0]) { + case "P" -> new Pid(Long.parseLong(parts[1]), Long.parseLong(parts[2])); + case "L" -> new Log("1".equals(parts[1]), parts[2]); + case "E" -> new Exit(Integer.parseInt(parts[1]), ProcessListener.ExitType.valueOf(parts[2])); + default -> throw new IllegalStateException("Unexpected value: " + message.charAt(0)); + }; + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + + String encode(); + + /// handshake message + record Pid(long pid, long processStartTime) implements MonitorMessage { + @Override + public String encode() { + return "P\t" + pid + "\t" + processStartTime; + } + } + + /// game output line + record Log(boolean isErrorStream, String line) implements MonitorMessage { + @Override + public String encode() { + return "L\t" + (isErrorStream ? 1 : 0) + "\t" + line; + } + } + + /// game exit message + record Exit(int exitCode, ProcessListener.ExitType exitType) implements MonitorMessage { + @Override + public String encode() { + return "E\t" + exitCode + "\t" + exitType.name(); + } + } + } +} 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")