Compare commits

...
Author SHA1 Message Date
layue13andClaude Sonnet 5.5 d02fb95bd4 fix: LauncherHelper 缺 Instances 导入
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 9m11s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:22:36 +08:00
layue13andClaude Sonnet 5.5 8d1c01aed6 fix: lzh 翻译用字检查
Java CI / build (pull_request) Failing after 10m34s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:09:45 +08:00
layue13andClaude Sonnet 5.5 4ee5c0984f feat: 高性能 GPU 默认开启、首页显示服务器 MOTD 公告、游戏请求重启后更新并重启
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Failing after 6m56s
- highPerformance 默认 true(Windows 同时登记 java.exe 与 javaw.exe),Linux AMD/nouveau 加 DRI_PRIME
- ServerStatusPing 解析 description,首页在服务器公告区显示,失败静默
- 游戏正常退出且运行目录有 tdlm-restart-request.json 时,删除文件并询问是否更新并重启

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 12:49:09 +08:00
layue13 eb7fb914cb Merge pull request 'feat: Start 按钮安装/更新,有更新时显示「更新」' (#22) from merge/start-button-update into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Successful in 14m6s
2026-11-10 14:47:09 +00:00
layue13andCursor adbdc85735 feat: show Update on home button when client pack is behind
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 14m46s
Compare the installed server-manifest version and file hashes with fileApi on the home page, and switch the Start button to Update when a differential sync is needed. Also persist the remote pack version after completion.

Co-authored-by: Cursor <[email protected]>
2026-11-10 22:44:01 +08:00
layue13andCursor 8dd4ef09aa merge: Start button installs/updates client (HMC-22)
Bring in ensureClientAndJoin so first install and updates are triggered by the home Start/Join button, not by opening the launcher. Differential updates still run on the launch completion path.

Co-authored-by: Cursor <[email protected]>
2026-11-10 22:41:15 +08:00
liuhao 5ca7bcaad0 fix: prefer self-hosted mirror for game metadata, libraries, forge and assets
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Publish TDLM launcher / publish (push) Skipped
Publish TDLM launcher / noop (push) Successful in 2s
Java CI / build (push) Successful in 17m34s
BMCLAPI 302-redirects to CDNs (sakura.sld.tw, cernet) that are TLS-blocked on
many mainland networks, leaving the official origins as the only fallback and
stalling launches at the 1.7.10 asset index with 0 B/s. The VPS now hosts the
pack's full dependency set; try it first with BMCLAPI as fallback.
2026-11-10 12:08:35 +00:00
layue13 a19679fd00 ux: Start auto-installs client; hide install and instance UI
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 8m38s
HMC-22: the home Join/Start button runs fileApi install when needed,
then joins. Differential updates stay on the launch completion path.
Remove the instance-management sidebar entry and stop opening the
modpack install wizard from startup, empty launch, or drag-drop.
2026-11-10 05:01:58 +00:00
17 changed files with 492 additions and 166 deletions

No files matched your search

@@ -133,6 +133,13 @@ public final class HMCLDownloadProvider extends DownloadProvider {
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
this.rules = List.of(
// The self-hosted client mirror carries the pack's full dependency set. BMCLAPI's
// redirect CDNs (sakura.sld.tw, cernet) and the Mojang/Forge origins are TLS-blocked
// on many mainland networks, so the VPS must be tried before any of them.
new SimpleRule("https://piston-meta.mojang.com", Metadata.getClientFileApi() + "/piston-meta", "https://bmclapi2.bangbang93.com"),
new SimpleRule("https://libraries.minecraft.net", Metadata.getClientFileApi() + "/libraries", "https://bmclapi2.bangbang93.com/libraries"),
new SimpleRule("https://maven.minecraftforge.net", Metadata.getClientFileApi() + "/forge-maven", "https://bmclapi2.bangbang93.com/maven"),
new SimpleRule("https://launcher.mojang.com", Metadata.getClientFileApi() + "/launcher-mojang", "https://bmclapi2.bangbang93.com"),
new MultiRootRule("https://bmclapi2.bangbang93.com", Function.identity()),
new MultiRootRule("https://bmclapi.bangbang93.com", Function.identity()),
new MultiRootRule("https://launchermeta.mojang.com", Function.identity()),
@@ -301,11 +308,18 @@ public final class HMCLDownloadProvider extends DownloadProvider {
@Override
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
String location = assetObject.getLocation();
return getCandidates(
fileSource,
WebURL.parse("https://resources.download.minecraft.net/" + location),
mirrorUrls("/assets/" + location)
);
WebURL officialUrl = WebURL.parse("https://resources.download.minecraft.net/" + location);
List<WebURL> mirrors = mirrorUrls("/assets/" + location);
if (preferMirror(fileSource)) {
// Keep the VPS strictly first: spreadPrimary would otherwise rotate BMCLAPI ahead of
// it, and BMCLAPI's asset CDN is unreachable on blocked networks (per-file timeouts).
var candidates = new ArrayList<DownloadCandidate>(mirrors.size() + 2);
candidates.add(DownloadCandidate.of(WebURL.parse(Metadata.getClientFileApi() + "/assets/" + location)));
candidates.addAll(spreadPrimary(mirrors.stream().map(DownloadCandidate::of).toList(), officialUrl.href()));
candidates.add(DownloadCandidate.of(officialUrl));
return DownloadCandidates.of(List.copyOf(candidates));
}
return getCandidates(fileSource, officialUrl, mirrors);
}
@NotNullByDefault
@@ -22,6 +22,7 @@ import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.LauncherVisibility;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
@@ -255,32 +256,12 @@ public final class LauncherHelper {
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
try {
WinReg reg = WinReg.INSTANCE;
if (reg != null) {
Object current = reg.queryValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg
);
if (current == null) {
boolean result = reg.setValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg,
"GpuPreference=2;"
);
if (result)
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
else
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
} else {
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
}
}
} catch (Exception e) {
LOG.warning("Failed to apply high performance GPU preference", e);
// Register both java.exe and javaw.exe: the game may be started through either one.
applyHighPerformanceGpuPreference(context.javaExecutable);
Path sibling = context.javaExecutable.resolveSibling(
FileUtils.getName(context.javaExecutable).equalsIgnoreCase("javaw.exe") ? "java.exe" : "javaw.exe");
if (Files.isRegularFile(sibling)) {
applyHighPerformanceGpuPreference(sibling);
}
}
@@ -1048,11 +1029,44 @@ public final class LauncherHelper {
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
}
if (exitType == ExitType.NORMAL) {
// The game asked to be restarted (hot update): offer to update and relaunch.
String restartReason = RestartRequest.consume(gameInstance.getRunDirectory());
if (restartReason != null) {
LOG.info("Game requested a restart, reason: " + restartReason);
runLater(() -> Instances.offerRestart(gameInstance, restartReason));
}
}
checkExit();
}
}
/// Asks Windows to run `executable` on the high-performance GPU (`GpuPreference=2`) unless the user
/// already has a preference stored for it.
private static void applyHighPerformanceGpuPreference(Path executable) {
String path = FileUtils.getAbsolutePath(executable);
try {
WinReg reg = WinReg.INSTANCE;
if (reg == null) {
return;
}
Object current = reg.queryValue(WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences", path);
if (current != null) {
LOG.info("GPU preference for %s already exists: %s".formatted(path, current));
} else if (reg.setValue(WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences", path, "GpuPreference=2;")) {
LOG.info("Successfully applied high performance GPU preference for java: " + path);
} else {
LOG.warning("Failed to apply high performance GPU preference for java: " + path);
}
} catch (Exception e) {
LOG.warning("Failed to apply high performance GPU preference", e);
}
}
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
public static int countMangedProcesses() {
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonObject;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/// The "please restart me" handshake between the game and the launcher.
///
/// When the game wants to be restarted (for example after a hot update), it writes
/// [#FILE_NAME] into its run directory as `{"reason":"update"}` and exits normally. After the
/// process ends the launcher calls [#consume(Path)], deletes the file and offers to update and relaunch.
@NotNullByDefault
public final class RestartRequest {
private RestartRequest() {
}
/// The marker file name inside the game's run directory.
public static final String FILE_NAME = "tdlm-restart-request.json";
/// Reads and deletes the restart request in `runDirectory`.
///
/// A malformed file still counts as a request with reason `"unknown"`, because the file's presence is
/// the signal. The file is always removed so a request cannot trigger twice.
///
/// @param runDirectory the game's run directory
/// @return the reason, or `null` if no request was pending
public static @Nullable String consume(Path runDirectory) {
Path file = runDirectory.resolve(FILE_NAME);
if (!Files.isRegularFile(file)) {
return null;
}
String reason = "unknown";
try {
JsonObject object = JsonUtils.GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), JsonObject.class);
if (object != null && object.has("reason") && object.get("reason").isJsonPrimitive()) {
reason = object.get("reason").getAsString();
}
} catch (IOException | RuntimeException ignored) {
// The presence of the file is the signal; keep the default reason.
}
try {
Files.deleteIfExists(file);
} catch (IOException ignored) {
// Nothing sensible to do; the worst case is one more prompt on the next exit.
}
return reason;
}
}
@@ -645,9 +645,12 @@ public sealed abstract class GameSettings extends ObservableSetting {
public static final String PROPERTY_HIGH_PERFORMANCE = "highPerformance";
/// Whether to launch games with high-performance GPU.
///
/// On by default: on a laptop with an integrated and a discrete GPU, Windows otherwise picks the
/// integrated one for java. Machines with a single GPU are unaffected by the preference.
@SerializedName(PROPERTY_HIGH_PERFORMANCE)
private final InheritableProperty<Boolean> highPerformance =
newInheritableProperty(PROPERTY_HIGH_PERFORMANCE, false);
newInheritableProperty(PROPERTY_HIGH_PERFORMANCE, true);
/// Returns the high performance GPU preference property.
public InheritableProperty<Boolean> highPerformanceProperty() {
@@ -27,16 +27,11 @@ import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.ui.account.AccountListPage;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.construct.*;
@@ -359,8 +354,8 @@ public final class Controllers {
);
}
/// Installs a launcher-bundled modpack when present; otherwise, when the repository has no
/// instances yet, pulls the org client [Metadata#getClientManifestUrl] and opens the install wizard.
/// Installs a launcher-bundled modpack when present. First-run remote install is deferred to the
/// home Start button ([Instances#ensureClientAndJoin]) so players never enter the install wizard.
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
if (modpackFile != null) {
@@ -384,45 +379,7 @@ public final class Controllers {
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
);
return;
}
if (repository.getDisplayInstances().findAny().isPresent()) {
return;
}
String manifestUrl = Metadata.getClientManifestUrl();
LOG.info("No instances yet; fetching client Server Modpack from " + manifestUrl);
Controllers.taskDialog(
new GetTask(WebURL.parse(manifestUrl))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
LOG.warning("Failed to fetch client Server Modpack manifest", exception);
Controllers.dialog(
manifestUrl + "\n" + exception.getMessage(),
i18n("message.error"),
MessageType.ERROR);
Instances.importModpack();
return;
}
ServerModpackManifest manifest =
JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
if (manifest == null) {
LOG.warning("Malformed client Server Modpack manifest from " + manifestUrl);
Controllers.dialog(
i18n("modpack.type.server.malformed"),
i18n("message.error"),
MessageType.ERROR);
Instances.importModpack();
return;
}
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
}),
i18n("message.downloading"),
TaskCancellationAction.NO_CANCEL
);
}
/// Shows the content in the main-window dialog stack without waiting for it to close.
@@ -14,6 +14,7 @@ import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.*;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
@@ -25,21 +26,26 @@ import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.download.LocalModpackPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -48,63 +54,198 @@ public final class Instances {
private Instances() {
}
/// Installs or updates the TDLM client.
/// Returns whether the installed server modpack is behind the remote `server-manifest.json`.
///
/// Prefers a launcher-bundled zip, then the org [Metadata#getClientManifestUrl] Server Modpack,
/// then the generic local/remote import wizard.
public static void installServerModpack() {
/// Compares the local embedded manifest version (and file path/hash set) with
/// `fileApi/server-manifest.json`. Network or parse failures return `false` so the home button
/// stays on "join".
public static boolean isClientUpdateAvailable(@Nullable HMCLGameInstance instance) {
if (instance == null) {
return false;
}
try {
Path configurationFile = instance.getModpackConfigurationFile();
if (Files.notExists(configurationFile)) {
return false;
}
ModpackConfiguration<ServerModpackManifest> configuration = JsonUtils.fromJsonFile(
configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
ServerModpackManifest local = configuration.getManifest();
if (local == null || StringUtils.isBlank(local.getFileApi())) {
return false;
}
String manifestUrl = StringUtils.removeSuffix(local.getFileApi().trim(), "/") + "/server-manifest.json";
ServerModpackManifest remote = JsonUtils.fromNonNullJson(
NetworkUtils.doGet(manifestUrl), ServerModpackManifest.class);
if (!Objects.equals(local.getVersion(), remote.getVersion())) {
return true;
}
return !fileHashes(local).equals(fileHashes(remote));
} catch (Exception e) {
LOG.warning("Failed to check TDLM client update", e);
return false;
}
}
private static Map<String, String> fileHashes(ServerModpackManifest manifest) {
return manifest.getFiles().stream().collect(Collectors.toMap(
ModpackConfiguration.FileInformation::getPath,
ModpackConfiguration.FileInformation::getHash,
(left, right) -> right));
}
/// Installs or updates the TDLM client from fileApi / bundled zip without opening the install UI,
/// then joins the configured server when ready.
///
/// Prefer this for the home Start / Join button. Existing server-modpack instances get differential
/// updates during launch via [org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask].
public static void ensureClientAndJoin() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (!repository.isLoaded()) {
return;
}
// Already installed: join immediately. Launch runs ServerModpackCompletionTask for updates.
if (repository.getSelectedInstance() != null && Metadata.findBundledModpackFile() == null) {
joinServer(repository.getSelectedInstance());
return;
}
Path bundled = Metadata.findBundledModpackFile();
if (bundled != null) {
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, bundled),
i18n("install.modpack"));
installBundledThenJoin(repository, bundled);
return;
}
Controllers.taskDialog(
new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
Controllers.dialog(
exception.getMessage(),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
importModpack();
return;
}
.thenComposeAsync(Schedulers.javafx(), result -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
result, ServerModpackManifest.class);
if (manifest == null) {
throw new IOException(i18n("modpack.type.server.malformed"));
}
return ensureRemoteClientInstalled(repository, manifest);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install or update TDLM client", exception);
Controllers.dialog(
i18n("modpack.type.server.malformed"),
i18n("message.error"),
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
importModpack();
return;
}
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
}),
i18n("message.downloading"),
i18n("modpack.installing"),
TaskCancellationAction.NORMAL);
}
public static void importModpack() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (repository.isLoaded()) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository), i18n("install.modpack"));
}
/// Silent first-install / update from the org client manifest — no wizard UI.
public static void installServerModpack() {
ensureClientAndJoin();
}
/// @deprecated use [#installServerModpack()]
/// Install wizard is disabled for the dedicated server-client launcher.
public static void importModpack() {
LOG.info("Ignoring importModpack: install UI is disabled; use Start to install/update");
}
/// @deprecated use [#ensureClientAndJoin()]
public static void addNewGame() {
installServerModpack();
ensureClientAndJoin();
}
/// Installs a local modpack zip without the wizard, then selects the new instance.
public static void installLocalModpackSilently(HMCLGameRepository repository, Path file) {
if (!repository.isLoaded()) {
return;
}
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(file);
String name = LocalModpackPage.processModpackName(modpack.getName());
if (StringUtils.isBlank(name)) {
name = "TDLM";
}
GameInstanceID instanceId = new GameInstanceID(name);
if (repository.hasInstance(instanceId)) {
instanceId = new GameInstanceID(name + "-" + System.currentTimeMillis());
}
GameInstanceID id = instanceId;
return ModpackHelper.getInstallTask(repository, file, id, modpack, null)
.thenRunAsync(Schedulers.javafx(), () ->
repository.setSelectedInstance(repository.getInstance(id)));
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install local modpack", exception);
Controllers.dialog(
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
}
}),
i18n("modpack.installing"),
TaskCancellationAction.NORMAL);
}
private static void installBundledThenJoin(HMCLGameRepository repository, Path bundled) {
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(bundled);
String name = LocalModpackPage.processModpackName(modpack.getName());
if (StringUtils.isBlank(name)) {
name = "TDLM";
}
GameInstanceID instanceId = new GameInstanceID(name);
return ModpackHelper.getInstallTask(repository, bundled, instanceId, modpack, null)
.thenRunAsync(Schedulers.javafx(), () -> {
repository.setSelectedInstance(repository.getInstance(instanceId));
try {
Files.deleteIfExists(bundled);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + bundled, e);
}
});
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
Controllers.dialog(
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
return;
}
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
}),
i18n("modpack.installing"),
TaskCancellationAction.NO_CANCEL);
}
/// Installs when missing; selects an existing instance otherwise.
///
/// Differential updates for an installed server modpack run during launch via
/// [org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask].
private static Task<?> ensureRemoteClientInstalled(
HMCLGameRepository repository, ServerModpackManifest manifest) throws IOException {
Modpack modpack = manifest.toModpack();
String name = LocalModpackPage.processModpackName(manifest.getName());
if (StringUtils.isBlank(name)) {
name = "TDLM";
}
GameInstanceID instanceId = new GameInstanceID(name);
if (repository.hasInstance(instanceId)) {
repository.setSelectedInstance(repository.getInstance(instanceId));
return Task.completed(null);
}
return ModpackHelper.getInstallTask(repository, manifest, instanceId, modpack)
.thenRunAsync(Schedulers.javafx(), () ->
repository.setSelectedInstance(repository.getInstance(instanceId)));
}
public static void deleteInstance(HMCLGameInstance gameInstance) {
@@ -257,8 +398,25 @@ public final class Instances {
.addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false)));
}
/// Asks whether to update and relaunch after the game requested a restart (see [RestartRequest]).
///
/// Yes runs the Start flow, which syncs the client pack and joins the server again; No leaves the
/// launcher as it is. The request file is already deleted, so the prompt shows only once.
///
/// @param gameInstance the instance that just exited
/// @param reason the reason from the request file, for example `update`
public static void offerRestart(HMCLGameInstance gameInstance, String reason) {
Controllers.confirm(
i18n("home.restart." + ("update".equals(reason) ? "update" : "generic")),
i18n("home.restart.title"),
() -> updateInstance(gameInstance),
null);
}
/// Updates via fileApi through the Start flow — no install wizard.
public static void updateInstance(HMCLGameInstance gameInstance) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(gameInstance.getRepository(), gameInstance.getId()));
gameInstance.getRepository().setSelectedInstance(gameInstance);
ensureClientAndJoin();
}
/// Queues an asset check with a fresh asset index for the supplied instance.
@@ -338,14 +496,7 @@ public final class Instances {
@SafeVarargs
public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
if (gameInstance == null) {
JFXButton installModpack = new JFXButton(i18n("install.modpack"));
installModpack.getStyleClass().add("dialog-accept");
installModpack.setOnAction(e -> installServerModpack());
Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"),
MessageDialogPane.MessageType.ERROR,
installModpack,
null);
ensureClientAndJoin();
return;
}
@@ -412,11 +563,10 @@ public final class Instances {
Controllers.navigate(Controllers.getSettingsPage());
}
/// Instance management UI is disabled for the dedicated server-client launcher.
public static void modifyGameSettings(HMCLGameInstance gameInstance) {
Controllers.getGameInstancePage().setInstance(gameInstance.getId(), gameInstance.getRepository());
Controllers.getGameInstancePage().showInstanceSettings();
// VersionPage.loadVersion will be invoked after navigation
Controllers.navigate(Controllers.getGameInstancePage());
LOG.info("Ignoring modifyGameSettings for " + gameInstance.getId()
+ ": instance management UI is disabled");
}
}
@@ -58,6 +58,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
.flatMap(HMCLGameRepository::snapshotProperty);
/// Bumped when the home join button starts a new remote-update check, so stale results are ignored.
private int updateCheckGeneration;
{
HBox titleNode = new HBox(8);
titleNode.setPadding(new Insets(0, 0, 0, 2));
@@ -133,26 +136,40 @@ public final class MainPage extends StackPane implements DecoratorPage {
card.getStyleClass().add("home-card");
card.setAlignment(Pos.CENTER_LEFT);
card.setMaxHeight(Region.USE_PREF_SIZE);
StackPane.setAlignment(card, Pos.BOTTOM_CENTER);
// Server announcement (the server's MOTD), shown above the card only when there is one.
Label motdLabel = new Label();
motdLabel.getStyleClass().add("home-subtitle");
motdLabel.setWrapText(true);
motdLabel.setMaxHeight(Region.USE_PREF_SIZE);
Label motdTitle = factLabel(i18n("home.motd"));
VBox motdBox = new VBox(3, motdTitle, motdLabel);
motdBox.setPadding(new Insets(0, 0, 14, 4));
motdBox.setVisible(false);
motdBox.setManaged(false);
VBox bottom = new VBox(motdBox, card);
bottom.setMaxHeight(Region.USE_PREF_SIZE);
StackPane.setAlignment(bottom, Pos.BOTTOM_CENTER);
// Start always installs/updates from fileApi then joins — never opens install or instance UI.
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
FXUtils.onChangeAndOperate(currentGameProperty(), currentGame -> {
refreshJoinButton(joinButton, currentGame);
if (currentGame == null) {
joinButton.setText(i18n("instance.launch.empty"));
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launchNoGame);
packValue.setText(i18n("home.not_installed"));
routeFact.setVisible(false);
routeFact.setManaged(false);
serverFact.setVisible(false);
serverFact.setManaged(false);
routeFact.setOnMouseClicked(null);
routeFact.setCursor(Cursor.DEFAULT);
} else {
joinButton.setText(i18n("server.join"));
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
packValue.setText(currentGame.getId().toString());
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(currentGame.getModpackConfigurationFile());
routeFact.setVisible(route != null);
routeFact.setManaged(route != null);
routeValue.setText(route != null ? route.name() : "");
refreshServerStatus(route, serverFact, serverValue);
refreshServerStatus(route, serverFact, serverValue, motdBox, motdLabel);
// With more than one route (for example direct and CDN), the route can be switched.
List<ServerEndpoint.Route> routes = ServerEndpoint.routes(currentGame.getModpackConfigurationFile());
@@ -169,7 +186,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
items.add(new IconedMenuItem(null, candidate.name(), () -> {
ServerEndpoint.select(currentGame.getModpackConfigurationFile(), candidate);
routeValue.setText(candidate.name() + " ▾");
refreshServerStatus(candidate, serverFact, serverValue);
refreshServerStatus(candidate, serverFact, serverValue, motdBox, motdLabel);
}, popup));
}
list.getContent().setAll(items);
@@ -178,32 +195,64 @@ public final class MainPage extends StackPane implements DecoratorPage {
}
});
getChildren().addAll(art, hero, card);
getChildren().addAll(art, hero, bottom);
}
/// Sets the home button to install / update / join based on the selected instance and remote manifest.
private void refreshJoinButton(JFXButton joinButton, @Nullable HMCLGameInstance currentGame) {
int generation = ++updateCheckGeneration;
if (currentGame == null) {
joinButton.setText(i18n("instance.launch.empty"));
return;
}
joinButton.setText(i18n("server.join"));
Schedulers.io().execute(() -> {
boolean updateAvailable = Instances.isClientUpdateAvailable(currentGame);
FXUtils.runInFX(() -> {
if (generation != updateCheckGeneration || getCurrentGame() != currentGame) {
return;
}
joinButton.setText(i18n(updateAvailable ? "server.update" : "server.join"));
});
});
}
/// Pings the server behind `route` in the background and shows the result in `serverValue`.
///
/// WebSocket routes cannot be pinged with plain TCP, so the status is hidden for them.
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue) {
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue,
Node motdBox, Label motdLabel) {
String address = route == null ? "" : route.address().trim();
boolean pingable = !address.isEmpty() && !address.toLowerCase(java.util.Locale.ROOT).startsWith("ws");
serverFact.setVisible(pingable);
serverFact.setManaged(pingable);
motdBox.setVisible(false);
motdBox.setManaged(false);
if (!pingable) {
return;
}
serverValue.setText("…");
Schedulers.io().execute(() -> {
String text;
String motd = "";
try {
ServerAddress parsed = ServerAddress.parse(address);
ServerStatusPing.Status status = ServerStatusPing.ping(parsed.host(), parsed.port() >= 0 ? parsed.port() : 25565, 3000);
text = status.online() + " / " + status.max() + " · " + status.latencyMillis() + " ms";
motd = status.motd();
} catch (IOException | IllegalArgumentException e) {
text = i18n("home.server.unreachable");
}
String result = text;
FXUtils.runInFX(() -> serverValue.setText(result));
String announcement = motd;
FXUtils.runInFX(() -> {
serverValue.setText(result);
// Unreachable or empty MOTD: stay silent, the announcement area stays hidden.
boolean show = !announcement.isEmpty();
motdLabel.setText(announcement);
motdBox.setVisible(show);
motdBox.setManaged(show);
});
});
}
@@ -214,14 +263,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
return label;
}
/// Installs or updates the client from fileApi when needed, then launches into the server.
private void launch() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
Instances.joinServer(repository.getSelectedInstance());
}
/// Opens the server modpack install/update flow when no instance is selected.
private void launchNoGame() {
Instances.installServerModpack();
Instances.ensureClientAndJoin();
}
@Override
@@ -4,7 +4,6 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.ReadOnlyObjectProperty;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
@@ -17,8 +16,6 @@ import org.jackhuang.hmcl.ui.account.AccountListPopupMenu;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.instances.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
@@ -43,20 +40,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
accountListItem.setOnAction(e -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -accountListItem.getHeight()));
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
// second item in left sidebar
GameAdvancedListItem gameInstanceItem = new GameAdvancedListItem();
gameInstanceItem.setOnAction(e -> {
@Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instance == null) {
Instances.installServerModpack();
} else {
Instances.modifyGameSettings(instance);
}
});
if (AnimationUtils.isAnimationEnabled()) {
gameInstanceItem.setPrepareAction(Controllers::prepareGameInstancePage);
}
// Instance management entry removed: Start on the home page installs/updates and joins.
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
launcherSettingsItem.setTitle(i18n("settings"));
@@ -68,9 +52,8 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
launcherSettingsItem.setPrepareAction(Controllers::prepareSettingsPage);
}
// The sidebar holds the modpack and settings entries; the account sits at the bottom-left.
// Sidebar: settings only; account sits at the bottom-left.
getSidebar()
.add(gameInstanceItem)
.add(launcherSettingsItem);
setSidebarBottom(accountListItem);
@@ -92,9 +75,9 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
modpacks -> {
Path file = modpacks.get(0);
if (ModpackHelper.isFileModpackByExtension(file)) {
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file),
i18n("install.modpack"));
// Silent install — no modpack wizard UI.
Instances.installLocalModpackSilently(
GameDirectoryManager.getSelectedRepository(), file);
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
}
@@ -808,7 +808,12 @@ first_run.microsoft=Sign in with Microsoft
first_run.invalid=Use 1 to 16 letters, digits or underscores.
home.server=Server
home.server.unreachable=Unreachable
home.motd=Server announcement
home.restart.title=Restart game
home.restart.update=The game closed to apply an update. Update the client and start it again now?
home.restart.generic=The game asked to be restarted. Update the client and start it again now?
server.join=Join Server
server.update=Update
instance.launch_and_enter_world=Play World
instance.launch.empty=Start Game
instance.launch.empty.installing=Installing Game
@@ -628,7 +628,12 @@ first_run.microsoft=登正版
first_run.invalid=一至十六字,限英文數字底綫。
home.server=服
home.server.unreachable=不通
home.motd=服公告
home.restart.title=重啟遊戲
home.restart.update=遊戲爲更新而止。今更新並重啟乎?
home.restart.generic=遊戲請重啟。今更新並重啟乎?
server.join=入服
server.update=更新
# instance.launch_and_enter_world=
instance.launch.empty=啟戲
instance.launch.empty.installing=裝戲
@@ -631,7 +631,12 @@ first_run.microsoft=登录正版账号
first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。
home.server=服务器
home.server.unreachable=连接不上
home.motd=服务器公告
home.restart.title=重启游戏
home.restart.update=游戏为应用更新已退出。现在更新客户端并重新启动游戏吗?
home.restart.generic=游戏请求重启。现在更新客户端并重新启动吗?
server.join=进入服务器
server.update=更新
instance.launch_and_enter_world=进入世界
instance.launch.empty=开始游戏
instance.launch.empty.installing=安装游戏
@@ -625,7 +625,12 @@ first_run.microsoft=登入正版帳號
first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。
home.server=伺服器
home.server.unreachable=無法連線
home.motd=伺服器公告
home.restart.title=重啟遊戲
home.restart.update=遊戲為套用更新已結束。現在更新客戶端並重新啟動遊戲嗎?
home.restart.generic=遊戲要求重啟。現在更新客戶端並重新啟動嗎?
server.join=進入伺服器
server.update=更新
instance.launch_and_enter_world=進入世界
instance.launch.empty=開始遊戲
instance.launch.empty.installing=安裝遊戲
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/// Tests [RestartRequest].
public final class RestartRequestTest {
@Test
void noFileMeansNoRequest(@TempDir Path dir) {
assertNull(RestartRequest.consume(dir));
}
@Test
void readsReasonAndDeletesFile(@TempDir Path dir) throws Exception {
Path file = dir.resolve(RestartRequest.FILE_NAME);
Files.writeString(file, "{\"reason\":\"update\"}");
assertEquals("update", RestartRequest.consume(dir));
assertFalse(Files.exists(file));
assertNull(RestartRequest.consume(dir));
}
@Test
void malformedFileStillCounts(@TempDir Path dir) throws Exception {
Path file = dir.resolve(RestartRequest.FILE_NAME);
Files.writeString(file, "not json");
assertEquals("unknown", RestartRequest.consume(dir));
assertFalse(Files.exists(file));
}
}
@@ -799,6 +799,10 @@ public class DefaultLauncher extends Launcher {
env.put("__NV_PRIME_RENDER_OFFLOAD", "1");
env.put("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
env.put("__VK_LAYER_NV_optimus", "NVIDIA_only");
} else if (HardwareVendor.AMD.equals(discreteGraphicsCard.getVendor())
|| HardwareVendor.NVIDIA.equals(discreteGraphicsCard.getVendor())) {
// Mesa PRIME offload (open source AMD / nouveau drivers)
env.put("DRI_PRIME", "1");
}
}
}
@@ -213,6 +213,6 @@ public class ServerModpackCompletionTask extends Task<Void> {
public void postExecute() throws Exception {
if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return;
Files.createDirectories(configurationFile.getParent());
JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), this.manifest.getVersion(), remoteManifest.getFiles()));
JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), remoteManifest.getVersion(), remoteManifest.getFiles()));
}
}
@@ -2,10 +2,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
@@ -28,7 +30,40 @@ public final class ServerStatusPing {
/// @param online the number of players online
/// @param max the player limit
/// @param latencyMillis the round-trip time of the ping packet in milliseconds
public record Status(int online, int max, long latencyMillis) {
/// @param motd the server's message of the day as plain text without colour codes; empty if absent
public record Status(int online, int max, long latencyMillis, String motd) {
/// Creates a status without a message of the day.
public Status(int online, int max, long latencyMillis) {
this(online, max, latencyMillis, "");
}
}
/// Flattens the `description` field of a status response (a plain string or a chat component with
/// `text` and `extra`) into plain text, dropping `§x` colour and format codes.
///
/// @param description the `description` element, may be `null`
/// @return the plain text, trimmed; empty when there is nothing to show
public static String motdToPlainText(@Nullable JsonElement description) {
StringBuilder builder = new StringBuilder();
appendComponent(description, builder);
return builder.toString().replaceAll("\u00a7.", "").strip();
}
private static void appendComponent(@Nullable JsonElement element, StringBuilder out) {
if (element == null || element.isJsonNull()) {
return;
}
if (element.isJsonPrimitive()) {
out.append(element.getAsString());
} else if (element.isJsonArray()) {
for (JsonElement item : element.getAsJsonArray()) {
appendComponent(item, out);
}
} else if (element.isJsonObject()) {
JsonObject object = element.getAsJsonObject();
appendComponent(object.get("text"), out);
appendComponent(object.get("extra"), out);
}
}
/// The protocol number sent in the handshake; Minecraft 1.7.10 uses 5 and servers answer any value.
@@ -73,9 +108,11 @@ public final class ServerStatusPing {
in.readFully(json);
int online;
int max;
String motd;
try {
JsonObject players = JsonUtils.GSON.fromJson(new String(json, StandardCharsets.UTF_8), JsonObject.class)
.getAsJsonObject("players");
JsonObject response = JsonUtils.GSON.fromJson(new String(json, StandardCharsets.UTF_8), JsonObject.class);
motd = motdToPlainText(response.get("description"));
JsonObject players = response.getAsJsonObject("players");
online = players.get("online").getAsInt();
max = players.get("max").getAsInt();
} catch (JsonParseException | NullPointerException | ClassCastException | IllegalStateException e) {
@@ -94,7 +131,7 @@ public final class ServerStatusPing {
throw new IOException("Unexpected pong packet");
}
in.readLong();
return new Status(online, max, (System.nanoTime() - start) / 1_000_000);
return new Status(online, max, (System.nanoTime() - start) / 1_000_000, motd);
}
}
@@ -25,6 +25,7 @@ public final class ServerStatusPingTest {
var status = ServerStatusPing.ping("127.0.0.1", server.getLocalPort(), 3000);
assertEquals(12, status.online());
assertEquals(50, status.max());
assertEquals("TDLM 公告: 今晚维护", status.motd());
assertTrue(status.latencyMillis() >= 0);
}
}
@@ -45,7 +46,7 @@ public final class ServerStatusPingTest {
skipPacket(in); // handshake
skipPacket(in); // status request
byte[] json = "{\"players\":{\"online\":12,\"max\":50}}".getBytes(StandardCharsets.UTF_8);
byte[] json = "{\"description\":{\"text\":\"§aTDLM \",\"extra\":[{\"text\":\"公告: 今晚维护\"}]},\"players\":{\"online\":12,\"max\":50}}".getBytes(StandardCharsets.UTF_8);
byte[] body = concat(new byte[]{0x00}, varInt(json.length), json);
out.write(varInt(body.length));
out.write(body);
@@ -88,4 +89,11 @@ public final class ServerStatusPingTest {
for (byte[] part : parts) out.writeBytes(part);
return out.toByteArray();
}
@Test
void flattensPlainStringMotd() {
var json = com.google.gson.JsonParser.parseString("\"§l欢迎§r来玩\"");
assertEquals("欢迎来玩", ServerStatusPing.motdToPlainText(json));
assertEquals("", ServerStatusPing.motdToPlainText(null));
}
}