Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
164 changes: 164 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/GameProcessManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* 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.game;

import javafx.beans.binding.Bindings;
import javafx.beans.binding.IntegerBinding;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.LogWindow;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.CircularArrayList;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.platform.ManagedProcess;

import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.Map;

/// Manager of game processes launched by HMCL.
///
/// @author Calboot
public final class GameProcessManager {

private GameProcessManager() {
}

@FXThread
private static final Map<String, Integer> idToLaunchedCount = new HashMap<>();

private static final ObservableList<GameProcessHolder> aliveProcessHolders = FXCollections.observableArrayList();

public static final ObservableList<GameProcessHolder> displayedHolders = FXCollections.observableArrayList();

public static final IntegerBinding aliveProcessCount = Bindings.size(aliveProcessHolders);

private static final BooleanProperty display = new SimpleBooleanProperty() {
@Override
public void invalidated() {
if (display.get()) {
updateDisplay();
} else {
displayedHolders.clear();
}
}
};

@FXThread
public static void setDisplay(boolean display) {
GameProcessManager.display.set(display);
}

public static void updateDisplay() {
FXUtils.runInFX(() -> {
aliveProcessHolders.removeIf(holder -> holder.exited.get());
displayedHolders.setAll(aliveProcessHolders);
});
}

public static void add(LauncherHelper.HMCLProcessListener processListener) {
FXUtils.runInFX(() -> {
var holder = new GameProcessHolder(processListener);
aliveProcessHolders.add(0, holder);
if (display.get()) displayedHolders.add(0, holder);
});
}

private static void remove(GameProcessHolder holder) {
FXUtils.runInFX(() -> aliveProcessHolders.remove(holder));
}

public static final class GameProcessHolder {

private final WeakReference<ManagedProcess> processRef;
private WeakReference<LogWindow> logWindowRef;

@SuppressWarnings("FieldCanBeLocal")
private final WeakListenerHolder holder = new WeakListenerHolder();

private final String id;
private final HMCLGameInstance instance;

private final CircularArrayList<Log> logs;
private final ReadOnlyStringWrapper lastLogLine = new ReadOnlyStringWrapper(null);
private final ReadOnlyBooleanWrapper exited = new ReadOnlyBooleanWrapper();

private GameProcessHolder(LauncherHelper.HMCLProcessListener processListener) {
this.processRef = new WeakReference<>(processListener.getProcess());
this.logWindowRef = new WeakReference<>(processListener.getLogWindow());
this.instance = processListener.getGameInstance();
this.logs = processListener.getLogs();
this.lastLogLine.bind(processListener.getLogWindow().lastLogLineProperty());
{
String id = processListener.getGameInstance().getId().id();
int i = idToLaunchedCount.computeIfAbsent(id, k -> 0) + 1;
idToLaunchedCount.put(id, i);
this.id = id + " #" + i;
}
holder.onWeakChangeAndOperate(processListener.exitedProperty(), b -> {
if (b) {
remove(this);
this.exited.set(true);
}
});
}

public String getId() {
return id;
}

public ReadOnlyStringProperty lastLogLineProperty() {
return lastLogLine.getReadOnlyProperty();
}

public ReadOnlyBooleanProperty exitedProperty() {
return exited.getReadOnlyProperty();
}

public void openSettings() {
Instances.modifyGameSettings(instance);
}

public void relaunch() {
if (exitedProperty().get()) Instances.launch(instance);
}

public void showLogWindow() {
LogWindow logWindow;
LogWindow cached;
if ((cached = logWindowRef.get()) == null) {
logWindow = new LogWindow();
logWindow.logLines(logs);
logWindowRef = new WeakReference<>(logWindow);
} else {
logWindow = cached;
}
logWindow.show();
logWindow.requestFocus();
}

public void terminate() {
var process = processRef.get();
if (process != null) process.stop();
}
}

}
85 changes: 53 additions & 32 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
package org.jackhuang.hmcl.game;

import com.jfoenix.controls.JFXButton;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.stage.Stage;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.auth.*;
Expand Down Expand Up @@ -47,6 +49,7 @@
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
Expand Down Expand Up @@ -97,7 +100,7 @@ public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
this.setting = gameInstance.getEffectiveSettings();
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
this.launchingStepsPane.setTitle(i18n("instance.launch"));
this.launchingStepsPane.setTitle(i18n("instance.launch") + " - " + gameInstance.getId().id());
}

