Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
46d77bb
refactor: 引入 ProcessInfo 接口解耦游戏进程管理
fishdry1000 Sep 30, 2026
6833a66
chore: 清理 import
fishdry1000 Sep 30, 2026
4279e76
feat: 新增 HMCLMonitor 子项目,设置 HMCL_USE_HMCLMONITOR 环境变量启用。使用轻量进程监督游戏进程。
fishdry1000 Sep 30, 2026
95f95f8
feat: 游戏异常退出后由 monitor 重拉 HMCL 并复用崩溃窗
fishdry1000 Sep 30, 2026
1cf1ac9
chore: 清理代码
fishdry1000 Sep 30, 2026
958b4e4
fix: 修复手动取消会弹出崩溃窗口
fishdry1000 Oct 1, 2026
d23d263
style: 优化格式
fishdry1000 Oct 1, 2026
6524116
fix: 修复几处 corner case
fishdry1000 Oct 1, 2026
6f441d6
docs: 修缮注释
fishdry1000 Oct 1, 2026
d354dcb
fix: 使 GameRepository 的加载标志与快照发布原子可见
fishdry1000 Oct 4, 2026
36f8bfb
fix: 崩溃恢复按游戏目录持久化 ID 定位实例,替代全目录扫描
fishdry1000 Oct 4, 2026
42eb85c
fix: 限制日志读取长度
fishdry1000 Oct 4, 2026
2a6d4a0
docs: 优化注释
fishdry1000 Oct 4, 2026
9e54096
fix: 仓库加载失败时等待不再无限挂起
fishdry1000 Oct 4, 2026
fc82931
fix: 以协议流写入失败判定启动器存活,修复 Linux 下 relaunch 不触发
fishdry1000 Oct 5, 2026
20b3191
docs: 精简 monitor 相关注释
fishdry1000 Oct 5, 2026
ad7f93b
chore: 清理代码
fishdry1000 Oct 6, 2026
ab354dc
refactor: 重构拉起 monitor 的模型
fishdry1000 Oct 12, 2026
853374f
chore: 优化 import
fishdry1000 Oct 12, 2026
f1eb162
Merge branch 'main' into feature/daemon
fishdry1000 Oct 12, 2026
28008aa
refactor: 重构 MonitorCrashReporter 和 spec
fishdry1000 Oct 12, 2026
ac1583d
docs: 优化注释
fishdry1000 Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -42,6 +43,7 @@ language-subtag-registry
/out/
/HMCL/out/
/HMCLCore/out/
/HMCLMonitor/out/
/minecraft/libraries/HMCLTransformerDiscoveryService/out/
/minecraft/libraries/HMCLMultiMCBootstrap/out/

Expand All @@ -50,6 +52,7 @@ language-subtag-registry
/HMCL/bin/
/HMCLBoot/bin/
/HMCLCore/bin/
/HMCLMonitor/bin/
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
.classpath
Expand Down
2 changes: 2 additions & 0 deletions HMCL/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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)
Expand Down Expand Up @@ -211,6 +212,7 @@ tasks.shadowJar {
exclude(dependency("net.java.dev.jna:jna:.*"))
exclude(dependency("libs:JFoenix:.*"))
exclude(project(":HMCLBoot"))
exclude(project(":HMCLMonitor"))
}

manifest.attributes(
Expand Down
6 changes: 6 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
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.setting.*;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
Expand Down Expand Up @@ -145,6 +146,11 @@ public void start(Stage primaryStage) {
UpdateChecker.init();

primaryStage.show();

// check wheather to show log from HMCL monitor
Path pendingCrashReport = MonitorCrashReporter.processArguments(getParameters().getRaw().toArray(new String[0]));
if (pendingCrashReport != null)
MonitorCrashReporter.show(pendingCrashReport);
});
} catch (Throwable e) {
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
Expand Down
39 changes: 37 additions & 2 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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();
Expand Down
20 changes: 14 additions & 6 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1040,18 +1048,18 @@ public void onExit(int exitCode, ExitType exitType) {

}

private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
private static final Queue<WeakReference<ProcessInfo>> 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();
}

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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.monitor;

