Compare commits

...
Author SHA1 Message Date
layue13andClaude Sonnet 5.5 f549f6d185 fix: default client fileApi is the Nexus raw tdlm-client; tell the player when the manifest cannot be fetched
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 9m1s
The default pointed at packages.dandanton.com/tdlm-client/latest (404), so the first-launch auto install
silently did nothing. It now points at https://maven.dandanton.com/repository/tdlm-client, and a failed or
malformed manifest shows an error and opens the import wizard (same behaviour as the Start menu install).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:18:46 +08:00
layue13 87b5efa64b fix: bake default join address mc.potatolab.uk (#18)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Publish TDLM launcher / publish (push) Skipped
Publish TDLM launcher / noop (push) Successful in 5s
Java CI / build (push) Successful in 11m49s
fix: bake default join address mc.potatolab.uk
2026-11-09 22:16:08 +00:00
layue13 8aaaadc2b0 fix: bake default join address mc.potatolab.uk
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 19m53s
Closed-beta players need a working join target even when the client
server-manifest is missing or omits serverAddress.
2026-11-09 22:16:05 +00:00
layue13 c4240a24bf Merge pull request 'fix: harden MultiMC/authlib/IO trust boundaries (#8–#13)' (#14) from cursor/fix-open-security-issues-1729 into main
Java CI / build (push) Successful in 12m30s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
fix: harden MultiMC/authlib/IO trust boundaries (#8–#13) (#14)
2026-11-09 21:19:54 +00:00
layue13 e81e974af7 Merge pull request 'ci: publish launcher to Gitea Release + tdlm-hmcl/latest for TDLMPack' (#7) from cursor/publish-gitea-artifacts-1729 into main
Mirror Repository / Mirror to CNB (push) Skipped
Publish TDLM launcher / publish (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Publish TDLM launcher / noop (push) Successful in 4s
Java CI / build (push) Canceled after 0s
ci: publish launcher to Gitea Release + tdlm-hmcl/latest for TDLMPack (#7)
2026-11-09 21:19:49 +00:00
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 95111d2f84 ci: stop caching entire ~/.gradle/caches (was ~4GB)
Java CI / build (pull_request) Successful in 13m11s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
2026-11-09 21:19:21 +00:00
layue13 327c0ea940 ci: fix HMCL:test hang — install GTK for JavaFX under xvfb 2026-11-09 21:19:21 +00:00
layue13 aace7932e0 ci: quote publish-tdlm workflow_dispatch description
Unquoted "Example: ..." broke YAML parsing, so Gitea recorded push
runs with 0 jobs as failure.
2026-11-09 21:19:21 +00:00
layue13 93bb547a9e ci: add publish-tdlm push no-op to avoid empty failed runs
Gitea was marking publish-tdlm.yml push events as failure with 0 jobs.
Add an explicit push path filter + noop job; keep publish on dispatch.
2026-11-09 21:19:21 +00:00
layue13 0f29fafc0a ci: skip publish-tdlm job unless workflow_dispatch
Push events were recording empty failed runs (0 jobs) for this
dispatch-only workflow. Guard the job so push is a clean skip.
2026-11-09 21:19:21 +00:00
layue13 7f3b7cfe8a ci: align publish-tdlm action pins with gradle.yml
Use checkout/setup-java v6 and gradle-actions v5 so act resolves the
same nest-mirrored action tags as Java CI.
2026-11-09 21:19:21 +00:00
layue13 9de2d8eeef ci: align publish auth with TDLMPack / org secret pattern
Keep secrets.TDLM_PACK_TOKEN (same name as pack.yml); document that it
is currently a TDLMPack repo secret while MAVEN_* are org-level.
Fall back to github.token so release can proceed after merge without a
new HMCL-only secret when the pack token is promoted or unavailable.
2026-11-09 21:19:21 +00:00
layue13 6434573105 fix: point DEFAULT_CLIENT_FILE_API at packages.dandanton.com CDN
Use https://packages.dandanton.com/tdlm-client/latest (same relative
layout as Pack's tdlm-client/latest). Document TDLM_CLIENT_FILE_API
alignment and the Pack publish/mirror caveat.
2026-11-09 21:19:21 +00:00
layue13 3cbab39d16 ci: publish HMCL.exe/jar to Gitea Release and tdlm-hmcl/latest
Add a TDLMPack-oriented publish workflow (nest API, same pattern as
TDLMPack pack.yml) plus docs for the exact consume URLs. Pack builds
still do not Maven-resolve the launcher.
2026-11-09 21:19:21 +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
20 changed files with 618 additions and 46 deletions

No files matched your search

+6 -8
View File
@@ -36,25 +36,23 @@ jobs:
cache-jdk: true
# Nest mirrors github.com/gradle/actions as actions/gradle-actions (also as gradle/actions).
# v5 rejects cache-provider; v6 supports it. Default on v6 is "enhanced" (commercial) —
# on Gitea use external and cache Gradle home with actions/cache below (org IC2/Forestry pattern).
# 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: actions/gradle-actions/setup-gradle@v6
with:
cache-cleanup: never
cache-provider: ${{ github.server_url == 'https://github.com' && 'basic' || 'external' }}
# Cache wrapper + dependency/build caches (covers what setup-gradle skips under external).
- name: Cache Gradle home
# 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/caches
~/.gradle/wrapper
.gradle/wrapper-neo
key: gradle-home-${{ runner.os }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java', '**/libs.versions.toml', '**/*.gradle.kts', '**/*.gradle') }}
restore-keys: |
gradle-home-${{ runner.os }}-
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).
+249
View File
@@ -0,0 +1,249 @@
name: Publish TDLM launcher
# Builds HMCL.exe / HMCL.jar and publishes them for TDLMPack / players:
# 1. Gitea Release assets (versioned download page)
# 2. Gitea generic package tdlm-hmcl/latest (stable curl URLs)
#
# Mirrors TDLM/TDLMPack's pack.yml publish style (nest API host; Cloudflare
# in front of git.potatolab.uk blocks Python urllib for large uploads).
#
# Auth (same name as TDLMPack pack.yml):
# secrets.TDLM_PACK_TOKEN — Gitea API token with package + release write.
# Prefer an *org* secret (like MAVEN_USER/MAVEN_PASSWORD) so HMCL inherits it;
# today it exists only as a TDLMPack *repo* secret unless promoted.
# Falls back to github.token when TDLM_PACK_TOKEN is unset (same-repo release;
# org generic packages may still need the pack token).
on:
workflow_dispatch:
inputs:
release_tag:
description: "Release tag (also used as the release title). Example: 2026.11.08 or v3.dev-abc1234"
required: true
type: string
# Gitea records a push run whenever this file changes; without a push job the run
# fails with 0 jobs. Keep a no-op so the check is green; real publish stays dispatch-only.
push:
paths:
- '.github/workflows/publish-tdlm.yml'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
noop:
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
steps:
- run: echo "publish-tdlm is workflow_dispatch-only; push no-op ok"
publish:
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
# Same caching pattern as gradle.yml / TDLM ForestryMC+IC2 setup-build.
- name: Set up JDK 17 + JavaFX
uses: actions/setup-java@v6
with:
distribution: 'liberica'
java-version: '17'
java-package: 'jdk+fx'
cache-jdk: true
- name: Setup Gradle
uses: actions/gradle-actions/setup-gradle@v6
with:
cache-cleanup: never
cache-provider: ${{ github.server_url == 'https://github.com' && 'basic' || 'external' }}
# Wrapper only — do not cache ~/.gradle/caches (org siblings don't; ~4GB killed Docker).
- 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') }}
- 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: |
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
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
- name: Build executables
run: xvfb-run --auto-servernum ./gradlew :HMCL:makeExecutables --no-daemon --parallel
env:
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true -Dprism.order=sw
VERSION_TYPE: unofficial
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Stage stable launcher names
run: |
set -euo pipefail
mkdir -p build/tdlm-hmcl
jar="$(ls -1 HMCL/build/libs/HMCL-*.jar | grep -v '\.sha' | head -n1)"
exe="$(ls -1 HMCL/build/libs/HMCL-*.exe | grep -v '\.sha' | head -n1)"
test -n "$jar" && test -n "$exe"
cp -f "$jar" build/tdlm-hmcl/HMCL.jar
cp -f "$exe" build/tdlm-hmcl/HMCL.exe
# Prefer Gradle-produced checksums when present; otherwise compute.
if [ -f "${jar}.sha256" ]; then cp -f "${jar}.sha256" build/tdlm-hmcl/HMCL.jar.sha256
else sha256sum build/tdlm-hmcl/HMCL.jar | awk '{print $1}' > build/tdlm-hmcl/HMCL.jar.sha256
fi
if [ -f "${exe}.sha256" ]; then cp -f "${exe}.sha256" build/tdlm-hmcl/HMCL.exe.sha256
else sha256sum build/tdlm-hmcl/HMCL.exe | awk '{print $1}' > build/tdlm-hmcl/HMCL.exe.sha256
fi
echo "STAGED_JAR=$jar" >> "$GITHUB_ENV"
echo "STAGED_EXE=$exe" >> "$GITHUB_ENV"
ls -la build/tdlm-hmcl
# Stable player/CI channel: overwritten in place under tdlm-hmcl/latest.
- name: Publish generic package tdlm-hmcl/latest
env:
# Same secret name as TDLM/TDLMPack pack.yml (org or repo). Not MAVEN_*.
TDLM_PACK_TOKEN: ${{ secrets.TDLM_PACK_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
GITEA_PACKAGE_ROOT: https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-hmcl/latest
run: |
python3 - <<'PY'
import os, pathlib, urllib.error, urllib.parse, urllib.request
token = (os.environ.get("TDLM_PACK_TOKEN") or os.environ.get("GITHUB_TOKEN") or "").strip()
token_src = "TDLM_PACK_TOKEN" if os.environ.get("TDLM_PACK_TOKEN", "").strip() else "github.token"
if not token:
raise SystemExit(
"Need secrets.TDLM_PACK_TOKEN (promote TDLMPack's repo secret to org, like MAVEN_*) "
"or a usable github.token to publish tdlm-hmcl")
print("auth=" + token_src)
upload_root = os.environ["GITEA_PACKAGE_ROOT"].rstrip("/")
root = pathlib.Path("build/tdlm-hmcl")
def put(name: str, data: bytes):
url = upload_root + "/" + urllib.parse.quote(name, safe="")
request = urllib.request.Request(url, data=data, method="PUT")
request.add_header("Authorization", "token " + token)
request.add_header("Content-Type", "application/octet-stream")
request.add_header("User-Agent", "tdlm-hmcl-publish")
try:
with urllib.request.urlopen(request, timeout=1800) as response:
response.read()
except urllib.error.HTTPError as exc:
raise SystemExit("HTTP %s uploading %s: %s" % (exc.code, name, exc.read()[:500]))
for path in sorted(root.iterdir()):
if path.is_file():
put(path.name, path.read_bytes())
print("PACKAGE " + path.name)
print("generic=" + upload_root)
PY
# Gitea release (versioned). Nest API avoids Cloudflare blocking large urllib uploads.
- name: Publish Gitea release
env:
RELEASE_TAG: ${{ inputs.release_tag }}
TDLM_PACK_TOKEN: ${{ secrets.TDLM_PACK_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
GITEA_API_URL: https://git.nest.potatolab.uk:8443/api/v1
GITEA_PACKAGE_ROOT: https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-hmcl/latest
TARGET_COMMIT: ${{ github.sha }}
run: |
python3 - <<'PY'
import json, os, urllib.error, urllib.parse, urllib.request
tag = os.environ["RELEASE_TAG"].strip()
token = (os.environ.get("TDLM_PACK_TOKEN") or os.environ.get("GITHUB_TOKEN") or "").strip()
token_src = "TDLM_PACK_TOKEN" if os.environ.get("TDLM_PACK_TOKEN", "").strip() else "github.token"
api = os.environ["GITEA_API_URL"].rstrip("/") + "/repos/TDLM/HMCL"
package_root = os.environ["GITEA_PACKAGE_ROOT"].rstrip("/")
target = os.environ.get("TARGET_COMMIT", "main").strip() or "main"
if not token:
raise SystemExit(
"Need secrets.TDLM_PACK_TOKEN (org-preferred, same as TDLMPack) or github.token")
print("auth=" + token_src)
if not tag:
raise SystemExit("release_tag is empty")
def call(method, path, data=None, content_type="application/json", timeout=90):
request = urllib.request.Request(api + path, data=data, method=method)
request.add_header("Authorization", "token " + token)
request.add_header("Accept", "application/json")
request.add_header("User-Agent", "tdlm-hmcl-release")
if data is not None and content_type:
request.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read()
return json.loads(body) if body else {}
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
detail = exc.read()[:500]
raise SystemExit("HTTP %s for %s: %s" % (exc.code, path, detail))
body = (
"TDLM/HMCL launcher binaries for players and TDLMPack.\n\n"
"Stable generic (overwrite in place):\n"
"- %s/HMCL.exe\n"
"- %s/HMCL.jar\n\n"
"TDLMPack does not Maven-resolve this launcher; point the pack README / install docs at these URLs "
"instead of hand-copying exe/jar.\n"
"Client pack fileApi default: https://packages.dandanton.com/tdlm-client/latest\n"
"(Pack must publish/mirror the same layout; see docs/TDLM_CONSUME.md).\n"
) % (package_root, package_root)
quoted = urllib.parse.quote(tag, safe="")
release = call("GET", "/releases/tags/" + quoted)
if release and not release.get("draft"):
raise SystemExit("Release tag already exists: " + tag)
if not release:
release = call("POST", "/releases", json.dumps({
"tag_name": tag,
"name": tag,
"target_commitish": target,
"draft": True,
"prerelease": False,
"body": body,
}).encode("utf-8"))
release_id = release["id"]
staged = [
("HMCL.exe", "build/tdlm-hmcl/HMCL.exe"),
("HMCL.jar", "build/tdlm-hmcl/HMCL.jar"),
("HMCL.exe.sha256", "build/tdlm-hmcl/HMCL.exe.sha256"),
("HMCL.jar.sha256", "build/tdlm-hmcl/HMCL.jar.sha256"),
]
for name, path in staged:
if not os.path.exists(path):
raise SystemExit("missing " + path)
for old in call("GET", "/releases/%s/assets" % release_id) or []:
if old.get("name") == name:
call("DELETE", "/releases/%s/assets/%s" % (release_id, old["id"]))
with open(path, "rb") as asset:
call(
"POST",
"/releases/%s/assets?name=%s" % (release_id, urllib.parse.quote(name)),
asset.read(),
"application/octet-stream",
1800,
)
print("RELEASE asset " + name)
call("PATCH", "/releases/%s" % release_id, json.dumps({"draft": False}).encode("utf-8"))
print(release.get("html_url", "Release created"))
PY
@@ -141,9 +141,9 @@ public final class Metadata {
return null;
}
/// Default client Server Modpack fileApi published by TDLMPack (Gitea generic package `latest`).
/// Default client Server Modpack fileApi: the Nexus raw repository `tdlm-client` read through the CDN host.
public static final String DEFAULT_CLIENT_FILE_API =
"https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-client/latest";
"https://maven.dandanton.com/repository/tdlm-client";
/// Stable client update root (`server-manifest.json` and `overrides/` live under this URL).
///
@@ -165,11 +165,14 @@ public final class Metadata {
return getClientFileApi() + "/server-manifest.json";
}
/// Fallback join address when the installed modpack manifest has no `serverAddress` / routes.
public static final String DEFAULT_SERVER_ADDRESS = "mc.potatolab.uk";
/// 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.
/// Override at build or run time with `-Dhmcl.server.address=…`; defaults to [#DEFAULT_SERVER_ADDRESS].
public static String getDefaultServerAddress() {
return System.getProperty("hmcl.server.address", "").trim();
return System.getProperty("hmcl.server.address", DEFAULT_SERVER_ADDRESS).trim();
}
/// The server name shown in the title bar.
@@ -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");
}
}
@@ -398,12 +398,22 @@ public final class Controllers {
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
LOG.warning("Failed to fetch client Server Modpack manifest", exception);
Controllers.dialog(
manifestUrl + "\n" + exception.getMessage(),
i18n("message.error"),
MessageType.ERROR);
Instances.importModpack();
return;
}
ServerModpackManifest manifest =
JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
if (manifest == null) {
LOG.warning("Malformed client Server Modpack manifest from " + manifestUrl);
Controllers.dialog(
i18n("modpack.type.server.malformed"),
i18n("message.error"),
MessageType.ERROR);
Instances.importModpack();
return;
}
Controllers.getDecorator().startWizard(
@@ -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();
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.Metadata;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -19,13 +20,15 @@ public final class ServerEndpointTest {
}
@Test
void noConfigurationMeansNoRoute(@TempDir Path dir) {
assertTrue(ServerEndpoint.routes(dir.resolve("modpack.json")).isEmpty());
assertNull(ServerEndpoint.selected(dir.resolve("modpack.json")));
void fallsBackToBakedDefaultAddress(@TempDir Path dir) {
var routes = ServerEndpoint.routes(dir.resolve("modpack.json"));
assertEquals(1, routes.size());
assertEquals(Metadata.DEFAULT_SERVER_ADDRESS, routes.get(0).address());
assertEquals(routes.get(0), ServerEndpoint.selected(dir.resolve("modpack.json")));
}
@Test
void fallsBackToDefaultAddress(@TempDir Path dir) {
void propertyOverridesBakedDefaultAddress(@TempDir Path dir) {
System.setProperty("hmcl.server.address", " play.example.net:25565 ");
var routes = ServerEndpoint.routes(dir.resolve("modpack.json"));
assertEquals(1, routes.size());
@@ -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));
}
}
}
@@ -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());
@@ -5,6 +5,7 @@ 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;
@@ -55,7 +56,7 @@ public final class Lwjgl3ifySupport {
try {
MultiMCInstancePatch patch = MultiMCInstancePatch.read(
patchFile.getFileName().toString(), Files.readString(patchFile));
args.addAll(patch.getJvmArgs());
args.addAll(JvmArgumentFilter.filter(patch.getJvmArgs()));
} catch (IOException | RuntimeException e) {
LOG.warning("Failed to read lwjgl3ify patch " + patchFile, e);
}
@@ -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;
@@ -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));
}
}
+71
View File
@@ -0,0 +1,71 @@
# TDLMPack consume path for TDLM/HMCL
TDLMPack does **not** pull this launcher via Maven, a version pin, or a pack-build download step.
The pack build publishes the HMCL Server Modpack client (`server-manifest.json` + `overrides/`)
under the `tdlm-client/latest` layout. Players install the launcher separately.
## After this repo’s publish workflow
Run **Actions → Publish TDLM launcher → Run workflow** with a new `release_tag`.
### Secrets (sibling pattern)
| Name | Where it lives today | Used by |
|---|---|---|
| `MAVEN_USER` / `MAVEN_PASSWORD` | **Org** secrets | Stratum / mod Maven publish — **not** used for launcher binaries |
| `TDLM_PACK_TOKEN` | **TDLMPack repo** secret only (not org) | TDLMPack `pack.yml` Gitea package + release uploads |
| `github.token` | Actions runtime | HMCL publish fallback when `TDLM_PACK_TOKEN` is unset |
HMCL `publish-tdlm.yml` uses the **same name** as TDLMPack: `secrets.TDLM_PACK_TOKEN`, then falls back to `github.token`.
Org Maven secrets cannot publish Gitea generic packages / releases.
**Recommended:** promote `TDLM_PACK_TOKEN` to an **org** secret (like `MAVEN_*`) so HMCL inherits it with no new repo secret. Until then, either copy it onto `TDLM/HMCL` or rely on `github.token` (same-repo release usually works; org `tdlm-hmcl` package write may still need the pack token).
### 1. Stable generic URLs (preferred for README / curl / CI)
Overwrite-in-place, same host style as the client fileApi:
```text
https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-hmcl/latest/HMCL.exe
https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-hmcl/latest/HMCL.jar
```
Checksums (when published): `HMCL.exe.sha256`, `HMCL.jar.sha256` next to those names.
### 2. Gitea Release (versioned page)
```text
https://git.potatolab.uk/TDLM/HMCL/releases/latest
```
Assets on each tag: `HMCL.exe`, `HMCL.jar`, plus `.sha256` files.
(Use the nest API host for scripted uploads; public download links on `git.potatolab.uk` are fine for browsers.)
### 3. What TDLMPack should change
In `TDLM/TDLMPack` README §启动, replace the repo-only “安装 TDLM/HMCL” step with one of the URLs above.
**Do not** add HMCL as a Gradle/Maven dependency of the pack — pack CI stays pack-only.
### 4. Client pack fileApi (CDN)
HMCL default (`Metadata.DEFAULT_CLIENT_FILE_API`):
```text
https://packages.dandanton.com/tdlm-client/latest
```
First install / updates fetch `{fileApi}/server-manifest.json` (and `overrides/**` under that root).
Override at runtime: `-Dhmcl.client.fileApi=…` or jar attribute `hmcl.client.fileApi`.
**TDLMPack must use the same URL** so `server-manifest.json` embeds it:
```text
TDLM_CLIENT_FILE_API=https://packages.dandanton.com/tdlm-client/latest
```
Set that as the Gitea Actions **variable** on `TDLM/TDLMPack` (pack.yml already passes `vars.TDLM_CLIENT_FILE_API`).
**Publish caveat:** Pack’s current workflow PUTs the client tree to whatever `TDLM_CLIENT_FILE_API` is (upload root = fileApi). Today that defaults to Gitea generic
`https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-client/latest`.
If you only change the HMCL default to the CDN and leave Pack publishing to Gitea, players will 404 unless you **mirror/sync** Gitea → `packages.dandanton.com/tdlm-client/latest`.
If you set `TDLM_CLIENT_FILE_API` to the CDN URL, Pack CI must be able to PUT to that CDN (or you must split “upload origin” from “public fileApi” in Pack — not implemented today). Do not point HMCL at the CDN without one of those two ops paths.