public HMCLGameInstance getGameInstance() {
Expand Down Expand Up @@ -290,7 +293,7 @@ private void launch0() {
launchOptions,
launcherVisibility == LauncherVisibility.CLOSE
? null // Unnecessary to start listening to game process output when close launcher immediately after game launched.
: new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0)
: new HMCLProcessListener(this, authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0)
);
}).thenComposeAsync(launcher -> { // launcher is prev task's result
if (scriptFile == null) {
Expand Down Expand Up @@ -834,9 +837,10 @@ private void enableAutoAgentForCurrentSetting() {
/// The managed process listener.
/// Guarantee that one Java [Process], one [HMCLProcessListener].
/// Because every time we launched a game, we generates a new [HMCLProcessListener]
private final class HMCLProcessListener implements ProcessListener {
public static final class HMCLProcessListener implements ProcessListener {

private final ReentrantLock lock = new ReentrantLock();
private final LauncherHelper launcherHelper;
private final LaunchOptions launchOptions;
private ManagedProcess process;
private volatile boolean lwjgl;
Expand All @@ -848,14 +852,37 @@ private final class HMCLProcessListener implements ProcessListener {
private Thread submitLogThread;
private LinkedBlockingQueue<Log> logBuffer;

public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
private final ReadOnlyBooleanWrapper exited = new ReadOnlyBooleanWrapper(false);

public HMCLProcessListener(LauncherHelper launcherHelper, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.launcherHelper = launcherHelper;
this.launchOptions = launchOptions;
this.launchingLatch = launchingLatch;
this.detectWindow = detectWindow;
this.forbiddenAccessToken = authInfo != null ? authInfo.getAccessToken() : null;
this.logs = new CircularArrayList<>(Log.getLogLines() + 1);
}

public ManagedProcess getProcess() {
return process;
}

public LogWindow getLogWindow() {
return logWindow;
}

public CircularArrayList<Log> getLogs() {
return logs;
}

public HMCLGameInstance getGameInstance() {
return launcherHelper.gameInstance;
}

public ReadOnlyBooleanProperty exitedProperty() {
return exited.getReadOnlyProperty();
}

@Override
public void setProcess(ManagedProcess process) {
this.process = process;
Expand All @@ -869,11 +896,14 @@ public void setProcess(ManagedProcess process) {
LOG.info("Process ClassPath: " + classpath);
}

if (showLogs) {
{
CountDownLatch logWindowLatch = new CountDownLatch(1);
runLater(() -> {
logWindow = new LogWindow(process, logs);
logWindow.show();
logWindow.logLine(new Log(Logger.filterForbiddenToken("Command: " + new CommandBuilder().addAll(process.getCommands())), Log4jLevel.INFO));
if (process.getClasspath() != null)
logWindow.logLine(new Log("ClassPath: " + process.getClasspath(), Log4jLevel.INFO));
if (launcherHelper.showLogs) logWindow.show();
logWindowLatch.countDown();
});

Expand Down Expand Up @@ -923,10 +953,12 @@ public void run() {
Thread.currentThread().interrupt();
}
}

GameProcessManager.add(this);
}

private void finishLaunch() {
switch (launcherVisibility) {
switch (launcherHelper.launcherVisibility) {
case HIDE_AND_REOPEN:
runLater(() -> {
// If application was stopped and execution services did not finish termination,
Expand Down Expand Up @@ -973,20 +1005,9 @@ public void onLog(String log, boolean isErrorStream) {
log = log.replace(forbiddenAccessToken, "<access token>");

Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
if (showLogs) {
if (level == null)
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
logBuffer.add(new Log(log, level));
} else {
lock.lock();
try {
logs.addLast(new Log(log, level));
if (logs.size() > Log.getLogLines())
logs.removeFirst();
} finally {
lock.unlock();
}
}
if (level == null)
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
logBuffer.add(new Log(log, level));

if (!lwjgl) {
String lowerCaseLog = log.toLowerCase(Locale.ROOT);
Expand All @@ -1006,16 +1027,16 @@ public void onLog(String log, boolean isErrorStream) {

@Override
public void onExit(int exitCode, ExitType exitType) {
if (showLogs) {
logBuffer.add(new Log(String.format("[%s] [HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", TIME_FORMATTER.format(Instant.now()), exitCode, exitCode, exitType), Log4jLevel.INFO));
submitLogThread.interrupt();
try {
submitLogThread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
logBuffer.add(new Log(String.format("[%s] [HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", TIME_FORMATTER.format(Instant.now()), exitCode, exitCode, exitType), Log4jLevel.INFO));
submitLogThread.interrupt();
try {
submitLogThread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

runInFX(() -> exited.set(true));

launchingLatch.countDown();

if (exitType == ExitType.INTERRUPTED)
Expand All @@ -1033,11 +1054,11 @@ public void onExit(int exitCode, ExitType exitType) {
}

if (exitType != ExitType.NORMAL) {
gameInstance.markLaunchedAbnormally();
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
launcherHelper.gameInstance.markLaunchedAbnormally();
runLater(() -> new GameCrashWindow(process, exitType, launcherHelper.gameInstance, launchOptions, logs).show());
}

checkExit();
launcherHelper.checkExit();
}

}
Expand Down
8 changes: 8 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.instances.GameProcessPage;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
Expand Down Expand Up @@ -106,6 +107,7 @@ public final class Controllers {
});
private static LauncherSettingsPage settingsPage;
private static Lazy<TerracottaPage> terracottaPage = new Lazy<>(TerracottaPage::new);
private static Lazy<GameProcessPage> gameProcessPage = new Lazy<>(GameProcessPage::new);

private Controllers() {
}
Expand Down Expand Up @@ -195,6 +197,11 @@ public static Node getTerracottaPage() {
return terracottaPage.get();
}

@FXThread
public static Node getGameProcessPage() {
return gameProcessPage.get();
}

/// Returns the initialized main-window decorator.
///
/// @return the application-wide main-window decorator
Expand Down Expand Up @@ -638,6 +645,7 @@ public static void shutdown() {
accountListPage = null;
settingsPage = null;
terracottaPage = null;
gameProcessPage = null;
decorator = null;

FXUtils.shutdown();
Expand Down
Loading