import com.google.gson.JsonParseException;
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.launch.ProcessListener;
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.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;

import static javafx.application.Platform.runLater;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;

/// The relaunched launcher role of the monitor feature: presenting the crash window of a supervised
/// game process, reusing the regular JavaFX user interface.
@NotNullByDefault
public final class MonitorCrashReporter {

private MonitorCrashReporter() {
}

public static @Nullable Path processArguments(String[] args) {
if (args.length == 2 && args[0].equals("--crash-report"))
return Path.of(args[1]);
return null;
}

public static void show(Path resultFile) {
ResultSpec result;
try {
result = JsonUtils.fromJsonFile(resultFile, ResultSpec.class);
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to read the monitor result file " + resultFile, e);
return;
}
try {
Files.deleteIfExists(resultFile);
} catch (IOException e) {
LOG.warning("Failed to delete the monitor result file " + resultFile, e);
}
if (result == null || result.launchOptions == null || result.gameDirectoryId == null)
return;

// find repository
GameDirectoryID id = GameDirectoryID.parse(result.gameDirectoryId);
Optional<GameDirectory> match = GameDirectoryManager.getGameDirectories().stream()
.filter(directory -> directory.getId().equals(id))
.findFirst();
if (match.isEmpty())
return;
GameDirectoryManager.setSelectedGameDirectory(match.get());
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();

// find instance
GameInstanceID instanceId = result.launchOptions.getInstanceId();
HMCLGameInstance instance = repository.findInstance(instanceId);

// instance found
if (instance != null) {
presentCrashWindow(result, instance);
return;
}

// repository found but cannot find instance
if (repository.isLoaded()) {
logFail(instanceId, result.logFile, repository);
return;
}

LOG.info("Waiting for the game repository to load before showing the crash window");
repository.refreshAsync().whenComplete(Schedulers.defaultScheduler(), exception -> {
if (exception == null) {
runLater(() -> {
HMCLGameInstance loaded = repository.findInstance(instanceId);
if (loaded != null)
presentCrashWindow(result, loaded);
else
logFail(instanceId, result.logFile, repository);
});
} else {
LOG.warning("Failed to load the game repository while waiting to show the crash window", exception);
logFail(instanceId, result.logFile, repository);
}
}).start();
}

private static void logFail(GameInstanceID instanceId, @Nullable String logFile,
@Nullable HMCLGameRepository repository) {
LOG.warning("Cannot resolve the launched instance " + instanceId + " ("
+ (repository != null
? repository.getSnapshot().getInstanceCount() + " instances, loaded="
+ repository.isLoaded() + ", base directory "
+ repository.getLayout().getBaseDirectory()
: "no registered game directory has the recorded id")
+ "), skip showing the crash window."
+ " The session log is at " + logFile);
}

private static void presentCrashWindow(ResultSpec result, HMCLGameInstance instance) {
MonitorGameProcess process = new MonitorGameProcess(
ProcessHandle.of(result.pid).orElse(null),
Objects.requireNonNullElseGet(result.commands, List::of),
result.processStartTime,
null); // pretend this is the real process.

@Nullable Path logFile = result.logFile != null ? Path.of(result.logFile) : null;

ProcessListener.ExitType exitType = ProcessListener.ExitType.valueOf(result.exitType);
Task.supplyAsync(() -> {
if (logFile == null)
return List.<Log>of();

// limit the lines of log
int limit = Log.getLogLines();
CircularArrayList<String> tail = new CircularArrayList<>(limit);
try (Stream<String> lines = Files.lines(logFile, MonitorProtocol.CHARSET)) {
lines.forEach(line -> {
if (tail.size() == limit)
tail.removeFirst();
tail.addLast(line);
});
} catch (IOException | UncheckedIOException e) {
LOG.warning("Failed to read the session log file " + logFile, e);
}

Files.deleteIfExists(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(process, exitType, instance, Objects.requireNonNull(result.launchOptions),
exception == null ? logs : List.of()).show();
}).start();
}
}
Loading
Loading