Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23ded32db1 | ||
|
|
9e66c9dbc7 | ||
|
|
07fffc4b01 | ||
|
|
141df8c7b8 | ||
|
|
f5d739711d | ||
|
|
87b5efa64b | ||
|
|
8aaaadc2b0 | ||
|
|
c4240a24bf | ||
|
|
e81e974af7 | ||
|
|
551bd4cef2 | ||
|
|
95111d2f84 | ||
|
|
327c0ea940 | ||
|
|
aace7932e0 | ||
|
|
93bb547a9e | ||
|
|
0f29fafc0a | ||
|
|
7f3b7cfe8a | ||
|
|
9de2d8eeef | ||
|
|
6434573105 | ||
|
|
3cbab39d16 | ||
|
|
0d58eac860 | ||
|
|
82b0459f4d | ||
|
|
b8305f85b0 | ||
|
|
b7e9d7fda4 | ||
|
|
a4e6ab3673 | ||
|
|
096146449d | ||
|
|
320c5644e9 | ||
|
|
5f61a0d05c | ||
|
|
bdacd65473 | ||
|
|
9d43a6fc0f |
No files matched your search
@@ -21,63 +21,93 @@ 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
|
||||
# Only main writes the dependency cache; pull requests read it.
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
# The repository's wrapper downloads Gradle itself, so cache that download too.
|
||||
- name: Cache Gradle distribution
|
||||
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-dist-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
|
||||
- name: Install Xvfb
|
||||
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: |
|
||||
# Installing is the slowest setup step, so skip it when the runner already has xvfb, and
|
||||
# only refresh the package index when a plain install fails.
|
||||
command -v xvfb-run >/dev/null || sudo apt-get install -y --no-install-recommends xvfb \
|
||||
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb; }
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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="")
|
||||
# Gitea refuses to overwrite a generic package file (HTTP 409), so the previous copy of this one
|
||||
# file is deleted right before it is uploaded again (keeps the gap without a file as short as possible).
|
||||
delete = urllib.request.Request(url, method="DELETE")
|
||||
delete.add_header("Authorization", "token " + token)
|
||||
delete.add_header("User-Agent", "tdlm-hmcl-publish")
|
||||
try:
|
||||
with urllib.request.urlopen(delete, timeout=300) as response:
|
||||
response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code != 404:
|
||||
raise SystemExit("HTTP %s deleting the old %s: %s" % (exc.code, name, exc.read()[:300]))
|
||||
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: http://159.75.5.225:19527/tdlm-client\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,18 +141,46 @@ public final class Metadata {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Default client Server Modpack fileApi: self-hosted static mirror on the TDLM VPS (nginx, port 19527).
|
||||
public static final String DEFAULT_CLIENT_FILE_API =
|
||||
"http://159.75.5.225:19527/tdlm-client";
|
||||
|
||||
/// 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";
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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,69 @@ 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);
|
||||
Controllers.dialog(
|
||||
manifestUrl + "\n" + exception.getMessage(),
|
||||
i18n("message.error"),
|
||||
MessageType.ERROR);
|
||||
Instances.importModpack();
|
||||
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);
|
||||
Controllers.dialog(
|
||||
i18n("modpack.type.server.malformed"),
|
||||
i18n("message.error"),
|
||||
MessageType.ERROR);
|
||||
Instances.importModpack();
|
||||
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();
|
||||
|
||||
+20
-3
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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 (self-hosted VPS)
|
||||
|
||||
HMCL default (`Metadata.DEFAULT_CLIENT_FILE_API`):
|
||||
|
||||
```text
|
||||
http://159.75.5.225:19527/tdlm-client
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
The fileApi is a read-only nginx static mirror on the TDLM VPS (`159.75.5.225`, port `19527`,
|
||||
root `/var/www/tdlm-dl`). Players read it anonymously; integrity is verified by the SHA-1 hashes
|
||||
in `server-manifest.json`.
|
||||
|
||||
**TDLMPack must use the same URL** so `server-manifest.json` embeds it:
|
||||
|
||||
```text
|
||||
TDLM_CLIENT_FILE_API=http://159.75.5.225:19527/tdlm-client
|
||||
```
|
||||
|
||||
Set that as the Gitea Actions **variable** on `TDLM/TDLMPack` (pack.yml passes `vars.TDLM_CLIENT_FILE_API`;
|
||||
`PackBuild.DEFAULT_FILE_API` is the build-time fallback).
|
||||
|
||||
**Publish path (upload origin split from public fileApi):** Pack CI no longer PUTs to the read URL.
|
||||
The `pack.yml` "Publish the client Server Modpack to fileApi" step rsyncs `build/pack/client/overrides/`
|
||||
then `server-manifest.json` (manifest last) over SSH to `ubuntu@159.75.5.225:/var/www/tdlm-dl/tdlm-client`.
|
||||
Auth is the repo secret `TDLM_VPS_SSH_KEY`, an ed25519 deploy key confined on the VPS by
|
||||
`command="/usr/bin/rrsync /var/www/tdlm-dl/tdlm-client"` (rsync-only, no shell, no forwarding).
|
||||
The public port 19527 serves GET/HEAD only — there is no public write surface.
|
||||
Reference in new issue
Block a user