Compare commits

...
Author SHA1 Message Date
layue13 bdacd65473 Merge pull request 'chore: 移除误提交的 java/ 目录并忽略' (#6) from fix/remove-committed-java into main
Mirror Repository / Mirror to Gitee (push) Skipped
Mirror Repository / Mirror to CNB (push) Skipped
Java CI / build (push) Canceled after 0s
Reviewed-on: #6
2026-11-08 09:18:07 +00:00
layue13andClaude Sonnet 5.5 f176fbd1fd chore: remove the Java runtime that was committed by mistake and ignore /java/
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 0s
A launcher run from the repository root downloads Mojang Java into java/, and it was
picked up by git add -A in e12c7e53f. The files stay in history.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:12:22 +08:00
layue13andCursor 9d43a6fc0f feat: install TDLM via Server Modpack fileApi and enable lwjgl3ify by default
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 0s
Wire fixed fileApi first-install/update, and apply the pack's tdlm/lwjgl3ify MultiMC companion on install and launch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 16:44:22 +08:00
layue13 23811dad7f Merge pull request 'ci: 减少重复构建并加缓存' (#5) from ci/faster-builds into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 0s
2026-11-08 08:30:18 +00:00
layue13andClaude Sonnet 5.5 e792bfc554 ci: try installing xvfb before refreshing the package index
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Failing after 16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:21:40 +08:00
layue13andClaude Sonnet 5.5 d6b4af2d96 ci: one workflow run per change, cancel superseded runs, cache the Gradle distribution
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 0s
Pushes to feature branches built twice (push and pull request) and Check Codes
repeated the compile in a second workflow. Build now runs only on main, tags and
pull requests, cancels older runs of the same ref, folds checkstyle and
checkTranslations into the single Gradle invocation, caches the wrapper download
and skips apt when xvfb is installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:19:50 +08:00
layue13 2a915460ae Merge pull request 'fix: 账号浮层添加账号入口,统一账号页与设置页样式' (#4) from fix/account-add-when-empty into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 7m28s
Java CI / build (push) Failing after 3h0m3s
2026-11-08 08:18:58 +00:00
layue13andClaude Sonnet 5.5 e9b577f66f style: light cards and lists, sans section titles, hairline outlined buttons, quieter sidebar labels
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Failing after 2m7s
Check Codes / build (push) Successful in 5m52s
Check Codes / build (pull_request) Successful in 7m0s
Java CI / build (pull_request) Failing after 3h0m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:08:31 +08:00
layue13andClaude Sonnet 5.5 e12c7e53f6 fix: keep the add-account action in the account popup when there are no accounts
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Failing after 2m30s
Check Codes / build (push) Successful in 4m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:50:52 +08:00
layue13 61caf8a925 Merge pull request 'feat: 服务器专用启动器(一键进服、新界面、精简)' (#3) from feat/server-launcher-redesign into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Canceled after 7m7s
Java CI / build (push) Failing after 3h0m4s
2026-11-08 07:31:54 +00:00
18 changed files with 587 additions and 107 deletions

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
name: Check Codes Comment
# Disabled on this Gitea fork: the upstream flow depends on reviewdog
# (missing from git.nest.potatolab.uk) and a checkstyle-artifact produced by
# check-codes.yml. Re-enable only after those actions are mirrored or the
# comment path is rewritten without reviewdog.
on:
workflow_dispatch:
permissions:
actions: read
contents: read
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
if: false
steps:
- name: Disabled
run: echo "check-codes-comment is disabled on this fork"
-35
View File
@@ -1,35 +0,0 @@
name: Check Codes
on:
workflow_dispatch:
push:
paths:
- '**.java'
- '**.properties'
- '.github/workflows/check-codes.yml'
pull_request:
paths:
- '**.java'
- '**.properties'
- '.github/workflows/check-codes.yml'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
# Keep only Gradle checks. reviewdog/action-setup is not mirrored on
# git.nest.potatolab.uk (404), so PR-comment artifact steps are omitted
# for Gitea Actions / act runners.
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
+29 -4
View File
@@ -1,11 +1,22 @@
name: Java CI
# Pushes to main and pull requests only. A branch with an open pull request used to build twice
# (once for the push, once for the pull request), which kept the runners queued.
on:
push:
branches:
- main
tags:
- '*'
pull_request:
paths-ignore:
- '**.md'
# A newer run for the same pull request or branch replaces the older one.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
@@ -23,12 +34,26 @@ jobs:
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
# Only main writes the dependency cache; pull requests read it.
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# The repository's wrapper downloads Gradle itself, so cache that download too.
- name: Cache Gradle distribution
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
.gradle/wrapper-neo
key: gradle-dist-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
- name: Install Xvfb
run: |
sudo apt-get update
sudo apt-get install -y xvfb
- name: Build with Gradle
run: xvfb-run --auto-servernum ./gradlew build --no-daemon --parallel
# Installing is the slowest setup step, so skip it when the runner already has xvfb, and
# only refresh the package index when a plain install fails.
command -v xvfb-run >/dev/null || sudo apt-get install -y --no-install-recommends xvfb \
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb; }
# One Gradle run builds, tests and runs the code and translation checks, so the
# project is configured and compiled once instead of in two workflows.
- name: Build and check with Gradle
run: xvfb-run --auto-servernum ./gradlew build checkstyle checkTranslations --no-daemon --parallel
env:
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
+3
View File
@@ -77,3 +77,6 @@ language-subtag-registry
!/.gemini/config.yaml
.codex
/.gradle-*/
# Managed Java downloaded by the launcher when it is run from the repository root
/java/
@@ -141,6 +141,30 @@ public final class Metadata {
return null;
}
/// Default client Server Modpack fileApi published by TDLMPack (Gitea generic package `latest`).
public static final String DEFAULT_CLIENT_FILE_API =
"https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-client/latest";
/// Stable client update root (`server-manifest.json` and `overrides/` live under this URL).
///
/// Override with system property `hmcl.client.fileApi` or jar attribute `hmcl.client.fileApi`.
public static String getClientFileApi() {
String fromProperty = System.getProperty("hmcl.client.fileApi", "").trim();
if (StringUtils.isNotBlank(fromProperty)) {
return StringUtils.removeSuffix(fromProperty, "/");
}
String fromJar = JarUtils.getAttribute("hmcl.client.fileApi", null);
if (StringUtils.isNotBlank(fromJar)) {
return StringUtils.removeSuffix(fromJar.trim(), "/");
}
return DEFAULT_CLIENT_FILE_API;
}
/// Absolute URL of the client `server-manifest.json` used for first install and differential updates.
public static String getClientManifestUrl() {
return getClientFileApi() + "/server-manifest.json";
}
/// Server address used when the installed modpack manifest does not provide one.
///
/// Can be set at build or run time with the `hmcl.server.address` system property; empty by default.
@@ -150,9 +174,10 @@ public final class Metadata {
/// The server name shown in the title bar.
///
/// Can be set with the `hmcl.server.name` system property; empty by default.
/// Can be set with the `hmcl.server.name` system property; defaults to `TDLM`.
public static String getServerName() {
return System.getProperty("hmcl.server.name", "").trim();
String name = System.getProperty("hmcl.server.name", "TDLM").trim();
return name.isEmpty() ? "TDLM" : name;
}
}
@@ -27,11 +27,16 @@ 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.*;
@@ -354,35 +359,59 @@ public final class Controllers {
);
}
/// Installs the package found by [Metadata#findBundledModpackFile()] asynchronously, if present.
///
/// Shows progress without a cancel action and attempts to delete the package after success.
/// Failures are logged; a retained package may be retried at the next startup.
/// 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.
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
if (modpackFile == null) {
if (modpackFile != null) {
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
return ModpackHelper.getInstallTask(
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
return;
}
try {
Files.deleteIfExists(modpackFile);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
);
return;
}
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
if (repository.getDisplayInstances().findAny().isPresent()) {
return;
}
String manifestUrl = Metadata.getClientManifestUrl();
LOG.info("No instances yet; fetching client Server Modpack from " + manifestUrl);
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
return ModpackHelper.getInstallTask(
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
new GetTask(WebURL.parse(manifestUrl))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
LOG.warning("Failed to fetch client Server Modpack manifest", exception);
return;
}
try {
Files.deleteIfExists(modpackFile);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
ServerModpackManifest manifest =
JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
if (manifest == null) {
LOG.warning("Malformed client Server Modpack manifest from " + manifestUrl);
return;
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
}),
i18n("message.downloading"),
TaskCancellationAction.NO_CANCEL
);
}
@@ -28,7 +28,6 @@ import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
@@ -78,7 +77,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
getSidebar().clear();
getSidebar()
.startCategory(i18n("account.create").toUpperCase(Locale.ROOT))
.startCategory(i18n("account.create"))
.add(offlineItem)
.add(microsoftItem);
@@ -11,6 +11,7 @@ import javafx.beans.binding.BooleanBinding;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.ui.Controllers;
@@ -19,6 +20,8 @@ import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import java.util.List;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class AccountListPopupMenu extends StackPane {
@@ -69,20 +72,6 @@ public final class AccountListPopupMenu extends StackPane {
box.add(item);
}
// Footer actions: add another account, or open the full account page.
AdvancedListItem addItem = footerItem(SVG.ADD_CIRCLE, i18n("account.add"));
addItem.setOnAction(e -> {
hidePopup();
Controllers.dialog(new CreateAccountPane());
});
AdvancedListItem manageItem = footerItem(SVG.SETTINGS, i18n("account.open_manage"));
manageItem.setOnAction(e -> {
hidePopup();
Controllers.navigate(Controllers.getAccountListPage());
});
box.add(addItem);
box.add(manageItem);
// https://github.com/HMCL-dev/HMCL/issues/5343
Platform.runLater(() -> box.setVvalue(0.0));
};
@@ -92,9 +81,31 @@ public final class AccountListPopupMenu extends StackPane {
Label placeholder = new Label(i18n("account.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
FXUtils.onChangeAndOperate(isEmpty, empty -> {
getChildren().setAll(empty ? placeholder : box);
// Footer actions stay visible even when there is no account yet: add one, or open the full account page.
AdvancedListItem addItem = footerItem(SVG.ADD_CIRCLE, i18n("account.add"));
addItem.setOnAction(e -> {
hidePopup();
// The first account uses the short name dialog; later ones can pick any login method.
if (Accounts.getAccounts().isEmpty()) {
Controllers.dialog(new FirstRunAccountDialog());
} else {
Controllers.dialog(new CreateAccountPane());
}
});
AdvancedListItem manageItem = footerItem(SVG.SETTINGS, i18n("account.open_manage"));
manageItem.setOnAction(e -> {
hidePopup();
Controllers.navigate(Controllers.getAccountListPage());
});
VBox footer = new VBox(addItem, manageItem);
footer.setPrefWidth(220);
VBox content = new VBox();
FXUtils.onChangeAndOperate(isEmpty, empty -> {
footer.getChildren().setAll(empty ? List.of(addItem) : List.of(addItem, manageItem));
content.getChildren().setAll(empty ? List.of(placeholder, footer) : List.of(box, footer));
});
getChildren().setAll(content);
}
}
@@ -135,6 +135,7 @@ public class ComponentList extends Control implements NoPaddingComponent {
{
Label advanced = new Label(title);
advanced.getStyleClass().add("component-list-title");
node.getChildren().setAll(advanced);
}
@@ -28,25 +28,40 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
private final HMCLGameRepository repository;
private final Path file;
private final String updateVersion;
private final ServerModpackManifest remoteManifest;
private String iconUrl;
private boolean hasSource;
public ModpackInstallWizardProvider(HMCLGameRepository repository) {
this(repository, null, null);
this(repository, null, null, null);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile) {
this(repository, modpackFile, null);
this(repository, modpackFile, null, null);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, GameInstanceID updateInstanceId) {
this(repository, null, updateInstanceId);
this(repository, null, updateInstanceId, null);
}
/// Starts the wizard with a remote Server Modpack manifest (TDLM fileApi install).
public ModpackInstallWizardProvider(HMCLGameRepository repository, ServerModpackManifest remoteManifest) {
this(repository, null, null, remoteManifest);
}
public ModpackInstallWizardProvider(HMCLGameRepository repository, Path modpackFile, GameInstanceID updateInstanceId) {
this(repository, modpackFile, updateInstanceId, null);
}
private ModpackInstallWizardProvider(
HMCLGameRepository repository,
Path modpackFile,
GameInstanceID updateInstanceId,
ServerModpackManifest remoteManifest) {
this.repository = repository;
this.file = modpackFile;
this.updateVersion = updateInstanceId != null ? updateInstanceId.toString() : null;
this.remoteManifest = remoteManifest;
}
public void setIconUrl(String iconUrl) {
@@ -57,6 +72,8 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
public void start(SettingsMap settings) {
if (file != null)
settings.put(LocalModpackPage.MODPACK_FILE, file);
if (remoteManifest != null)
settings.put(RemoteModpackPage.MODPACK_SERVER_MANIFEST, remoteManifest);
if (updateVersion != null)
settings.put(LocalModpackPage.MODPACK_NAME, updateVersion);
if (StringUtils.isNotBlank(iconUrl))
@@ -13,6 +13,9 @@ import org.jackhuang.hmcl.download.game.GameDownloadTask;
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.server.ServerModpackManifest;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
@@ -45,9 +48,10 @@ public final class Instances {
private Instances() {
}
/// Installs or updates the server client from the launcher-bundled modpack when present.
/// Installs or updates the TDLM client.
///
/// Falls back to the local/remote modpack import wizard when no bundled package is found.
/// Prefers a launcher-bundled zip, then the org [Metadata#getClientManifestUrl] Server Modpack,
/// then the generic local/remote import wizard.
public static void installServerModpack() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (!repository.isLoaded()) {
@@ -62,7 +66,33 @@ public final class Instances {
return;
}
importModpack();
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;
}
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
result, ServerModpackManifest.class);
if (manifest == null) {
Controllers.dialog(
i18n("modpack.type.server.malformed"),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
importModpack();
return;
}
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL);
}
public static void importModpack() {
@@ -2349,3 +2349,78 @@
-fx-pref-height: 18px;
-fx-max-height: 18px;
}
/* ---- Unified list and card styling ---- */
/* Sidebar section labels: small muted text without an underline. */
.class-title {
-fx-font-family: "Noto Sans SC";
-fx-padding: 13px 16px 4px 16px;
}
.class-title Text {
-fx-fill: -monet-on-surface-variant;
}
.class-title Rectangle {
-fx-fill: transparent;
}
/* Cards: a hairline border instead of a drop shadow. */
.card, .card-no-padding, .card-non-transparent {
-fx-background-color: -monet-surface-bright;
-fx-border-color: -monet-outline-variant;
-fx-border-radius: 13px;
-fx-effect: null;
}
/* Icon buttons use a softer ink than body text. */
.toggle-icon4 .svg, .toggle-icon-tiny .svg {
-fx-fill: -monet-on-surface-variant;
}
/* ---- Settings lists ---- */
.component-list-title {
-fx-font-family: "Noto Sans SC";
-fx-font-weight: bold;
-fx-font-size: 14px;
-fx-text-fill: -monet-on-surface;
}
.options-list {
-fx-background-radius: 13px;
-fx-border-color: -monet-outline-variant;
-fx-border-radius: 13px;
-fx-effect: null;
}
.options-list-item {
-fx-background-color: -monet-surface-bright;
}
.options-list-item:first {
-fx-background-radius: 13px 13px 0 0;
}
.options-list-item:last {
-fx-background-radius: 0 0 13px 13px;
}
.options-list-item:first:last {
-fx-background-radius: 13px;
}
/* Sidebar rows: a lighter selected and hover tint. */
.advanced-list-item:selected .rippler-container,
.advanced-list-item .rippler-container:hover {
-fx-background-color: -monet-surface-container-high;
}
/* Outlined buttons: one crisp hairline. */
.jfx-button-border {
-fx-border-color: -monet-outline-variant;
-fx-border-radius: 8px;
-fx-border-width: 1px;
-fx-background-radius: 8px;
-fx-padding: 8px 16px;
}
@@ -327,6 +327,10 @@ public class LaunchOptions implements Serializable {
return this;
}
public Path getGameDir() {
return options.gameDir;
}
public Builder setJava(JavaRuntime java) {
options.java = java;
return this;
@@ -0,0 +1,220 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.modpack.server;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameDownloadTask;
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.modpack.multimc.MultiMCComponents;
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstancePatch;
import org.jackhuang.hmcl.modpack.multimc.MultiMCManifest;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
/// Applies the default-on lwjgl3ify MultiMC companion under {@link Lwjgl3ifySupport#ROOT}.
///
/// Replaces the plain Forge install produced by {@link org.jackhuang.hmcl.download.GameBuilder} with the
/// resolved MultiMC artifact (libraries, JVM args, bootstrap main class) so Java 25 launches work
/// without a player toggle.
@NotNullByDefault
public final class Lwjgl3ifyApplyTask extends Task<Void> {
private final DefaultDependencyManager dependencyManager;
private final DefaultGameRepository repository;
private final GameInstanceID instanceId;
private final @Nullable DefaultGameInstance updateTarget;
private final List<Task<?>> dependents = new ArrayList<>();
private final List<Task<?>> dependencies = new ArrayList<>();
private @Nullable MultiMCInstancePatch.ResolvedInstance artifact;
public Lwjgl3ifyApplyTask(
DefaultDependencyManager dependencyManager,
GameInstanceID instanceId,
@Nullable DefaultGameInstance updateTarget) {
this.dependencyManager = dependencyManager;
this.repository = dependencyManager.getGameRepository();
this.instanceId = instanceId;
this.updateTarget = updateTarget;
setStage("hmcl.modpack");
setSignificance(TaskSignificance.MODERATE);
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
if (!Lwjgl3ifySupport.isPresent(instanceRoot)) {
return;
}
try {
dependents.add(new AssemblePatchesTask(instanceRoot));
} catch (IOException e) {
dependents.add(Task.failed(e));
}
}
/// Runs after Server Modpack file sync when the companion is present; otherwise a completed no-op.
public static Task<?> afterCompletion(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
if (!Lwjgl3ifySupport.isPresent(instance.getInstanceRoot())) {
return Task.completed(null);
}
return new Lwjgl3ifyApplyTask(dependencyManager, instance.getId(), instance);
}
@Override
public List<Task<?>> getDependents() {
return dependents;
}
@Override
public List<Task<?>> getDependencies() {
return dependencies;
}
@Override
public void execute() throws Exception {
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
if (!Lwjgl3ifySupport.isPresent(instanceRoot)) {
return;
}
@Nullable AssemblePatchesTask assemble = null;
for (Task<?> dependent : dependents) {
if (dependent instanceof AssemblePatchesTask patchesTask) {
assemble = patchesTask;
break;
}
}
Objects.requireNonNull(assemble, "lwjgl3ify patch assemble task");
artifact = MultiMCInstancePatch.resolveArtifact(assemble.getResult(), instanceId);
Path embeddedLibraries = Lwjgl3ifySupport.root(instanceRoot).resolve("libraries");
if (Files.isDirectory(embeddedLibraries)) {
FileUtils.copyDirectory(embeddedLibraries, instanceRoot.resolve("libraries"));
}
for (Library library : artifact.getManifest().getLibraries()) {
if ("local".equals(library.hint())) {
Path from = repository.getLayout().getLibraryFile(artifact.getManifest().id(), library);
Path target = repository.getLayout().getLibraryFile(artifact.getManifest().id(), library.withoutCommunityFields());
if (Files.exists(from)) {
Files.createDirectories(target.getParent());
Files.copy(from, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
try (InputStream input = Objects.requireNonNull(
Lwjgl3ifyApplyTask.class.getResourceAsStream("/assets/game/HMCLMultiMCBootstrap-1.0.jar"),
"Bundled HMCLMultiMCBootstrap is missing.")) {
Path libraryPath = repository.getLayout().getLibraryFile(artifact.getManifest().id(), MultiMCInstancePatch.BOOTSTRAP_LIBRARY);
Files.createDirectories(libraryPath.getParent());
Files.copy(input, libraryPath, StandardCopyOption.REPLACE_EXISTING);
}
GameInstanceManifest instanceManifest = artifact.getManifest();
repository.save(instanceManifest);
dependencies.add(new GameAssetDownloadTask(dependencyManager, instanceManifest, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true));
dependencies.add(new GameLibrariesTask(
dependencyManager,
instanceManifest.withLibraries(Lang.merge(instanceManifest.getLibraries(), artifact.getMavenOnlyFiles())),
true));
Path instanceJar = getPrimaryJarFile();
dependencies.add(new GameDownloadTask(dependencyManager, instanceManifest)
.thenAcceptAsync(cachedJar -> FileUtils.copyFile(cachedJar, instanceJar))
.withStage("hmcl.modpack.download"));
}
private Path getPrimaryJarFile() {
if (updateTarget == null) {
return repository.getLayout().getInstanceJarFile(instanceId);
}
Path manifestFile = updateTarget.getManifestFile();
return manifestFile.resolveSibling(FileUtils.getNameWithoutExtension(manifestFile) + ".jar");
}
private static final class AssemblePatchesTask extends Task<List<MultiMCInstancePatch>> {
private final Path companionRoot;
private final String mcVersion;
private final List<Task<MultiMCInstancePatch>> patches = new ArrayList<>();
AssemblePatchesTask(Path instanceRoot) throws IOException {
this.companionRoot = Lwjgl3ifySupport.root(instanceRoot);
MultiMCManifest mmcPack = Lwjgl3ifySupport.readMmcPack(instanceRoot);
List<MultiMCManifest.MultiMCManifestComponent> components = Objects.requireNonNull(
mmcPack.getComponents(), "components");
String resolvedMc = null;
for (MultiMCManifest.MultiMCManifestComponent component : components) {
if (MultiMCComponents.getComponent(component.getUid()) == GameComponentType.GAME) {
resolvedMc = component.getVersion();
break;
}
}
if (resolvedMc == null) {
throw new IOException("lwjgl3ify mmc-pack.json has no Minecraft component");
}
this.mcVersion = resolvedMc;
for (MultiMCManifest.MultiMCManifestComponent component : components) {
String componentID = Objects.requireNonNull(component.getUid(), "Component ID");
Path patchPath = companionRoot.resolve("patches").resolve(componentID + ".json");
if (Files.isRegularFile(patchPath)) {
MultiMCInstancePatch patch = MultiMCInstancePatch.read(componentID, Files.readString(patchPath));
patches.add(Task.supplyAsync(() -> patch));
} else {
patches.add(new GetTask(MultiMCComponents.getMetaURL(componentID, component.getVersion(), mcVersion))
.thenApplyAsync(s -> MultiMCInstancePatch.read(componentID, s)));
}
}
}
@Override
public Collection<? extends Task<?>> getDependents() {
return patches;
}
@Override
public void execute() throws Exception {
Map<String, MultiMCInstancePatch> existed = new LinkedHashMap<>();
for (Task<MultiMCInstancePatch> patch : patches) {
MultiMCInstancePatch result = patch.getResult();
existed.put(result.getID(), result);
}
checking:
while (true) {
for (MultiMCInstancePatch patch : existed.values()) {
for (MultiMCManifest.MultiMCManifestCachedRequires require : patch.getRequires()) {
String componentID = require.getID();
if (!existed.containsKey(componentID)) {
Task<MultiMCInstancePatch> task = new GetTask(MultiMCComponents.getMetaURL(
componentID,
Lang.requireNonNullElse(require.getEqualsVersion(), require.getSuggests()),
mcVersion
)).thenApplyAsync(s -> MultiMCInstancePatch.read(componentID, s));
task.run();
MultiMCInstancePatch result = Objects.requireNonNull(task.getResult());
existed.put(result.getID(), result);
continue checking;
}
}
}
break;
}
setResult(new ArrayList<>(existed.values()));
}
}
}
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.modpack.server;
import org.jackhuang.hmcl.game.LaunchOptions;
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstancePatch;
import org.jackhuang.hmcl.modpack.multimc.MultiMCManifest;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Default-on lwjgl3ify companion shipped under {@code tdlm/lwjgl3ify/} in the TDLM Server Modpack.
@NotNullByDefault
public final class Lwjgl3ifySupport {
/// Instance-root-relative directory written by the TDLM pack builder.
public static final String ROOT = "tdlm/lwjgl3ify";
private Lwjgl3ifySupport() {
}
/// Returns whether the instance carries an enabled lwjgl3ify MultiMC companion.
public static boolean isPresent(Path instanceRoot) {
Path root = root(instanceRoot);
return Files.isRegularFile(root.resolve("enabled")) && Files.isRegularFile(root.resolve("mmc-pack.json"));
}
/// Returns the companion root under the instance.
public static Path root(Path instanceRoot) {
return instanceRoot.resolve(ROOT);
}
/// Injects {@code +jvmArgs} from every local patch when the companion is present.
///
/// Version-json application via {@link Lwjgl3ifyApplyTask} is the primary path; this covers launches
/// where the resolved manifest has not yet absorbed those args.
public static void injectLaunchOptions(Path instanceRoot, LaunchOptions.Builder builder) {
if (!isPresent(instanceRoot)) {
return;
}
Path patches = root(instanceRoot).resolve("patches");
if (!Files.isDirectory(patches)) {
return;
}
List<String> args = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(patches, "*.json")) {
for (Path patchFile : stream) {
try {
MultiMCInstancePatch patch = MultiMCInstancePatch.read(
patchFile.getFileName().toString(), Files.readString(patchFile));
args.addAll(patch.getJvmArgs());
} catch (IOException | RuntimeException e) {
LOG.warning("Failed to read lwjgl3ify patch " + patchFile, e);
}
}
} catch (IOException e) {
LOG.warning("Failed to list lwjgl3ify patches under " + patches, e);
return;
}
for (String arg : args) {
if (!builder.getJavaArguments().contains(arg)) {
builder.getJavaArguments().add(arg);
}
}
}
/// Parses {@code mmc-pack.json} from the companion root.
public static MultiMCManifest readMmcPack(Path instanceRoot) throws IOException {
Path mmcPack = root(instanceRoot).resolve("mmc-pack.json");
return JsonUtils.fromNonNullJson(Files.readString(mmcPack), MultiMCManifest.class);
}
}
@@ -32,6 +32,7 @@ public class ServerModpackLocalInstallTask extends Task<Void> {
/// Existing instance selecting update mode, or `null` for a new installation.
private final @Nullable DefaultGameInstance updateTarget;
private final DefaultDependencyManager dependencyManager;
private final DefaultGameRepository repository;
private final List<Task<?>> dependencies = new ArrayList<>();
private final List<Task<?>> dependents = new ArrayList<>(4);
@@ -97,6 +98,7 @@ public class ServerModpackLocalInstallTask extends Task<Void> {
this.manifest = manifest;
this.instanceId = instanceId;
this.updateTarget = updateTarget;
this.dependencyManager = dependencyManager;
this.repository = dependencyManager.getGameRepository();
Path run = repository.getLayout().getInstanceRoot(instanceId);
@@ -148,5 +150,9 @@ public class ServerModpackLocalInstallTask extends Task<Void> {
@Override
public void execute() throws Exception {
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
if (Lwjgl3ifySupport.isPresent(instanceRoot)) {
dependencies.add(new Lwjgl3ifyApplyTask(dependencyManager, instanceId, updateTarget));
}
}
}
@@ -6,6 +6,7 @@ import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.game.DefaultGameInstance;
import org.jackhuang.hmcl.game.LaunchOptions;
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackProvider;
@@ -29,7 +30,8 @@ public final class ServerModpackProvider implements ModpackProvider {
@Override
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
return new ServerModpackCompletionTask(dependencyManager, instance);
return new ServerModpackCompletionTask(dependencyManager, instance)
.thenComposeAsync(ignored -> Lwjgl3ifyApplyTask.afterCompletion(dependencyManager, instance));
}
@Override
@@ -51,4 +53,12 @@ public final class ServerModpackProvider implements ModpackProvider {
ServerModpackManifest manifest = JsonUtils.fromNonNullJson(json, ServerModpackManifest.class);
return manifest.toModpack();
}
@Override
public void injectLaunchOptions(String modpackConfigurationJson, LaunchOptions.Builder builder) {
Path gameDir = builder.getGameDir();
if (gameDir != null) {
Lwjgl3ifySupport.injectLaunchOptions(gameDir, builder);
}
}
}
@@ -126,10 +126,12 @@ public class ServerModpackRemoteInstallTask extends Task<Void> {
@Override
public void execute() throws Exception {
DefaultGameInstance instance = repository.getInstance(instanceId);
dependencies.add(new ServerModpackCompletionTask(
dependency,
repository.getInstance(instanceId),
new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList())));
instance,
new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))
.thenComposeAsync(ignored -> Lwjgl3ifyApplyTask.afterCompletion(dependency, instance)));
}
public static final String MODPACK_TYPE = "Server";