Compare commits

...
Author SHA1 Message Date
layue13 551bd4cef2 fix: harden MultiMC/authlib/IO trust boundaries (#8–#13)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 12m50s
- #8: do not import MultiMC PreLaunch/Wrapper/JVM overrides from packs
- #9: filter dangerous +jvmArgs from MultiMC patches / lwjgl3ify
- #10: cap HTTP body reads at 16 MiB
- #11: guard FileUtils.copyDirectory against path escape
- #12: allow only http/https/mailto in openLink
- #13: follow authlib api-location only on same-host HTTPS
2026-11-09 21:19:30 +00:00
layue13 0d58eac860 Merge pull request 'ci: stop caching entire ~/.gradle/caches (was ~4GB)' (#17) from cursor/shrink-gradle-cache-4ee7 into main
Java CI / build (push) Canceled after 47s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
ci: stop caching entire ~/.gradle/caches (was ~4GB) (#17)
2026-11-09 21:19:12 +00:00
layue13 82b0459f4d ci: stop caching entire ~/.gradle/caches (was ~4GB)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 12m22s
Match ForestryMC/IC2: Gitea uses setup-gradle cache-provider external
with no whole-home actions/cache. Keep wrapper-only cache so restores
stay small on the Windows Docker runner.
2026-11-09 20:37:39 +00:00
layue13 b8305f85b0 Merge pull request 'ci: align Gradle/JDK caching with TDLM org workflows' (#16) from cursor/ci-cache-optimize-1729 into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Successful in 13m8s
ci: align Gradle/JDK caching with TDLM org workflows (#16)
2026-11-09 19:57:41 +00:00
layue13 b7e9d7fda4 ci: fix HMCL:test hang — install GTK for JavaFX under xvfb
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 14m4s
The apparent checkstyle stall was :HMCL:test stuck after
GtkPlatformFactory Internal Error (xvfb without libgtk). Install GTK/X11
deps, use software prism, and bound Platform.startup in test launchers.
2026-11-09 19:42:47 +00:00
layue13 a4e6ab3673 ci: bump setup-gradle to v6 and cache apt xvfb debs
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 24m45s
Nest actions/gradle-actions@v5 rejects cache-provider; v6 supports it
(and defaults to enhanced — keep Gitea on external). Cache ~/apt-cache
so xvfb debs can skip apt-get update when Actions cache is available.
2026-11-09 18:27:47 +00:00
layue13 096146449d ci: align Gradle/JDK caching with TDLM org workflows
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 1m55s
Match ForestryMC/IC2 setup-build: cache-jdk, and use setup-gradle
cache-provider basic on GitHub / external on Gitea (avoids ECONNREFUSED
to GitHub cache). Cache ~/.gradle caches+wrapper via actions/cache.
2026-11-09 17:31:17 +00:00
layue13 320c5644e9 Merge pull request 'ci: point setup-gradle at nest mirror actions/gradle-actions' (#15) from cursor/mirror-gradle-actions-1729 into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 1h31m56s
ci: point setup-gradle at nest mirror actions/gradle-actions

Merged HMC-17.
2026-11-09 16:40:26 +00:00
layue13 5f61a0d05c ci: use nest mirror actions/gradle-actions for setup-gradle
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 1h53m21s
act resolves uses: gradle/actions/* to git.nest…/gradle/actions, which
is not available. Sibling mirrors live under the actions org as
actions/gradle-actions (mirror of github.com/gradle/actions).
2026-11-09 16:20:00 +00:00
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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
33 changed files with 954 additions and 150 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
+62 -7
View File
@@ -1,58 +1,113 @@
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:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
# Same pattern as TDLM ForestryMC/IndustrialCraft2 setup-build: cache the JDK toolchain.
- name: Set up JDK 17
uses: actions/setup-java@v6
with:
distribution: 'liberica'
java-version: '17'
java-package: 'jdk+fx'
cache-jdk: true
# Nest mirrors github.com/gradle/actions as actions/gradle-actions (also as gradle/actions).
# Match ForestryMC/IC2: on Gitea use cache-provider external (no setup-gradle Actions cache).
# Do NOT actions/cache the whole ~/.gradle/caches — that hit ~4GB here and OOM'd the
# Windows Docker runner on restore; siblings never cache that tree.
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
uses: actions/gradle-actions/setup-gradle@v6
with:
cache-cleanup: never
- name: Install Xvfb
cache-provider: ${{ github.server_url == 'https://github.com' && 'basic' || 'external' }}
# Wrapper only (small). Dependency jars re-download when cold — same as org mod CI on Gitea.
- name: Cache Gradle wrapper
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
.gradle/wrapper-neo
key: gradle-wrapper-${{ runner.os }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
# Xvfb alone is not enough: JavaFX Glass needs GTK/X11 libs or Platform.startup fails with
# GtkPlatformFactory "Internal Error" and :HMCL:test can hang (looks like a checkstyle stall).
- name: Cache apt packages
uses: actions/cache@v4
with:
path: ~/apt-cache
key: apt-javafx-xvfb-${{ runner.os }}-${{ runner.arch }}-1
- name: Install Xvfb and JavaFX GTK deps
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
pkgs='xvfb libgtk-3-0 libxtst6 libxxf86vm1 libgl1 libxi6 libx11-6 libxext6 libxrender1'
sudo mkdir -p /var/cache/apt/archives/partial
if [ -d "${HOME}/apt-cache" ] && [ -n "$(ls -A "${HOME}/apt-cache" 2>/dev/null)" ]; then
sudo cp -a "${HOME}/apt-cache/." /var/cache/apt/archives/
fi
# Prefer cached debs; refresh the index only when install cannot resolve packages.
sudo apt-get install -y --no-install-recommends $pkgs \
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends $pkgs; }
mkdir -p "${HOME}/apt-cache"
cp -a /var/cache/apt/archives/*.deb "${HOME}/apt-cache/" 2>/dev/null || true
# 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
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true -Dprism.order=sw
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload JAR
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-jar
path: HMCL/build/libs/HMCL-*.jar
archive: false
- name: Upload EXE
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-exe
path: HMCL/build/libs/HMCL-*.exe
archive: false
- name: Upload SH
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: HMCL/build/libs/HMCL-*.sh
archive: false
- name: Upload DEB
uses: actions/upload-artifact@v7
with:
+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;
}
}
@@ -19,7 +19,6 @@ import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
@@ -44,6 +43,7 @@ import java.util.Set;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Utilities for reading, installing, and applying modpack-specific game settings.
@NotNullByDefault
@@ -314,18 +314,13 @@ public final class ModpackHelper {
setting.minMemoryProperty().setValue(c.getMinMemory());
}
// MultiMC packs are untrusted input: never auto-apply PreLaunch/Wrapper/JVM overrides.
if (c.isOverrideCommands()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Objects.requireNonNullElse(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Objects.requireNonNullElse(c.getPreLaunchCommand(), ""));
LOG.warning("Ignoring MultiMC OverrideCommands (PreLaunch/Wrapper) from modpack for security");
}
if (c.isOverrideJavaArgs()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Objects.requireNonNullElse(c.getJvmArgs(), ""));
LOG.warning("Ignoring MultiMC OverrideJavaArgs from modpack for security");
}
if (c.isOverrideConsole()) {
@@ -348,18 +343,13 @@ public final class ModpackHelper {
}
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
// Same policy as toGameSettings: do not import executable commands / raw JVM args from packs.
if (c.isOverrideCommands()) {
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_COMMAND_WRAPPER,
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
));
setting.commandWrapperProperty().setValue(Lang.nonNull(c.getWrapperCommand(), ""));
setting.preLaunchCommandProperty().setValue(Lang.nonNull(c.getPreLaunchCommand(), ""));
LOG.warning("Ignoring MultiMC OverrideCommands (PreLaunch/Wrapper) from modpack update for security");
}
if (c.isOverrideJavaArgs()) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
setting.jvmOptionsProperty().setValue(Lang.nonNull(c.getJvmArgs(), ""));
LOG.warning("Ignoring MultiMC OverrideJavaArgs from modpack update for security");
}
}
@@ -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
);
}
@@ -602,6 +602,10 @@ public final class FXUtils {
return;
String url = NetworkUtils.encodeLocation(link);
if (!isAllowedOpenLink(url)) {
LOG.warning("Refusing to open link with disallowed scheme: " + link);
return;
}
thread(() -> {
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
@@ -635,6 +639,23 @@ public final class FXUtils {
});
}
/// Only {@code http}/{@code https}/{@code mailto} may be handed to the OS URL handler.
static boolean isAllowedOpenLink(String link) {
try {
URI uri = new URI(link);
String scheme = uri.getScheme();
if (scheme == null) {
return false;
}
return switch (scheme.toLowerCase(java.util.Locale.ROOT)) {
case "http", "https", "mailto" -> true;
default -> false;
};
} catch (Exception e) {
return false;
}
}
public static <T> void bind(JFXTextField textField, Property<T> property, StringConverter<T> converter) {
TextFieldBinding<T> binding = new TextFieldBinding<>(textField, property, converter);
binding.updateTextField();
@@ -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;
}
@@ -10,6 +10,9 @@ import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Field;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/// Owns the shared JavaFX toolkit for the lifetime of the test process.
@NotNullByDefault
@@ -27,11 +30,36 @@ public final class JavaFXLauncher {
System.setProperty("hmcl.scroll.speed", "1");
try {
initializeAnimationPolicy();
Platform.startup(() -> {
});
started = true;
} catch (IllegalStateException e) {
started = true;
// Platform.startup can hang when GTK/Glass is broken (minimal CI images with only xvfb).
// Bound class-init so :HMCL:test cannot stall the whole Gradle build.
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Thread t = new Thread(() -> {
try {
Platform.startup(() -> {
});
} catch (Throwable e) {
error.set(e);
} finally {
done.countDown();
}
}, "hmcl-javafx-startup");
t.setDaemon(true);
t.start();
if (!done.await(45, TimeUnit.SECONDS)) {
System.err.println("JavaFX Platform.startup timed out; JavaFX tests will be skipped");
} else {
Throwable e = error.get();
if (e == null) {
started = true;
} else if (e instanceof IllegalStateException) {
started = true;
} else {
e.printStackTrace();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Throwable e) {
e.printStackTrace();
}
@@ -42,6 +42,11 @@ public final class DesktopUtils {
}
public static void openLink(String link) {
if (!isAllowedOpenLink(link)) {
System.err.println("Refusing to open link with disallowed scheme: " + link);
return;
}
String osName = System.getProperty("os.name").toLowerCase(Locale.ROOT);
Throwable e1 = null;
@@ -79,6 +84,21 @@ public final class DesktopUtils {
}
}
/// Only {@code http}/{@code https}/{@code mailto} may be handed to the OS URL handler.
static boolean isAllowedOpenLink(String link) {
try {
URI uri = new URI(link);
String scheme = uri.getScheme();
if (scheme == null) {
return false;
}
String lower = scheme.toLowerCase(Locale.ROOT);
return "http".equals(lower) || "https".equals(lower) || "mailto".equals(lower);
} catch (Exception e) {
return false;
}
}
private DesktopUtils() {
}
}
@@ -45,10 +45,18 @@ public class AuthlibInjectorServer implements Observable {
String ali = conn.getHeaderField("x-authlib-injector-api-location");
if (ali != null) {
WebURL absoluteAli = WebURL.parse(ali, WebURL.of(conn.getURL()));
// Only follow same-host HTTPS redirects; open redirects to other hosts are rejected.
if (!urlEqualsIgnoreSlash(webURL.toString(), absoluteAli.toString())) {
conn.disconnect();
url = absoluteAli.toString();
conn = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(absoluteAli));
if (!"https".equalsIgnoreCase(absoluteAli.getScheme())
|| absoluteAli.getHost() == null
|| webURL.getHost() == null
|| !absoluteAli.getHost().equalsIgnoreCase(webURL.getHost())) {
LOG.warning("Ignoring x-authlib-injector-api-location redirect outside original HTTPS host: " + absoluteAli);
} else {
conn.disconnect();
url = absoluteAli.toString();
conn = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(absoluteAli));
}
}
}
@@ -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;
@@ -7,6 +7,7 @@ import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.JvmArgumentFilter;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -282,7 +283,7 @@ public final class MultiMCInstancePatch {
if (minecraftArguments == null & patch.getMinecraftArguments() != null) {
minecraftArguments = StringUtils.tokenize(patch.getMinecraftArguments());
}
for (String jvmArg : patch.getJvmArgs()) {
for (String jvmArg : JvmArgumentFilter.filter(patch.getJvmArgs())) {
jvmArguments.add(new StringArgument(jvmArg));
}
mainClass = Lang.requireNonNullElse(mainClass, patch.getMainClass());
@@ -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,80 @@
// 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.JvmArgumentFilter;
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(JvmArgumentFilter.filter(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";
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* Filters JVM arguments coming from untrusted modpack / MultiMC patch input.
*
* <p>Denies agent injection and {@code -XX:On*Error} command hooks while allowing ordinary
* memory / GC / system-property args used by companions such as lwjgl3ify.
*/
public final class JvmArgumentFilter {
private JvmArgumentFilter() {
}
/// Returns whether a single JVM argument is safe to accept from modpack / patch metadata.
public static boolean isAllowed(@Nullable String arg) {
if (arg == null) {
return false;
}
String trimmed = arg.trim();
if (trimmed.isEmpty()) {
return false;
}
String lower = trimmed.toLowerCase(Locale.ROOT);
if (lower.startsWith("-javaagent")
|| lower.startsWith("-agentpath")
|| lower.startsWith("-agentlib")
|| lower.startsWith("-xbootclasspath")
|| lower.startsWith("-xx:onoutofmemoryerror")
|| lower.startsWith("-xx:onerror")
|| lower.startsWith("-xx:exitonoutofmemoryerror")) {
return false;
}
return true;
}
/// Copies {@code args}, dropping disallowed entries and logging each skip.
public static List<String> filter(Iterable<String> args) {
List<String> out = new ArrayList<>();
for (String arg : args) {
if (isAllowed(arg)) {
out.add(arg);
} else {
LOG.warning("Ignoring disallowed JVM argument from modpack/patch: " + arg);
}
}
return out;
}
}
@@ -306,6 +306,7 @@ public final class FileUtils {
}
public static void copyDirectory(Path src, Path dest, Predicate<String> filePredicate) throws IOException {
Path destRoot = dest.toAbsolutePath().normalize();
Files.walkFileTree(src, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
@@ -313,7 +314,11 @@ public final class FileUtils {
return FileVisitResult.SKIP_SUBTREE;
}
Path destFile = dest.resolve(src.relativize(file).toString());
Path destFile = destRoot.resolve(src.relativize(file).toString()).toAbsolutePath().normalize();
if (!destFile.startsWith(destRoot)) {
throw new IOException("Refusing to copy outside destination directory: " + file);
}
Files.createDirectories(destFile.getParent());
Files.copy(file, destFile, StandardCopyOption.REPLACE_EXISTING);
return FileVisitResult.CONTINUE;
}
@@ -324,7 +329,10 @@ public final class FileUtils {
return FileVisitResult.SKIP_SUBTREE;
}
Path destDir = dest.resolve(src.relativize(dir).toString());
Path destDir = destRoot.resolve(src.relativize(dir).toString()).toAbsolutePath().normalize();
if (!destDir.startsWith(destRoot)) {
throw new IOException("Refusing to create directory outside destination: " + dir);
}
Files.createDirectories(destDir);
return FileVisitResult.CONTINUE;
}
@@ -10,6 +10,7 @@ import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
@@ -134,9 +135,10 @@ public abstract class HttpRequest {
con = resolveConnection(con);
checkResponseCode(con);
return IOUtils.readFullyAsString("gzip".equals(con.getContentEncoding())
InputStream body = "gzip".equals(con.getContentEncoding())
? IOUtils.wrapFromGZip(con.getInputStream())
: con.getInputStream());
: con.getInputStream();
return IOUtils.readFullyAsString(body, UTF_8, IOUtils.DEFAULT_MAX_HTTP_BODY_BYTES);
}, retryTimes);
}
}
@@ -5,7 +5,13 @@ package org.jackhuang.hmcl.util.io;
import kala.encdet.EncodingDetector;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
@@ -29,6 +35,9 @@ public final class IOUtils {
public static final int DEFAULT_BUFFER_SIZE = 32 * 1024;
/// Soft cap for HTTP / metadata bodies read into memory (authlib, API JSON, etc.).
public static final long DEFAULT_MAX_HTTP_BODY_BYTES = 16L * 1024L * 1024L;
public static BufferedReader newBufferedReaderMaybeNativeEncoding(Path file) throws IOException {
if (NATIVE_CHARSET == UTF_8)
return Files.newBufferedReader(file);
@@ -68,8 +77,29 @@ public final class IOUtils {
}
public static byte[] readFully(InputStream stream) throws IOException {
return readFully(stream, -1L);
}
/// Reads the stream fully, optionally refusing bodies larger than {@code maxBytes}.
///
/// @param maxBytes maximum accepted size, or {@code <= 0} for no limit
public static byte[] readFully(InputStream stream, long maxBytes) throws IOException {
try (stream) {
return stream.readAllBytes();
if (maxBytes <= 0) {
return stream.readAllBytes();
}
ByteArrayOutputStream out = new ByteArrayOutputStream(Math.min(DEFAULT_BUFFER_SIZE, (int) Math.min(maxBytes, Integer.MAX_VALUE)));
byte[] buf = new byte[DEFAULT_BUFFER_SIZE];
long total = 0;
int n;
while ((n = stream.read(buf)) >= 0) {
total += n;
if (total > maxBytes) {
throw new IOException("Response body exceeds limit of " + maxBytes + " bytes");
}
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
@@ -81,6 +111,10 @@ public final class IOUtils {
return new String(readFully(stream), charset);
}
public static String readFullyAsString(InputStream stream, Charset charset, long maxBytes) throws IOException {
return new String(readFully(stream, maxBytes), charset);
}
public static void skipNBytes(InputStream input, long n) throws IOException {
while (n > 0) {
long ns = input.skip(n);
@@ -437,13 +437,13 @@ public final class NetworkUtils {
Charset charset = getCharsetFromContentType(con.getHeaderField("Content-Type"));
try (InputStream stdout = con.getInputStream()) {
return IOUtils.readFullyAsString(contentEncoding.wrap(stdout), charset);
return IOUtils.readFullyAsString(contentEncoding.wrap(stdout), charset, IOUtils.DEFAULT_MAX_HTTP_BODY_BYTES);
} catch (IOException e) {
if (con instanceof HttpURLConnection) {
try (InputStream stderr = ((HttpURLConnection) con).getErrorStream()) {
if (stderr == null)
throw e;
return IOUtils.readFullyAsString(contentEncoding.wrap(stderr), charset);
return IOUtils.readFullyAsString(contentEncoding.wrap(stderr), charset, IOUtils.DEFAULT_MAX_HTTP_BODY_BYTES);
}
} else {
throw e;
@@ -2,6 +2,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public final class JavaFXLauncher {
private JavaFXLauncher() {
@@ -10,13 +14,37 @@ public final class JavaFXLauncher {
private static boolean started = false;
static {
// init JavaFX Toolkit
// init JavaFX Toolkit — never block class init forever if GTK/Glass hangs under CI.
try {
javafx.application.Platform.startup(() -> {
});
started = true;
} catch (IllegalStateException e) {
started = true;
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> error = new AtomicReference<>();
Thread t = new Thread(() -> {
try {
javafx.application.Platform.startup(() -> {
});
} catch (Throwable e) {
error.set(e);
} finally {
done.countDown();
}
}, "hmcl-javafx-startup");
t.setDaemon(true);
t.start();
if (!done.await(45, TimeUnit.SECONDS)) {
System.err.println("JavaFX Platform.startup timed out; JavaFX tests will be skipped");
} else {
Throwable e = error.get();
if (e == null) {
started = true;
} else if (e instanceof IllegalStateException) {
// Toolkit already running in this JVM.
started = true;
} else {
e.printStackTrace();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Throwable e) {
e.printStackTrace();
}
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class JvmArgumentFilterTest {
@Test
public void allowsOrdinaryArgs() {
assertTrue(JvmArgumentFilter.isAllowed("-Xmx2G"));
assertTrue(JvmArgumentFilter.isAllowed("-Djava.library.path=/tmp"));
assertTrue(JvmArgumentFilter.isAllowed("--add-opens=java.base/java.lang=ALL-UNNAMED"));
}
@Test
public void rejectsAgentsAndOnErrorHooks() {
assertFalse(JvmArgumentFilter.isAllowed("-javaagent:/tmp/evil.jar"));
assertFalse(JvmArgumentFilter.isAllowed("-agentpath:/tmp/evil.so"));
assertFalse(JvmArgumentFilter.isAllowed("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"));
assertFalse(JvmArgumentFilter.isAllowed("-Xbootclasspath/a:/tmp/evil.jar"));
assertFalse(JvmArgumentFilter.isAllowed("-XX:OnOutOfMemoryError=curl evil.example"));
assertFalse(JvmArgumentFilter.isAllowed("-XX:OnError=id"));
}
@Test
public void filterDropsDisallowed() {
assertEquals(List.of("-Xmx1G"),
JvmArgumentFilter.filter(List.of("-Xmx1G", "-javaagent:x.jar")));
}
}
@@ -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.util.io;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
public class CopyDirectoryTraversalTest {
@TempDir
Path temp;
@Test
public void copiesWithinDestination() throws IOException {
Path src = temp.resolve("src");
Path dest = temp.resolve("dest");
Files.createDirectories(src.resolve("a"));
Files.writeString(src.resolve("a/b.txt"), "hi");
FileUtils.copyDirectory(src, dest);
assertEquals("hi", Files.readString(dest.resolve("a/b.txt")));
}
@Test
public void destinationRootGuardMatchesUnzipperStyle() {
Path destRoot = temp.resolve("dest").toAbsolutePath().normalize();
Path escape = destRoot.resolve("sub/../../outside.txt").toAbsolutePath().normalize();
assertFalse(escape.startsWith(destRoot), "normalize + startsWith must reject escapes");
Path inside = destRoot.resolve("sub/file.txt").toAbsolutePath().normalize();
assertTrue(inside.startsWith(destRoot));
}
}
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util.io;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
public class IOUtilsReadLimitTest {
@Test
public void rejectsOversizedBody() {
byte[] data = new byte[100];
assertThrows(IOException.class, () -> IOUtils.readFully(new ByteArrayInputStream(data), 50));
}
@Test
public void acceptsWithinLimit() throws IOException {
byte[] data = "hello".getBytes(StandardCharsets.UTF_8);
assertArrayEquals(data, IOUtils.readFully(new ByteArrayInputStream(data), 16));
assertEquals("hello", IOUtils.readFullyAsString(new ByteArrayInputStream(data), StandardCharsets.UTF_8, 16));
}
}