Compare commits

...
Author SHA1 Message Date
layue13 f183b4a5dc Merge pull request 'feat: UI refresh batch 1 (home progress, single settings page, slim about)' (#24) from feat/ui-refresh-1 into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Successful in 10m12s
2026-11-11 08:31:37 +00:00
layue13andClaude Sonnet 5.5 8eec436e23 Merge origin/main into feat/ui-refresh-1
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 8m50s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TiBPbVGBHNHdzqfq1L5tch
2026-11-11 16:22:08 +08:00
layue13 df319745fc Merge pull request 'feat: 默认高性能 GPU、首页 MOTD 公告、更新后重启' (#23) from feat/client-gpu-motd-restart into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Successful in 8m15s
2026-11-11 07:42:50 +00:00
layue13andClaude Sonnet 5.5 d02fb95bd4 fix: LauncherHelper 缺 Instances 导入
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 9m11s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:22:36 +08:00
layue13andClaude Sonnet 5.5 8d1c01aed6 fix: lzh 翻译用字检查
Java CI / build (pull_request) Failing after 10m34s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:09:45 +08:00
layue13andClaude Sonnet 5.5 4ee5c0984f feat: 高性能 GPU 默认开启、首页显示服务器 MOTD 公告、游戏请求重启后更新并重启
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Failing after 6m56s
- highPerformance 默认 true(Windows 同时登记 java.exe 与 javaw.exe),Linux AMD/nouveau 加 DRI_PRIME
- ServerStatusPing 解析 description,首页在服务器公告区显示,失败静默
- 游戏正常退出且运行目录有 tdlm-restart-request.json 时,删除文件并询问是否更新并重启

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 12:49:09 +08:00
layue13andClaude Sonnet 5.5 bcefa87e27 fix: base home sync progress on announced download total
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 1m41s
The percentage used finished/seen task counts, which hit the 99% cap as soon
as the first tasks finished and then stayed there while new tasks were still
being discovered. Use the "total" property announced by the sync task as the
denominator and count finished file downloads; stay indeterminate until known.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TiBPbVGBHNHdzqfq1L5tch
2026-11-11 12:36:56 +08:00
layue13andClaude Sonnet 5.5 26187e6f61 feat: UI refresh batch 1 (home progress card, single settings page, slim about)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
- Home: join/update button turns the bottom card into an in-place progress
  view (percent, thin bar, downloaded size/speed, cancel, four-step indicator,
  error + retry). ClientSyncProgress mirrors the existing task executor.
- Label "Start Game" -> "进入服务器" (en: Join Server) in en/zh_Hans/zh_Hant.
- Settings merged into one page: General / Game / Help cards; Java management
  and About open as sub pages.
- About trimmed; system/device/runtime info moved into "Copy diagnostics".

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

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

Co-authored-by: Cursor <[email protected]>
2026-11-10 22:41:15 +08:00
liuhao 5ca7bcaad0 fix: prefer self-hosted mirror for game metadata, libraries, forge and assets
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Publish TDLM launcher / publish (push) Skipped
Publish TDLM launcher / noop (push) Successful in 2s
Java CI / build (push) Successful in 17m34s
BMCLAPI 302-redirects to CDNs (sakura.sld.tw, cernet) that are TLS-blocked on
many mainland networks, leaving the official origins as the only fallback and
stalling launches at the 1.7.10 asset index with 0 B/s. The VPS now hosts the
pack's full dependency set; try it first with BMCLAPI as fallback.
2026-11-10 12:08:35 +00:00
liuhao 040dceb9ed fix: report per-file progress while downloading Mojang Java
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Publish TDLM launcher / publish (push) Skipped
Publish TDLM launcher / noop (push) Successful in 2s
Java CI / build (push) Successful in 8m46s
MojangJavaDownloadTask never called updateProgress, so its progress bar stayed
indeterminate while the hidden LZMA-decompress and cache-copy steps wrote files
to disk. Players saw 0KB speed and a frozen-looking bar and restarted the
launcher. Append a progress callback after each file completes so the bar
advances through download/decompress/copy phases; the download logic is unchanged.
2026-11-10 11:15:06 +00:00
liuhao c905b49102 fix: mirror Prism maven (log4j-fixed) to self-hosted VPS for CN clients
Publish TDLM launcher / publish (push) Skipped
Publish TDLM launcher / noop (push) Successful in 2s
Java CI / build (push) Successful in 9m29s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
files.prismlauncher.org resets the TLS handshake from mainland China and
BMCLAPI does not mirror its repackaged log4j-fixed artifacts, aborting TDLM
installs at Lwjgl3ifyApplyTask. Redirect that maven to the self-hosted client
mirror (fileApi root + /maven), keeping Prism as fallback. The log4j-api/core
2.0-beta9-fixed jars are hosted on the VPS with matching SHA-1.
2026-11-10 11:00:47 +00:00
liuhao 23ded32db1 docs: rewrite consume path section 4 for self-hosted VPS fileApi + rsync publish
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Publish TDLM launcher / publish (push) Skipped
Publish TDLM launcher / noop (push) Successful in 2s
Java CI / build (push) Successful in 8m54s
2026-11-10 10:21:55 +00:00
liuhao 9e66c9dbc7 docs: release note client fileApi default now self-hosted VPS
Java CI / build (push) Canceled after 43s
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 3s
2026-11-10 10:21:09 +00:00
liuhao 07fffc4b01 fix: point default client fileApi at self-hosted VPS mirror
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 6m30s
Migrate client downloads off the external dandanton.com Nexus/CDN onto
http://159.75.5.225:19527/tdlm-client (nginx static, SHA-1 verified).
2026-11-10 10:14:44 +00:00
layue13 141df8c7b8 ci: delete the old generic package file right before re-uploading it (#21)
Publish TDLM launcher / publish (push) Skipped
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Publish TDLM launcher / noop (push) Successful in 2s
Java CI / build (push) Successful in 8m10s
Co-authored-by: Layue13 <[email protected]>
2026-11-10 08:46:54 +00:00
layue13 f5d739711d fix: default client fileApi is the Nexus raw tdlm-client (#20)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Successful in 8m14s
Co-authored-by: Layue13 <[email protected]>
2026-11-10 08:28:39 +00:00
layue13 a19679fd00 ux: Start auto-installs client; hide install and instance UI
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 8m38s
HMC-22: the home Join/Start button runs fileApi install when needed,
then joins. Differential updates stay on the launch completion path.
Remove the instance-management sidebar entry and stop opening the
modpack install wizard from startup, empty launch, or drag-drop.
2026-11-10 05:01:58 +00:00
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
layue13 b7e9d7fda4 ci: fix HMCL:test hang — install GTK for JavaFX under xvfb
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 14m4s
The apparent checkstyle stall was :HMCL:test stuck after
GtkPlatformFactory Internal Error (xvfb without libgtk). Install GTK/X11
deps, use software prism, and bound Platform.startup in test launchers.
2026-11-09 19:42:47 +00:00
layue13 a4e6ab3673 ci: bump setup-gradle to v6 and cache apt xvfb debs
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 24m45s
Nest actions/gradle-actions@v5 rejects cache-provider; v6 supports it
(and defaults to enhanced — keep Gitea on external). Cache ~/apt-cache
so xvfb debs can skip apt-get update when Actions cache is available.
2026-11-09 18:27:47 +00:00
layue13 096146449d ci: align Gradle/JDK caching with TDLM org workflows
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 1m55s
Match ForestryMC/IC2 setup-build: cache-jdk, and use setup-gradle
cache-provider basic on GitHub / external on Gitea (avoids ECONNREFUSED
to GitHub cache). Cache ~/.gradle caches+wrapper via actions/cache.
2026-11-09 17:31:17 +00:00
layue13 320c5644e9 Merge pull request 'ci: point setup-gradle at nest mirror actions/gradle-actions' (#15) from cursor/mirror-gradle-actions-1729 into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 1h31m56s
ci: point setup-gradle at nest mirror actions/gradle-actions

Merged HMC-17.
2026-11-09 16:40:26 +00:00
layue13 5f61a0d05c ci: use nest mirror actions/gradle-actions for setup-gradle
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 1h53m21s
act resolves uses: gradle/actions/* to git.nest…/gradle/actions, which
is not available. Sibling mirrors live under the actions org as
actions/gradle-actions (mirror of github.com/gradle/actions).
2026-11-09 16:20:00 +00:00
layue13 bdacd65473 Merge pull request 'chore: 移除误提交的 java/ 目录并忽略' (#6) from fix/remove-committed-java into main
Mirror Repository / Mirror to Gitee (push) Skipped
Mirror Repository / Mirror to CNB (push) Skipped
Java CI / build (push) Canceled after 0s
Reviewed-on: #6
2026-11-08 09:18:07 +00:00
layue13andCursor 9d43a6fc0f feat: install TDLM via Server Modpack fileApi and enable lwjgl3ify by default
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 0s
Wire fixed fileApi first-install/update, and apply the pack's tdlm/lwjgl3ify MultiMC companion on install and launch.

Co-authored-by: Cursor <[email protected]>
2026-11-08 16:44:22 +08:00
50 changed files with 2420 additions and 504 deletions

No files matched your search

+42 -12
View File
@@ -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:
+260
View File
@@ -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;
}
}
@@ -3,6 +3,7 @@
package org.jackhuang.hmcl.download;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
@@ -132,6 +133,13 @@ public final class HMCLDownloadProvider extends DownloadProvider {
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
this.rules = List.of(
// The self-hosted client mirror carries the pack's full dependency set. BMCLAPI's
// redirect CDNs (sakura.sld.tw, cernet) and the Mojang/Forge origins are TLS-blocked
// on many mainland networks, so the VPS must be tried before any of them.
new SimpleRule("https://piston-meta.mojang.com", Metadata.getClientFileApi() + "/piston-meta", "https://bmclapi2.bangbang93.com"),
new SimpleRule("https://libraries.minecraft.net", Metadata.getClientFileApi() + "/libraries", "https://bmclapi2.bangbang93.com/libraries"),
new SimpleRule("https://maven.minecraftforge.net", Metadata.getClientFileApi() + "/forge-maven", "https://bmclapi2.bangbang93.com/maven"),
new SimpleRule("https://launcher.mojang.com", Metadata.getClientFileApi() + "/launcher-mojang", "https://bmclapi2.bangbang93.com"),
new MultiRootRule("https://bmclapi2.bangbang93.com", Function.identity()),
new MultiRootRule("https://bmclapi.bangbang93.com", Function.identity()),
new MultiRootRule("https://launchermeta.mojang.com", Function.identity()),
@@ -183,6 +191,11 @@ public final class HMCLDownloadProvider extends DownloadProvider {
new SimpleRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
new SimpleRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
// files.prismlauncher.org is unreachable from mainland China (TLS handshake reset) and BMCLAPI
// does not mirror its repackaged log4j-fixed artifacts. Serve them from the self-hosted client
// mirror (alongside the fileApi root); Prism stays as the fallback for reachable networks.
new SimpleRule("https://files.prismlauncher.org/maven", Metadata.getClientFileApi() + "/maven"),
// https://github.com/mcmod-info-mirror/mcim-rust-api
new SimpleRule("https://api.modrinth.com", null, "https://mod.mcimirror.top/modrinth"),
new SimpleRule("https://cdn.modrinth.com", null, "https://mod.mcimirror.top"),
@@ -295,11 +308,18 @@ public final class HMCLDownloadProvider extends DownloadProvider {
@Override
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
String location = assetObject.getLocation();
return getCandidates(
fileSource,
WebURL.parse("https://resources.download.minecraft.net/" + location),
mirrorUrls("/assets/" + location)
);
WebURL officialUrl = WebURL.parse("https://resources.download.minecraft.net/" + location);
List<WebURL> mirrors = mirrorUrls("/assets/" + location);
if (preferMirror(fileSource)) {
// Keep the VPS strictly first: spreadPrimary would otherwise rotate BMCLAPI ahead of
// it, and BMCLAPI's asset CDN is unreachable on blocked networks (per-file timeouts).
var candidates = new ArrayList<DownloadCandidate>(mirrors.size() + 2);
candidates.add(DownloadCandidate.of(WebURL.parse(Metadata.getClientFileApi() + "/assets/" + location)));
candidates.addAll(spreadPrimary(mirrors.stream().map(DownloadCandidate::of).toList(), officialUrl.href()));
candidates.add(DownloadCandidate.of(officialUrl));
return DownloadCandidates.of(List.copyOf(candidates));
}
return getCandidates(fileSource, officialUrl, mirrors);
}
@NotNullByDefault
@@ -22,6 +22,7 @@ import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.LauncherVisibility;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
@@ -255,32 +256,12 @@ public final class LauncherHelper {
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
try {
WinReg reg = WinReg.INSTANCE;
if (reg != null) {
Object current = reg.queryValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg
);
if (current == null) {
boolean result = reg.setValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg,
"GpuPreference=2;"
);
if (result)
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
else
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
} else {
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
}
}
} catch (Exception e) {
LOG.warning("Failed to apply high performance GPU preference", e);
// Register both java.exe and javaw.exe: the game may be started through either one.
applyHighPerformanceGpuPreference(context.javaExecutable);
Path sibling = context.javaExecutable.resolveSibling(
FileUtils.getName(context.javaExecutable).equalsIgnoreCase("javaw.exe") ? "java.exe" : "javaw.exe");
if (Files.isRegularFile(sibling)) {
applyHighPerformanceGpuPreference(sibling);
}
}
@@ -1048,11 +1029,44 @@ public final class LauncherHelper {
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
}
if (exitType == ExitType.NORMAL) {
// The game asked to be restarted (hot update): offer to update and relaunch.
String restartReason = RestartRequest.consume(gameInstance.getRunDirectory());
if (restartReason != null) {
LOG.info("Game requested a restart, reason: " + restartReason);
runLater(() -> Instances.offerRestart(gameInstance, restartReason));
}
}
checkExit();
}
}
/// Asks Windows to run `executable` on the high-performance GPU (`GpuPreference=2`) unless the user
/// already has a preference stored for it.
private static void applyHighPerformanceGpuPreference(Path executable) {
String path = FileUtils.getAbsolutePath(executable);
try {
WinReg reg = WinReg.INSTANCE;
if (reg == null) {
return;
}
Object current = reg.queryValue(WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences", path);
if (current != null) {
LOG.info("GPU preference for %s already exists: %s".formatted(path, current));
} else if (reg.setValue(WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences", path, "GpuPreference=2;")) {
LOG.info("Successfully applied high performance GPU preference for java: " + path);
} else {
LOG.warning("Failed to apply high performance GPU preference for java: " + path);
}
} catch (Exception e) {
LOG.warning("Failed to apply high performance GPU preference", e);
}
}
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
public static int countMangedProcesses() {
@@ -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");
}
}
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonObject;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/// The "please restart me" handshake between the game and the launcher.
///
/// When the game wants to be restarted (for example after a hot update), it writes
/// [#FILE_NAME] into its run directory as `{"reason":"update"}` and exits normally. After the
/// process ends the launcher calls [#consume(Path)], deletes the file and offers to update and relaunch.
@NotNullByDefault
public final class RestartRequest {
private RestartRequest() {
}
/// The marker file name inside the game's run directory.
public static final String FILE_NAME = "tdlm-restart-request.json";
/// Reads and deletes the restart request in `runDirectory`.
///
/// A malformed file still counts as a request with reason `"unknown"`, because the file's presence is
/// the signal. The file is always removed so a request cannot trigger twice.
///
/// @param runDirectory the game's run directory
/// @return the reason, or `null` if no request was pending
public static @Nullable String consume(Path runDirectory) {
Path file = runDirectory.resolve(FILE_NAME);
if (!Files.isRegularFile(file)) {
return null;
}
String reason = "unknown";
try {
JsonObject object = JsonUtils.GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), JsonObject.class);
if (object != null && object.has("reason") && object.get("reason").isJsonPrimitive()) {
reason = object.get("reason").getAsString();
}
} catch (IOException | RuntimeException ignored) {
// The presence of the file is the signal; keep the default reason.
}
try {
Files.deleteIfExists(file);
} catch (IOException ignored) {
// Nothing sensible to do; the worst case is one more prompt on the next exit.
}
return reason;
}
}
@@ -645,9 +645,12 @@ public sealed abstract class GameSettings extends ObservableSetting {
public static final String PROPERTY_HIGH_PERFORMANCE = "highPerformance";
/// Whether to launch games with high-performance GPU.
///
/// On by default: on a laptop with an integrated and a discrete GPU, Windows otherwise picks the
/// integrated one for java. Machines with a single GPU are unaffected by the preference.
@SerializedName(PROPERTY_HIGH_PERFORMANCE)
private final InheritableProperty<Boolean> highPerformance =
newInheritableProperty(PROPERTY_HIGH_PERFORMANCE, false);
newInheritableProperty(PROPERTY_HIGH_PERFORMANCE, true);
/// Returns the high performance GPU preference property.
public InheritableProperty<Boolean> highPerformanceProperty() {
@@ -354,36 +354,32 @@ 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. First-run remote install is deferred to the
/// home Start button ([Instances#ensureClientAndJoin]) so players never enter the install wizard.
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
if (modpackFile == null) {
return;
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
);
}
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
);
}
/// Shows the content in the main-window dialog stack without waiting for it to close.
@@ -602,6 +602,10 @@ public final class FXUtils {
return;
String url = NetworkUtils.encodeLocation(link);
if (!isAllowedOpenLink(url)) {
LOG.warning("Refusing to open link with disallowed scheme: " + link);
return;
}
thread(() -> {
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
@@ -635,6 +639,23 @@ public final class FXUtils {
});
}
/// Only {@code http}/{@code https}/{@code mailto} may be handed to the OS URL handler.
static boolean isAllowedOpenLink(String link) {
try {
URI uri = new URI(link);
String scheme = uri.getScheme();
if (scheme == null) {
return false;
}
return switch (scheme.toLowerCase(java.util.Locale.ROOT)) {
case "http", "https", "mailto" -> true;
default -> false;
};
} catch (Exception e) {
return false;
}
}
public static <T> void bind(JFXTextField textField, Property<T> property, StringConverter<T> converter) {
TextFieldBinding<T> binding = new TextFieldBinding<>(textField, property, converter);
binding.updateTextField();
@@ -28,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))
@@ -0,0 +1,199 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.*;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.TaskManager;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.function.Consumer;
/// Observable progress of the home button's "install / update the client, then join" workflow.
///
/// The model is only a view of the task executor started by [Instances#ensureClientAndJoin(ClientSyncProgress)]:
/// it never changes what is downloaded. All properties must be read and written on the JavaFX thread.
/// Overall percentage is estimated from finished file downloads against the total announced by the sync task, and stays indeterminate until that total is known.
@NotNullByDefault
public final class ClientSyncProgress {
/// Lifecycle of the workflow.
public enum State {
/// Nothing is running; the home card shows its normal facts and button.
IDLE,
/// A task is running; the home card shows the progress state.
RUNNING,
/// The last run failed; the home card shows [#errorProperty()] and a retry button.
FAILED
}
/// Index of the step that checks the remote manifest.
public static final int STEP_CHECK = 0;
/// Index of the step that prepares Java.
public static final int STEP_JAVA = 1;
/// Index of the step that synchronises the modpack.
public static final int STEP_SYNC = 2;
/// Index of the final step that joins the server.
public static final int STEP_JOIN = 3;
private final ObjectProperty<State> state = new SimpleObjectProperty<>(this, "state", State.IDLE);
/// The first step that is not yet finished; equals 4 once every step is done.
private final IntegerProperty step = new SimpleIntegerProperty(this, "step", STEP_CHECK);
/// Fraction in `[0, 1]`, or `-1` while unknown.
private final DoubleProperty progress = new SimpleDoubleProperty(this, "progress", -1);
private final StringProperty detail = new SimpleStringProperty(this, "detail", "");
private final StringProperty error = new SimpleStringProperty(this, "error", "");
private @Nullable TaskExecutor executor;
private long downloadedBytes;
/// Total number of downloads announced by tasks so far.
private int expectedFiles;
/// Number of finished file downloads.
private int finishedFiles;
/// Tasks whose `total` has already been added to [#expectedFiles].
private final java.util.Set<Task<?>> announcedTasks = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private boolean cancelled;
/// Keeps the weakly registered speed listener alive.
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private final Consumer<FetchTask.SpeedEvent> speedHandler;
/// Creates an idle progress model.
public ClientSyncProgress() {
speedHandler = FetchTask.SPEED_EVENT.registerWeak(event -> FXUtils.runInFX(() -> {
if (state.get() != State.RUNNING || event.getSpeed() <= 0)
return;
downloadedBytes += event.getSpeed();
detail.set(String.format("%.0f MB", downloadedBytes / 1048576.0) + " · " + I18n.formatSpeed(event.getSpeed()));
}));
}
/// The workflow state.
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
/// The first unfinished step index (0–3), or 4 when everything is done.
public ReadOnlyIntegerProperty stepProperty() {
return step;
}
/// Overall fraction in `[0, 1]`, or `-1` when it cannot be estimated yet.
public ReadOnlyDoubleProperty progressProperty() {
return progress;
}
/// Transfer details such as downloaded size and speed; empty when unknown.
public ReadOnlyStringProperty detailProperty() {
return detail;
}
/// The failure message of the last run; empty unless the state is [State#FAILED].
public ReadOnlyStringProperty errorProperty() {
return error;
}
/// Starts a new run: resets counters and shows the progress state at the first step.
void begin() {
executor = null;
cancelled = false;
downloadedBytes = 0;
expectedFiles = 0;
finishedFiles = 0;
announcedTasks.clear();
progress.set(-1);
detail.set("");
error.set("");
step.set(STEP_CHECK);
state.set(State.RUNNING);
}
/// Moves the indicator forward to `newStep`; it never moves backwards.
void advanceTo(int newStep) {
if (newStep > step.get())
step.set(newStep);
}
/// Returns to the idle state, for example after the game was launched or the run was cancelled.
void finish() {
executor = null;
state.set(State.IDLE);
}
/// Shows `message` in the card together with the retry button.
void fail(String message) {
executor = null;
error.set(message);
state.set(State.FAILED);
}
/// Dismisses a failure and returns to the idle state.
public void dismiss() {
if (state.get() == State.FAILED)
state.set(State.IDLE);
}
/// Cancels the running task, if any, and returns to the idle state.
public void cancel() {
TaskExecutor current = executor;
cancelled = true;
if (current != null)
current.cancel();
finish();
}
/// Returns whether the last run was cancelled by the user.
boolean isCancelled() {
return cancelled;
}
/// Recomputes the fraction from finished downloads; stays indeterminate until a total is known.
private void refreshProgress(TaskExecutor current) {
if (executor != current || expectedFiles <= 0)
return;
double value = Math.min(0.99, (double) finishedFiles / expectedFiles);
if (value > progress.get())
progress.set(value);
}
/// Submits `task` to the serial task queue and mirrors its task count into the progress.
///
/// @param task the task to run
void submit(Task<?> task) {
TaskExecutor taskExecutor = task.executor();
executor = taskExecutor;
taskExecutor.addTaskListener(new TaskListener() {
/// Tasks announce how many downloads they will start through the `total` property
/// (set once the file list is known), so the denominator never grows with discovered tasks.
@Override
public void onPropertiesUpdate(Task<?> updated) {
Object total = updated.getProperties().get("total");
if (total instanceof Integer count && count > 0) {
FXUtils.runInFX(() -> {
if (announcedTasks.add(updated))
expectedFiles += count;
refreshProgress(taskExecutor);
});
}
}
@Override
public void onFinished(Task<?> finishedTask) {
if (finishedTask instanceof FileDownloadTask) {
FXUtils.runInFX(() -> {
finishedFiles++;
refreshProgress(taskExecutor);
});
}
}
});
TaskManager.getInstance().submit(taskExecutor, null);
}
}
@@ -13,6 +13,10 @@ 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.ModpackConfiguration;
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;
@@ -22,21 +26,26 @@ import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.download.LocalModpackPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -45,36 +54,244 @@ public final class Instances {
private Instances() {
}
/// Installs or updates the server client from the launcher-bundled modpack when present.
/// Returns whether the installed server modpack is behind the remote `server-manifest.json`.
///
/// Falls back to the local/remote modpack import wizard when no bundled package is found.
public static void installServerModpack() {
/// Compares the local embedded manifest version (and file path/hash set) with
/// `fileApi/server-manifest.json`. Network or parse failures return `false` so the home button
/// stays on "join".
public static boolean isClientUpdateAvailable(@Nullable HMCLGameInstance instance) {
if (instance == null) {
return false;
}
try {
Path configurationFile = instance.getModpackConfigurationFile();
if (Files.notExists(configurationFile)) {
return false;
}
ModpackConfiguration<ServerModpackManifest> configuration = JsonUtils.fromJsonFile(
configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
ServerModpackManifest local = configuration.getManifest();
if (local == null || StringUtils.isBlank(local.getFileApi())) {
return false;
}
String manifestUrl = StringUtils.removeSuffix(local.getFileApi().trim(), "/") + "/server-manifest.json";
ServerModpackManifest remote = JsonUtils.fromNonNullJson(
NetworkUtils.doGet(manifestUrl), ServerModpackManifest.class);
if (!Objects.equals(local.getVersion(), remote.getVersion())) {
return true;
}
return !fileHashes(local).equals(fileHashes(remote));
} catch (Exception e) {
LOG.warning("Failed to check TDLM client update", e);
return false;
}
}
private static Map<String, String> fileHashes(ServerModpackManifest manifest) {
return manifest.getFiles().stream().collect(Collectors.toMap(
ModpackConfiguration.FileInformation::getPath,
ModpackConfiguration.FileInformation::getHash,
(left, right) -> right));
}
/// Installs or updates the TDLM client from fileApi / bundled zip without opening the install UI,
/// then joins the configured server when ready.
///
/// Prefer this for the home Start / Join button. Existing server-modpack instances get differential
/// updates during launch via [org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask].
public static void ensureClientAndJoin() {
ensureClientAndJoin(null);
}
/// Same as [#ensureClientAndJoin()], but reports task progress to `progress` instead of opening a
/// task dialog, so the home card can show it in place. Errors are shown through `progress` too.
///
/// @param progress the progress model to drive, or `null` to use the task dialog
public static void ensureClientAndJoin(@Nullable ClientSyncProgress progress) {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (!repository.isLoaded()) {
return;
}
Path bundled = Metadata.findBundledModpackFile();
if (bundled != null) {
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, bundled),
i18n("install.modpack"));
// Already installed: join immediately. Launch runs ServerModpackCompletionTask for updates.
if (repository.getSelectedInstance() != null && Metadata.findBundledModpackFile() == null) {
joinServer(repository.getSelectedInstance());
return;
}
importModpack();
Path bundled = Metadata.findBundledModpackFile();
if (bundled != null) {
installBundledThenJoin(repository, bundled, progress);
return;
}
if (progress != null) {
progress.begin();
}
Task<?> task = new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
.thenComposeAsync(Schedulers.javafx(), result -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
result, ServerModpackManifest.class);
if (manifest == null) {
throw new IOException(i18n("modpack.type.server.malformed"));
}
if (progress != null) {
// Java is prepared by the launch step; mark it as passed once the manifest is known.
progress.advanceTo(ClientSyncProgress.STEP_SYNC);
}
return ensureRemoteClientInstalled(repository, manifest);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install or update TDLM client", exception);
reportFailure(progress, exception);
return;
}
if (progress != null) {
progress.advanceTo(ClientSyncProgress.STEP_JOIN);
}
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
if (progress != null) {
progress.advanceTo(ClientSyncProgress.STEP_JOIN + 1);
progress.finish();
}
});
runSyncTask(task, progress, TaskCancellationAction.NORMAL);
}
public static void importModpack() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (repository.isLoaded()) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository), i18n("install.modpack"));
/// Runs `task` in the progress card when `progress` is set, otherwise in the task dialog.
private static void runSyncTask(Task<?> task, @Nullable ClientSyncProgress progress, TaskCancellationAction cancellation) {
if (progress != null) {
progress.submit(task);
} else {
Controllers.taskDialog(task, i18n("modpack.installing"), cancellation);
}
}
/// @deprecated use [#installServerModpack()]
/// Shows a failure in the progress card, or in an error dialog when there is no card.
/// A user-requested cancellation is not reported as an error.
private static void reportFailure(@Nullable ClientSyncProgress progress, Throwable exception) {
if (progress != null) {
if (progress.isCancelled()) {
progress.finish();
} else {
progress.fail(StringUtils.getStackTrace(exception));
}
return;
}
Controllers.dialog(
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
}
/// Silent first-install / update from the org client manifest — no wizard UI.
public static void installServerModpack() {
ensureClientAndJoin();
}
/// Install wizard is disabled for the dedicated server-client launcher.
public static void importModpack() {
LOG.info("Ignoring importModpack: install UI is disabled; use Start to install/update");
}
/// @deprecated use [#ensureClientAndJoin()]
public static void addNewGame() {
installServerModpack();
ensureClientAndJoin();
}
/// Installs a local modpack zip without the wizard, then selects the new instance.
public static void installLocalModpackSilently(HMCLGameRepository repository, Path file) {
if (!repository.isLoaded()) {
return;
}
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(file);
String name = LocalModpackPage.processModpackName(modpack.getName());
if (StringUtils.isBlank(name)) {
name = "TDLM";
}
GameInstanceID instanceId = new GameInstanceID(name);
if (repository.hasInstance(instanceId)) {
instanceId = new GameInstanceID(name + "-" + System.currentTimeMillis());
}
GameInstanceID id = instanceId;
return ModpackHelper.getInstallTask(repository, file, id, modpack, null)
.thenRunAsync(Schedulers.javafx(), () ->
repository.setSelectedInstance(repository.getInstance(id)));
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install local modpack", exception);
Controllers.dialog(
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
}
}),
i18n("modpack.installing"),
TaskCancellationAction.NORMAL);
}
private static void installBundledThenJoin(HMCLGameRepository repository, Path bundled, @Nullable ClientSyncProgress progress) {
if (progress != null) {
progress.begin();
progress.advanceTo(ClientSyncProgress.STEP_SYNC);
}
Task<?> task = Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(bundled);
String name = LocalModpackPage.processModpackName(modpack.getName());
if (StringUtils.isBlank(name)) {
name = "TDLM";
}
GameInstanceID instanceId = new GameInstanceID(name);
return ModpackHelper.getInstallTask(repository, bundled, instanceId, modpack, null)
.thenRunAsync(Schedulers.javafx(), () -> {
repository.setSelectedInstance(repository.getInstance(instanceId));
try {
Files.deleteIfExists(bundled);
} catch (IOException e) {
LOG.warning("Failed to delete bundled modpack: " + bundled, e);
}
});
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
reportFailure(progress, exception);
return;
}
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
if (progress != null) {
progress.advanceTo(ClientSyncProgress.STEP_JOIN + 1);
progress.finish();
}
});
runSyncTask(task, progress, TaskCancellationAction.NO_CANCEL);
}
/// Installs when missing; selects an existing instance otherwise.
///
/// Differential updates for an installed server modpack run during launch via
/// [org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask].
private static Task<?> ensureRemoteClientInstalled(
HMCLGameRepository repository, ServerModpackManifest manifest) throws IOException {
Modpack modpack = manifest.toModpack();
String name = LocalModpackPage.processModpackName(manifest.getName());
if (StringUtils.isBlank(name)) {
name = "TDLM";
}
GameInstanceID instanceId = new GameInstanceID(name);
if (repository.hasInstance(instanceId)) {
repository.setSelectedInstance(repository.getInstance(instanceId));
return Task.completed(null);
}
return ModpackHelper.getInstallTask(repository, manifest, instanceId, modpack)
.thenRunAsync(Schedulers.javafx(), () ->
repository.setSelectedInstance(repository.getInstance(instanceId)));
}
public static void deleteInstance(HMCLGameInstance gameInstance) {
@@ -227,8 +444,25 @@ public final class Instances {
.addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false)));
}
/// Asks whether to update and relaunch after the game requested a restart (see [RestartRequest]).
///
/// Yes runs the Start flow, which syncs the client pack and joins the server again; No leaves the
/// launcher as it is. The request file is already deleted, so the prompt shows only once.
///
/// @param gameInstance the instance that just exited
/// @param reason the reason from the request file, for example `update`
public static void offerRestart(HMCLGameInstance gameInstance, String reason) {
Controllers.confirm(
i18n("home.restart." + ("update".equals(reason) ? "update" : "generic")),
i18n("home.restart.title"),
() -> updateInstance(gameInstance),
null);
}
/// Updates via fileApi through the Start flow — no install wizard.
public static void updateInstance(HMCLGameInstance gameInstance) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(gameInstance.getRepository(), gameInstance.getId()));
gameInstance.getRepository().setSelectedInstance(gameInstance);
ensureClientAndJoin();
}
/// Queues an asset check with a fresh asset index for the supplied instance.
@@ -308,14 +542,7 @@ public final class Instances {
@SafeVarargs
public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
if (gameInstance == null) {
JFXButton installModpack = new JFXButton(i18n("install.modpack"));
installModpack.getStyleClass().add("dialog-accept");
installModpack.setOnAction(e -> installServerModpack());
Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"),
MessageDialogPane.MessageType.ERROR,
installModpack,
null);
ensureClientAndJoin();
return;
}
@@ -382,11 +609,10 @@ public final class Instances {
Controllers.navigate(Controllers.getSettingsPage());
}
/// Instance management UI is disabled for the dedicated server-client launcher.
public static void modifyGameSettings(HMCLGameInstance gameInstance) {
Controllers.getGameInstancePage().setInstance(gameInstance.getId(), gameInstance.getRepository());
Controllers.getGameInstancePage().showInstanceSettings();
// VersionPage.loadVersion will be invoked after navigation
Controllers.navigate(Controllers.getGameInstancePage());
LOG.info("Ignoring modifyGameSettings for " + gameInstance.getId()
+ ": instance management UI is disabled");
}
}
@@ -3,11 +3,18 @@
package org.jackhuang.hmcl.ui.main;
import com.google.gson.*;
import com.jfoenix.controls.JFXButton;
import javafx.geometry.Insets;
import javafx.scene.control.ScrollPane;
import javafx.scene.image.Image;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
@@ -20,7 +27,6 @@ import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.platform.hardware.CentralProcessor;
import org.jackhuang.hmcl.util.platform.hardware.DeviceInfo;
import org.jackhuang.hmcl.util.platform.hardware.GraphicsCard;
import org.jackhuang.hmcl.util.platform.hardware.PageFileConfiguration;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
@@ -34,16 +40,22 @@ import static org.jackhuang.hmcl.util.i18n.I18n.getTranslator;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays launcher, system, device, runtime, and project information.
/// Displays a compact about page: identity, update check, licenses, source link and diagnostics.
///
/// System, device and runtime details are not listed; they are collected by "Copy diagnostics".
@NotNullByDefault
public final class AboutPage extends SpinnerPane {
/// The upstream project that this launcher is based on, and the source of its GPL-licensed code.
private static final String SOURCE_URL = "https://github.com/HMCL-dev/HMCL";
/// Keeps theme listeners alive while the page is visible.
private final WeakListenerHolder holder = new WeakListenerHolder();
/// Builds the page from cached hardware information and bundled project lists.
/// Builds the page from bundled project lists.
public AboutPage() {
VBox content = new VBox();
VBox content = new VBox(13);
content.setPadding(new Insets(21));
content.getStyleClass().add("spinner-pane-content");
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
@@ -56,196 +68,112 @@ public final class AboutPage extends SpinnerPane {
launcher.setLargeTitle(true);
launcher.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png"));
launcher.setTitle(Metadata.FULL_NAME);
launcher.setSubtitle(Metadata.VERSION);
launcher.setSubtitle(Metadata.VERSION + " · " + i18n("about.based_on"));
JFXButton checkUpdate = FXUtils.newBorderButton(i18n("about.check_update"));
checkUpdate.setOnAction(e -> checkClientUpdate(checkUpdate));
launcher.setRight(checkUpdate);
about.getContent().setAll(launcher);
}
ComponentList systemInfo = new ComponentList();
{
var system = new LinePane();
system.setLargeTitle(true);
system.setLeading(SVG.LUCIDE_APP_WINDOW.createIcon(32));
system.setTitle(i18n("about.system.os"));
system.setSubtitle((OperatingSystem.OS_RELEASE_PRETTY_NAME == null
? "%s %s".formatted(OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion())
: "%s (%s %s)".formatted(OperatingSystem.OS_RELEASE_PRETTY_NAME, OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion())));
systemInfo.getContent().add(system);
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && SystemInfo.getDesktopEnvironment() != null) {
var desktop = new LinePane();
desktop.setLargeTitle(true);
desktop.setLeading(SVG.LUCIDE_PANELS_TOP_LEFT.createIcon(32));
desktop.setTitle(i18n("about.system.desktop"));
desktop.setSubtitle(SystemInfo.getDesktopEnvironment());
systemInfo.getContent().add(desktop);
}
}
ComponentList deviceInfo = new ComponentList();
{
@Nullable DeviceInfo deviceIdentity = SystemInfo.getDeviceInfo();
if (deviceIdentity != null
&& !"Virtual Machine".equals(deviceIdentity.name())
&& OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS || !Architecture.SYSTEM_ARCH.isX86())) {
var device = new LinePane();
device.setLargeTitle(true);
device.setLeading(SVG.HOST.createIcon(32));
device.setTitle(i18n("about.device.model"));
device.setSubtitle(getTranslator().formatDeviceName(deviceIdentity));
deviceInfo.getContent().add(device);
}
@Unmodifiable List<CentralProcessor> processors = SystemInfo.getCentralProcessors();
for (int i = 0; i < processors.size(); i++) {
String title = processors.size() == 1
? i18n("about.device.cpu")
: i18n("about.device.cpu.n", i + 1);
CentralProcessor processor = processors.get(i);
String name = processor.getName();
@Nullable CentralProcessor.Cores cores = processor.getCores();
String subtitle;
if (cores == null)
subtitle = i18n("about.device.cpu.name", name);
else if (cores.efficiency() > 0) {
subtitle = cores.logical() == cores.physical()
? i18n("about.device.cpu.name.cores.hybrid", name, cores.performance(), cores.efficiency())
: i18n("about.device.cpu.name.cores.hybrid.threads", name, cores.performance(), cores.efficiency(), cores.logical());
} else {
subtitle = cores.logical() == cores.physical()
? i18n("about.device.cpu.name.cores", name, cores.physical())
: i18n("about.device.cpu.name.cores.threads", name, cores.physical(), cores.logical());
}
var pane = new LinePane();
pane.setLargeTitle(true);
pane.setTitle(title);
pane.setSubtitle(subtitle);
pane.setLeading(SVG.LUCIDE_CPU.createIcon(32));
pane.getTags().add(new Tag(Architecture.SYSTEM_ARCH.getDisplayName()));
deviceInfo.getContent().add(pane);
}
@Unmodifiable List<GraphicsCard> visibleGraphicsCards = SystemInfo.getGraphicsCards().stream()
.filter(card -> card.getType() != GraphicsCard.Type.DISPLAY_ONLY && !"Unknown".equals(card.getName()))
.sorted(Comparator.comparing(GraphicsCard::getType))
.toList();
for (int i = 0; i < visibleGraphicsCards.size(); i++) {
GraphicsCard graphicsCard = visibleGraphicsCards.get(i);
String title = visibleGraphicsCards.size() == 1
? i18n("about.device.gpu")
: i18n("about.device.gpu.n", i + 1);
var button = new LinePane();
button.setLargeTitle(true);
button.setLeading(SVG.LUCIDE_GPU.createIcon(32));
button.setTitle(title);
StringBuilder subtitle = new StringBuilder(graphicsCard.getName());
if (graphicsCard.getDriver() != null || graphicsCard.getDriverVersion() != null) {
subtitle.append(" (");
if (graphicsCard.getDriver() != null) {
subtitle.append(graphicsCard.getDriver());
if (graphicsCard.getDriverVersion() != null) {
subtitle.append(' ');
}
}
if (graphicsCard.getDriverVersion() != null) {
subtitle.append(graphicsCard.getDriverVersion());
}
subtitle.append(')');
}
button.setSubtitle(subtitle.toString());
switch (graphicsCard.getType()) {
case INTEGRATED -> button.getTags().add(new Tag(i18n("about.device.gpu.type.integrated")));
case DISCRETE -> button.getTags().add(new Tag(i18n("about.device.gpu.type.discrete")));
case VIRTUAL -> button.getTags().add(new Tag(i18n("about.device.gpu.type.virtual")));
}
deviceInfo.getContent().add(button);
}
if (SystemInfo.getTotalMemorySize() > 0) {
String subtitle = i18n("about.device.memory.system",
"%.1f GiB".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize())));
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
long pageFileSize = SystemInfo.getTotalPageFileSize();
if (pageFileSize < 0) {
subtitle = i18n("about.device.memory.page_files.unknown", subtitle);
} else {
String pageFileCapacity = "%.1f GiB".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(pageFileSize));
PageFileConfiguration configuration = SystemInfo.getPageFileConfiguration();
subtitle = switch (configuration.mode()) {
case MANUAL -> i18n("about.device.memory.page_files.maximum",
subtitle,
pageFileCapacity,
"%.1f GiB".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(configuration.maximumSize())));
case SYSTEM_MANAGED, MIXED, UNKNOWN -> i18n("about.device.memory.page_files", subtitle, pageFileCapacity);
};
}
}
var memory = new LinePane();
memory.setLargeTitle(true);
memory.setLeading(SVG.LUCIDE_MEMORY_STICK.createIcon(32));
memory.setTitle(i18n("about.device.memory"));
memory.setSubtitle(subtitle);
deviceInfo.getContent().add(memory);
}
}
ComponentList runtimeInfo = new ComponentList();
{
var java = new LinePane();
java.setLargeTitle(true);
java.setLeading(SVG.LOCAL_CAFE.createIcon(32));
java.setTitle("Java");
java.setSubtitle("%s, %s, %s".formatted(
Runtime.version().toString(),
System.getProperty("java.vm.name"),
System.getProperty("java.vendor")));
runtimeInfo.getContent().add(java);
var javaFX = new LinePane();
javaFX.setLargeTitle(true);
javaFX.setLeading(SVG.LOCAL_CAFE.createIcon(32));
javaFX.setTitle("JavaFX");
javaFX.setSubtitle(System.getProperty("javafx.runtime.version"));
runtimeInfo.getContent().add(javaFX);
}
ComponentList deps = loadIconedTwoLineList("/assets/about/deps.json");
// The HMCL GPLv3 notice and the third-party dependency list must stay reachable.
ComponentList legal = new ComponentList();
{
var copyright = new LinePane();
copyright.setLargeTitle(true);
copyright.setTitle(i18n("about.copyright"));
copyright.setSubtitle(i18n("about.copyright.statement"));
var license = new LinePane();
license.setLargeTitle(true);
license.setTitle(i18n("about.open_source"));
license.setSubtitle(i18n("about.open_source.statement") + "\n" + i18n("about.copyright.statement"));
legal.getContent().add(license);
legal.getContent().setAll(copyright);
var source = new LineButton();
source.setLargeTitle(true);
source.setTitle(i18n("about.source_code"));
source.setSubtitle(SOURCE_URL);
source.setTrailingIcon(SVG.OPEN_IN_NEW);
source.setOnAction(e -> FXUtils.openLink(SOURCE_URL));
legal.getContent().add(source);
ComponentList deps = loadIconedTwoLineList("/assets/about/deps.json");
var depsSublist = new ComponentSublist(() -> List.of(deps));
depsSublist.setTitle(i18n("about.dependency"));
legal.getContent().add(depsSublist);
}
content.getChildren().setAll(
ComponentList.createComponentListTitle(i18n("about")),
about,
ComponentList.createComponentListTitle(i18n("about.system")),
systemInfo,
ComponentList.createComponentListTitle(i18n("about.device")),
deviceInfo,
ComponentList.createComponentListTitle(i18n("about.runtime")),
runtimeInfo,
ComponentList.createComponentListTitle(i18n("about.dependency")),
deps,
ComponentList.createComponentListTitle(i18n("about.legal")),
legal
);
ComponentList support = new ComponentList();
{
var diagnostics = new LineButton();
diagnostics.setLargeTitle(true);
diagnostics.setTitle(i18n("about.copy_diagnostics"));
diagnostics.setTrailingIcon(SVG.CONTENT_COPY);
diagnostics.setOnAction(e -> FXUtils.copyText(buildDiagnostics()));
support.getContent().add(diagnostics);
}
content.getChildren().setAll(about, legal, support);
}
/// Checks whether the installed client pack is behind the remote manifest and tells the user.
///
/// @param button the button to disable while the check runs
private static void checkClientUpdate(JFXButton button) {
HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instance == null) {
Controllers.showToast(i18n("about.check_update.not_installed"));
return;
}
button.setDisable(true);
Schedulers.io().execute(() -> {
boolean available = Instances.isClientUpdateAvailable(instance);
FXUtils.runInFX(() -> {
button.setDisable(false);
Controllers.showToast(i18n(available ? "about.check_update.available" : "about.check_update.latest"));
});
});
}
/// Collects launcher, system, device and runtime information as plain text for bug reports.
private static String buildDiagnostics() {
StringBuilder sb = new StringBuilder();
sb.append(Metadata.FULL_NAME).append(' ').append(Metadata.VERSION).append('\n');
sb.append("OS: ").append(OperatingSystem.OS_RELEASE_PRETTY_NAME == null
? "%s %s".formatted(OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion())
: "%s (%s %s)".formatted(OperatingSystem.OS_RELEASE_PRETTY_NAME, OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion()))
.append(" (").append(Architecture.SYSTEM_ARCH.getDisplayName()).append(")\n");
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && SystemInfo.getDesktopEnvironment() != null)
sb.append("Desktop: ").append(SystemInfo.getDesktopEnvironment()).append('\n');
@Nullable DeviceInfo deviceIdentity = SystemInfo.getDeviceInfo();
if (deviceIdentity != null && !"Virtual Machine".equals(deviceIdentity.name()))
sb.append("Device: ").append(getTranslator().formatDeviceName(deviceIdentity)).append('\n');
for (CentralProcessor processor : SystemInfo.getCentralProcessors())
sb.append("CPU: ").append(processor.getName()).append('\n');
@Unmodifiable List<GraphicsCard> cards = SystemInfo.getGraphicsCards().stream()
.filter(card -> card.getType() != GraphicsCard.Type.DISPLAY_ONLY && !"Unknown".equals(card.getName()))
.sorted(Comparator.comparing(GraphicsCard::getType))
.toList();
for (GraphicsCard card : cards) {
sb.append("GPU: ").append(card.getName());
if (card.getDriver() != null || card.getDriverVersion() != null) {
sb.append(" (");
if (card.getDriver() != null)
sb.append(card.getDriver());
if (card.getDriverVersion() != null)
sb.append(card.getDriver() != null ? " " : "").append(card.getDriverVersion());
sb.append(')');
}
sb.append('\n');
}
if (SystemInfo.getTotalMemorySize() > 0)
sb.append("Memory: %.1f GiB%n".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize())));
sb.append("Java: %s, %s, %s%n".formatted(
Runtime.version(), System.getProperty("java.vm.name"), System.getProperty("java.vendor")));
sb.append("JavaFX: ").append(System.getProperty("javafx.runtime.version")).append('\n');
return sb.toString();
}
private static Image loadImage(String url) {
@@ -4,60 +4,21 @@ package org.jackhuang.hmcl.ui.main;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jetbrains.annotations.NotNullByDefault;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class LauncherSettingsPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
/// The launcher settings destination: a single [SettingsPage] without sidebar tabs.
@NotNullByDefault
public class LauncherSettingsPage extends StackPane implements DecoratorPage {
/// The page state holding the "Settings" title.
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings")));
/// Manages tab selection and content transitions independently of the sidebar.
private final TabController tabController;
/// The tab whose content manages installed Java runtimes.
private final TabItem<JavaManagementPage> javaManagementTab;
/// The tab for general launcher settings.
private final TabItem<SettingsPage> settingsTab;
/// The tab for download settings.
private final TabItem<DownloadSettingsPage> downloadTab;
/// The tab displaying launcher information.
private final TabItem<AboutPage> aboutTab;
private final TransitionPane transitionPane = new TransitionPane();
/// Creates the settings tabs and fills the shared navigation sidebar.
/// Creates the page with the single settings view.
public LauncherSettingsPage() {
javaManagementTab = new TabItem<>(JavaManagementPage::new);
settingsTab = new TabItem<>(SettingsPage::new);
downloadTab = new TabItem<>(DownloadSettingsPage::new);
aboutTab = new TabItem<>(AboutPage::new);
tabController = new TabController(transitionPane,
javaManagementTab,
settingsTab,
downloadTab,
aboutTab);
tabController.selectTab(settingsTab);
getSidebar()
.addNavigationDrawerTab(tabController, javaManagementTab, i18n("java.management"), SVG.LOCAL_CAFE, SVG.LOCAL_CAFE_FILL)
.addNavigationDrawerTab(tabController, settingsTab, i18n("settings.launcher.general"), SVG.TUNE)
.addNavigationDrawerTab(tabController, downloadTab, i18n("download"), SVG.DOWNLOAD)
.addNavigationDrawerTab(tabController, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL);
setContent(transitionPane);
}
@Override
public void onPageShown() {
tabController.onPageShown();
}
@Override
public void onPageHidden() {
tabController.onPageHidden();
getChildren().setAll(new SettingsPage());
}
@Override
@@ -6,6 +6,7 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
@@ -13,6 +14,7 @@ import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
@@ -26,6 +28,7 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.instances.ClientSyncProgress;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.ServerAddress;
@@ -58,6 +61,12 @@ public final class MainPage extends StackPane implements DecoratorPage {
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
.flatMap(HMCLGameRepository::snapshotProperty);
/// Bumped when the home join button starts a new remote-update check, so stale results are ignored.
private int updateCheckGeneration;
/// Progress of the install / update workflow, shown in place of the home card while it runs.
private final ClientSyncProgress syncProgress = new ClientSyncProgress();
{
HBox titleNode = new HBox(8);
titleNode.setPadding(new Insets(0, 0, 0, 2));
@@ -133,26 +142,40 @@ public final class MainPage extends StackPane implements DecoratorPage {
card.getStyleClass().add("home-card");
card.setAlignment(Pos.CENTER_LEFT);
card.setMaxHeight(Region.USE_PREF_SIZE);
StackPane.setAlignment(card, Pos.BOTTOM_CENTER);
// Server announcement (the server's MOTD), shown above the card only when there is one.
Label motdLabel = new Label();
motdLabel.getStyleClass().add("home-subtitle");
motdLabel.setWrapText(true);
motdLabel.setMaxHeight(Region.USE_PREF_SIZE);
Label motdTitle = factLabel(i18n("home.motd"));
VBox motdBox = new VBox(3, motdTitle, motdLabel);
motdBox.setPadding(new Insets(0, 0, 14, 4));
motdBox.setVisible(false);
motdBox.setManaged(false);
VBox bottom = new VBox(motdBox, card);
bottom.setMaxHeight(Region.USE_PREF_SIZE);
StackPane.setAlignment(bottom, Pos.BOTTOM_CENTER);
// Start always installs/updates from fileApi then joins — never opens install or instance UI.
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
FXUtils.onChangeAndOperate(currentGameProperty(), currentGame -> {
refreshJoinButton(joinButton, currentGame);
if (currentGame == null) {
joinButton.setText(i18n("instance.launch.empty"));
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launchNoGame);
packValue.setText(i18n("home.not_installed"));
routeFact.setVisible(false);
routeFact.setManaged(false);
serverFact.setVisible(false);
serverFact.setManaged(false);
routeFact.setOnMouseClicked(null);
routeFact.setCursor(Cursor.DEFAULT);
} else {
joinButton.setText(i18n("server.join"));
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
packValue.setText(currentGame.getId().toString());
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(currentGame.getModpackConfigurationFile());
routeFact.setVisible(route != null);
routeFact.setManaged(route != null);
routeValue.setText(route != null ? route.name() : "");
refreshServerStatus(route, serverFact, serverValue);
refreshServerStatus(route, serverFact, serverValue, motdBox, motdLabel);
// With more than one route (for example direct and CDN), the route can be switched.
List<ServerEndpoint.Route> routes = ServerEndpoint.routes(currentGame.getModpackConfigurationFile());
@@ -169,7 +192,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
items.add(new IconedMenuItem(null, candidate.name(), () -> {
ServerEndpoint.select(currentGame.getModpackConfigurationFile(), candidate);
routeValue.setText(candidate.name() + " ▾");
refreshServerStatus(candidate, serverFact, serverValue);
refreshServerStatus(candidate, serverFact, serverValue, motdBox, motdLabel);
}, popup));
}
list.getContent().setAll(items);
@@ -178,32 +201,160 @@ public final class MainPage extends StackPane implements DecoratorPage {
}
});
getChildren().addAll(art, hero, card);
// While the client is installed or updated the card turns into a progress view in place.
VBox progressCard = createProgressCard();
StackPane.setAlignment(progressCard, Pos.BOTTOM_CENTER);
progressCard.setMaxHeight(Region.USE_PREF_SIZE);
progressCard.visibleProperty().bind(syncProgress.stateProperty().isNotEqualTo(ClientSyncProgress.State.IDLE));
progressCard.managedProperty().bind(progressCard.visibleProperty());
// The whole bottom block (announcement + card) gives way to the progress card.
bottom.visibleProperty().bind(progressCard.visibleProperty().not());
bottom.managedProperty().bind(bottom.visibleProperty());
getChildren().addAll(art, hero, bottom, progressCard);
}
/// Builds the progress state of the home card: title with percentage, a thin progress bar,
/// transfer details, a cancel / retry button and the four-step indicator.
private VBox createProgressCard() {
Label title = new Label();
title.getStyleClass().add("home-progress-title");
ProgressBar bar = new ProgressBar();
bar.getStyleClass().add("home-progress-bar");
bar.setMaxWidth(Double.MAX_VALUE);
bar.progressProperty().bind(syncProgress.progressProperty());
Label detail = new Label();
detail.getStyleClass().add("home-fact-label");
detail.textProperty().bind(syncProgress.detailProperty());
Label error = new Label();
error.getStyleClass().add("home-progress-error");
error.setWrapText(true);
error.setMaxHeight(55);
error.textProperty().bind(syncProgress.errorProperty());
VBox status = new VBox(8, title, bar, detail, error);
status.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(status, Priority.ALWAYS);
JFXButton action = new JFXButton();
action.getStyleClass().add("home-progress-button");
action.setOnAction(e -> {
if (syncProgress.stateProperty().get() == ClientSyncProgress.State.FAILED) {
Instances.ensureClientAndJoin(syncProgress);
} else {
syncProgress.cancel();
}
});
// A failed run can also be dismissed to get back to the normal card.
JFXButton dismiss = new JFXButton(i18n("button.cancel"));
dismiss.getStyleClass().add("home-progress-button");
dismiss.setOnAction(e -> syncProgress.dismiss());
dismiss.visibleProperty().bind(syncProgress.stateProperty().isEqualTo(ClientSyncProgress.State.FAILED));
dismiss.managedProperty().bind(dismiss.visibleProperty());
HBox top = new HBox(21, status, new HBox(8, dismiss, action));
top.setAlignment(Pos.CENTER_LEFT);
String[] stepKeys = {"home.progress.step.check", "home.progress.step.java", "home.progress.step.sync", "home.progress.step.join"};
HBox steps = new HBox(21);
Label[] stepLabels = new Label[stepKeys.length];
for (int i = 0; i < stepKeys.length; i++) {
stepLabels[i] = new Label();
stepLabels[i].getStyleClass().add("home-progress-step");
steps.getChildren().add(stepLabels[i]);
}
Runnable refresh = () -> {
ClientSyncProgress.State state = syncProgress.stateProperty().get();
boolean failed = state == ClientSyncProgress.State.FAILED;
int current = syncProgress.stepProperty().get();
for (int i = 0; i < stepKeys.length; i++) {
boolean done = i < current;
boolean active = i == current && !failed;
stepLabels[i].setText((done ? "✓ " : active ? "● " : "○ ") + i18n(stepKeys[i]));
stepLabels[i].pseudoClassStateChanged(PseudoClass.getPseudoClass("done"), done);
stepLabels[i].pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), active);
}
int percent = (int) Math.round(Math.max(0, syncProgress.progressProperty().get()) * 100);
if (failed) {
title.setText(i18n("home.progress.failed"));
action.setText(i18n("home.progress.retry"));
} else {
title.setText(current <= ClientSyncProgress.STEP_JAVA
? i18n("home.progress.checking")
: i18n("home.progress.syncing", percent));
action.setText(i18n("button.cancel"));
}
error.setVisible(failed);
error.setManaged(failed);
detail.setVisible(!failed);
detail.setManaged(!failed);
};
syncProgress.stateProperty().addListener(o -> refresh.run());
syncProgress.stepProperty().addListener(o -> refresh.run());
syncProgress.progressProperty().addListener(o -> refresh.run());
refresh.run();
VBox box = new VBox(21, top, steps);
box.getStyleClass().add("home-card");
return box;
}
/// Sets the home button to install / update / join based on the selected instance and remote manifest.
private void refreshJoinButton(JFXButton joinButton, @Nullable HMCLGameInstance currentGame) {
int generation = ++updateCheckGeneration;
if (currentGame == null) {
joinButton.setText(i18n("instance.launch.empty"));
return;
}
joinButton.setText(i18n("server.join"));
Schedulers.io().execute(() -> {
boolean updateAvailable = Instances.isClientUpdateAvailable(currentGame);
FXUtils.runInFX(() -> {
if (generation != updateCheckGeneration || getCurrentGame() != currentGame) {
return;
}
joinButton.setText(i18n(updateAvailable ? "server.update" : "server.join"));
});
});
}
/// Pings the server behind `route` in the background and shows the result in `serverValue`.
///
/// WebSocket routes cannot be pinged with plain TCP, so the status is hidden for them.
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue) {
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue,
Node motdBox, Label motdLabel) {
String address = route == null ? "" : route.address().trim();
boolean pingable = !address.isEmpty() && !address.toLowerCase(java.util.Locale.ROOT).startsWith("ws");
serverFact.setVisible(pingable);
serverFact.setManaged(pingable);
motdBox.setVisible(false);
motdBox.setManaged(false);
if (!pingable) {
return;
}
serverValue.setText("…");
Schedulers.io().execute(() -> {
String text;
String motd = "";
try {
ServerAddress parsed = ServerAddress.parse(address);
ServerStatusPing.Status status = ServerStatusPing.ping(parsed.host(), parsed.port() >= 0 ? parsed.port() : 25565, 3000);
text = status.online() + " / " + status.max() + " · " + status.latencyMillis() + " ms";
motd = status.motd();
} catch (IOException | IllegalArgumentException e) {
text = i18n("home.server.unreachable");
}
String result = text;
FXUtils.runInFX(() -> serverValue.setText(result));
String announcement = motd;
FXUtils.runInFX(() -> {
serverValue.setText(result);
// Unreachable or empty MOTD: stay silent, the announcement area stays hidden.
boolean show = !announcement.isEmpty();
motdLabel.setText(announcement);
motdBox.setVisible(show);
motdBox.setManaged(show);
});
});
}
@@ -214,14 +365,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
return label;
}
/// Installs or updates the client from fileApi when needed, then launches into the server.
private void launch() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
Instances.joinServer(repository.getSelectedInstance());
}
/// Opens the server modpack install/update flow when no instance is selected.
private void launchNoGame() {
Instances.installServerModpack();
Instances.ensureClientAndJoin(syncProgress);
}
@Override
@@ -4,7 +4,6 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.ReadOnlyObjectProperty;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
@@ -17,8 +16,6 @@ import org.jackhuang.hmcl.ui.account.AccountListPopupMenu;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.instances.GameAdvancedListItem;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
@@ -43,20 +40,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
accountListItem.setOnAction(e -> AccountListPopupMenu.show(accountListItem, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -accountListItem.getHeight()));
accountListItem.accountProperty().bind(Accounts.selectedAccountProperty());
// second item in left sidebar
GameAdvancedListItem gameInstanceItem = new GameAdvancedListItem();
gameInstanceItem.setOnAction(e -> {
@Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instance == null) {
Instances.installServerModpack();
} else {
Instances.modifyGameSettings(instance);
}
});
if (AnimationUtils.isAnimationEnabled()) {
gameInstanceItem.setPrepareAction(Controllers::prepareGameInstancePage);
}
// Instance management entry removed: Start on the home page installs/updates and joins.
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
launcherSettingsItem.setLeftIcon(SVG.SETTINGS);
launcherSettingsItem.setTitle(i18n("settings"));
@@ -68,9 +52,8 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
launcherSettingsItem.setPrepareAction(Controllers::prepareSettingsPage);
}
// The sidebar holds the modpack and settings entries; the account sits at the bottom-left.
// Sidebar: settings only; account sits at the bottom-left.
getSidebar()
.add(gameInstanceItem)
.add(launcherSettingsItem);
setSidebarBottom(accountListItem);
@@ -92,9 +75,9 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
modpacks -> {
Path file = modpacks.get(0);
if (ModpackHelper.isFileModpackByExtension(file)) {
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(GameDirectoryManager.getSelectedRepository(), file),
i18n("install.modpack"));
// Silent install — no modpack wizard UI.
Instances.installLocalModpackSilently(
GameDirectoryManager.getSelectedRepository(), file);
} else if ("json".equalsIgnoreCase(FileUtils.getExtension(file))) {
Instances.installFromJson(GameDirectoryManager.getSelectedRepository(), file);
}
@@ -3,7 +3,13 @@
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXSlider;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.layout.StackPane;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
@@ -11,6 +17,14 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.DataSizeUnit;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -33,6 +47,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.zip.GZIPInputStream;
@@ -43,90 +58,177 @@ import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// The single launcher settings page: general, game and help cards.
///
/// Java management and the about page are reachable from the game and help cards and open as sub pages.
public final class SettingsPage extends ScrollPane {
/// Smallest selectable maximum game memory in MiB.
private static final int MIN_MEMORY_MIB = 1024;
/// Step of the memory slider in MiB.
private static final int MEMORY_STEP_MIB = 256;
/// Builds the three setting cards.
public SettingsPage() {
this.setFitToWidth(true);
VBox rootPane = new VBox(10);
rootPane.setPadding(new Insets(10));
VBox rootPane = new VBox(13);
rootPane.setPadding(new Insets(21));
this.setContent(rootPane);
FXUtils.smoothScrolling(this);
{
ComponentList languagePaneList = new ComponentList();
rootPane.getChildren().addAll(
ComponentList.createComponentListTitle(i18n("settings.launcher.general")), createGeneralCard(),
ComponentList.createComponentListTitle(i18n("settings.group.game")), createGameCard(),
ComponentList.createComponentListTitle(i18n("settings.group.help")), createHelpCard());
}
{
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
/// Creates the card with the language and download source choices.
private static ComponentList createGeneralCard() {
ComponentList list = new ComponentList();
SupportedLocale currentLocale = I18n.getLocale();
chooseLanguagePane.setNullSafeConverter(locale -> {
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
});
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
chooseLanguagePane.valueProperty().bindBidirectional(settings().languageProperty());
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
languagePaneList.getContent().add(chooseLanguagePane);
SupportedLocale currentLocale = I18n.getLocale();
chooseLanguagePane.setNullSafeConverter(locale -> {
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
});
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
chooseLanguagePane.valueProperty().bindBidirectional(settings().languageProperty());
list.getContent().add(chooseLanguagePane);
}
// One choice drives both the file download source and the version list source.
var downloadSourcePane = new LineSelectButton<DownloadSource>();
downloadSourcePane.setTitle(i18n("settings.launcher.download_source"));
downloadSourcePane.setNullSafeConverter(source -> i18n("settings.download_source." + source.name().toLowerCase(Locale.ROOT)));
downloadSourcePane.setItems(List.of(DownloadSource.values()));
downloadSourcePane.valueProperty().bindBidirectional(settings().fileDownloadSourceProperty());
downloadSourcePane.valueProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null)
settings().versionListSourceProperty().set(newValue);
});
list.getContent().add(downloadSourcePane);
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.language")), languagePaneList);
return list;
}
/// Creates the card with the maximum memory slider and the Java summary row.
private static ComponentList createGameCard() {
ComponentList list = new ComponentList();
GameSettings.Preset preset = SettingsManager.getDefaultGameSettingsPresetOrCreate();
int totalMiB = Math.max(MIN_MEMORY_MIB, (int) DataSizeUnit.MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()));
JFXSlider slider = new JFXSlider(MIN_MEMORY_MIB, totalMiB, MIN_MEMORY_MIB);
slider.setBlockIncrement(MEMORY_STEP_MIB);
slider.setPrefWidth(280);
slider.setValueFactory(s -> Bindings.createStringBinding(
() -> "%.1f GB".formatted(s.getValue() / 1024), s.valueProperty()));
@Nullable Integer current = preset.maxMemoryProperty().getValue();
slider.setValue(current != null && current > 0 ? Math.min(current, totalMiB) : Math.min(4096, totalMiB));
// Moving the slider switches off automatic allocation and stores the chosen limit.
slider.valueProperty().addListener((observable, oldValue, newValue) -> {
int mib = (int) (Math.round(newValue.doubleValue() / MEMORY_STEP_MIB) * MEMORY_STEP_MIB);
preset.autoMemoryProperty().setValue(false);
preset.maxMemoryProperty().setValue(Math.max(MIN_MEMORY_MIB, mib));
});
BorderPane memoryPane = new BorderPane();
Label memoryLabel = new Label(i18n("settings.memory"));
BorderPane.setAlignment(memoryLabel, Pos.CENTER_LEFT);
memoryPane.setLeft(memoryLabel);
memoryPane.setRight(slider);
list.getContent().add(memoryPane);
BorderPane javaPane = new BorderPane();
Label javaLabel = new Label(i18n("settings.java.summary"));
BorderPane.setAlignment(javaLabel, Pos.CENTER_LEFT);
javaPane.setLeft(javaLabel);
JFXButton advanced = FXUtils.newBorderButton(i18n("settings.java.advanced"));
advanced.setOnAction(e -> Controllers.navigate(new SubPage(i18n("java.management"), new JavaManagementPage())));
javaPane.setRight(advanced);
list.getContent().add(javaPane);
return list;
}
/// Creates the card with the log actions and the entry to the about page.
private ComponentList createHelpCard() {
ComponentList list = new ComponentList();
BorderPane debugPane = new BorderPane();
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
openLogFolderButton.setOnAction(e -> openLogFolder());
openLogFolderButton.getStyleClass().add("jfx-button-border");
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
SpinnerPane exportLogPane = new SpinnerPane();
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
exportLogPane.setContent(logButton);
logButton.setOnAction(e -> {
exportLogPane.showSpinner();
onExportLogs().whenCompleteAsync((result, exception) -> {
exportLogPane.hideSpinner();
if (exception == null) {
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
FXUtils.showFileInExplorer(result);
} else {
LOG.warning("Failed to export logs", exception);
Controllers.dialog(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
null,
MessageType.ERROR
);
}
}, Schedulers.javafx());
});
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
list.getContent().add(debugPane);
var about = new LineButton();
about.setTitle(i18n("settings.help.about"));
about.setTrailingIcon(SVG.ARROW_FORWARD);
about.setOnAction(e -> Controllers.navigate(new SubPage(i18n("about"), new AboutPage())));
list.getContent().add(about);
return list;
}
/// A titled page that hosts a settings sub page (Java management, about) inside the main navigator.
private static final class SubPage extends StackPane implements DecoratorPage {
/// The page state, holding the title shown in the window header.
private final ReadOnlyObjectWrapper<State> state;
/// Creates the page.
///
/// @param title the title shown in the header
/// @param content the page content
SubPage(String title, Node content) {
this.state = new ReadOnlyObjectWrapper<>(State.fromTitle(title));
getChildren().setAll(content);
}
{
ComponentList miscPaneList = new ComponentList();
{
BorderPane debugPane = new BorderPane();
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
openLogFolderButton.setOnAction(e -> openLogFolder());
openLogFolderButton.getStyleClass().add("jfx-button-border");
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
SpinnerPane exportLogPane = new SpinnerPane();
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
exportLogPane.setContent(logButton);
logButton.setOnAction(e -> {
exportLogPane.showSpinner();
onExportLogs().whenCompleteAsync((result, exception) -> {
exportLogPane.hideSpinner();
if (exception == null) {
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
FXUtils.showFileInExplorer(result);
} else {
LOG.warning("Failed to export logs", exception);
Controllers.dialog(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
null,
MessageType.ERROR
);
}
}, Schedulers.javafx());
});
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
miscPaneList.getContent().add(debugPane);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.misc")), miscPaneList);
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
}
@@ -2237,6 +2237,65 @@
-fx-text-fill: -monet-on-primary;
}
.home-progress-title {
-fx-font-family: "Noto Sans SC";
-fx-font-size: 16px;
-fx-text-fill: -monet-on-surface;
}
.home-progress-bar {
-fx-pref-height: 8px;
-fx-min-height: 8px;
-fx-max-height: 8px;
}
.home-progress-bar > .track {
-fx-background-color: -monet-outline-variant;
-fx-background-radius: 8px;
-fx-background-insets: 0;
}
.home-progress-bar > .bar {
-fx-background-color: -monet-primary;
-fx-background-radius: 8px;
-fx-background-insets: 0;
-fx-padding: 0;
}
.home-progress-error {
-fx-font-size: 12px;
-fx-text-fill: -monet-error;
}
.home-progress-button {
-fx-font-family: "Noto Sans SC";
-fx-background-color: transparent;
-fx-border-color: -monet-primary;
-fx-border-radius: 13px;
-fx-background-radius: 13px;
-fx-text-fill: -monet-primary;
-fx-padding: 8px 21px;
-fx-cursor: hand;
}
.home-progress-button .label {
-fx-text-fill: -monet-primary;
}
.home-progress-step {
-fx-font-family: "Noto Sans SC";
-fx-font-size: 12px;
-fx-text-fill: -monet-on-surface-variant;
}
.home-progress-step:active {
-fx-text-fill: -monet-primary;
}
.home-progress-step:done {
-fx-text-fill: -monet-on-surface;
}
.home-art-line {
-fx-stroke: -monet-outline-variant;
-fx-stroke-width: 1.5px;
@@ -808,9 +808,34 @@ first_run.microsoft=Sign in with Microsoft
first_run.invalid=Use 1 to 16 letters, digits or underscores.
home.server=Server
home.server.unreachable=Unreachable
home.progress.step.check=Check updates
home.progress.step.java=Prepare Java
home.progress.step.sync=Sync modpack
home.progress.step.join=Join server
home.progress.checking=Checking for updates…
home.progress.syncing=Syncing modpack · %d%%
home.progress.failed=Sync failed
home.progress.retry=Retry
settings.group.game=Game
settings.group.help=Help
settings.java.summary=Java · managed automatically
settings.java.advanced=Advanced…
settings.help.about=About
about.based_on=Based on HMCL
about.check_update=Check for updates
about.check_update.latest=The modpack is up to date.
about.check_update.available=An update is available. Press "Update" on the home page.
about.check_update.not_installed=The modpack is not installed yet.
about.source_code=Source code
about.copy_diagnostics=Copy diagnostics
home.motd=Server announcement
home.restart.title=Restart game
home.restart.update=The game closed to apply an update. Update the client and start it again now?
home.restart.generic=The game asked to be restarted. Update the client and start it again now?
server.join=Join Server
server.update=Update
instance.launch_and_enter_world=Play World
instance.launch.empty=Start Game
instance.launch.empty=Join Server
instance.launch.empty.installing=Installing Game
instance.launch.empty.tooltip=Install and launch the bundled server modpack
instance.launch.test=Test Launch
@@ -1559,6 +1584,9 @@ settings.launcher.download.threads=Download Threads
settings.launcher.download.threads.auto=Automatically Determine
settings.launcher.download.threads.custom=Custom
settings.launcher.download_source=Download Source
settings.download_source.default=Automatic
settings.download_source.official=Official
settings.download_source.mirror=Mirror
settings.launcher.download_source.auto=Automatically Choose Download Sources
settings.launcher.default_addon_source=Default Addon Source
settings.launcher.misc=Misc
@@ -628,7 +628,12 @@ first_run.microsoft=登正版
first_run.invalid=一至十六字,限英文數字底綫。
home.server=服
home.server.unreachable=不通
home.motd=服公告
home.restart.title=重啟遊戲
home.restart.update=遊戲爲更新而止。今更新並重啟乎?
home.restart.generic=遊戲請重啟。今更新並重啟乎?
server.join=入服
server.update=更新
# instance.launch_and_enter_world=
instance.launch.empty=啟戲
instance.launch.empty.installing=裝戲
@@ -631,9 +631,34 @@ first_run.microsoft=登录正版账号
first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。
home.server=服务器
home.server.unreachable=连接不上
home.progress.step.check=检查更新
home.progress.step.java=准备 Java
home.progress.step.sync=同步整合包
home.progress.step.join=进入服务器
home.progress.checking=正在检查更新…
home.progress.syncing=正在同步整合包 · %d%%
home.progress.failed=同步失败
home.progress.retry=重试
settings.group.game=游戏
settings.group.help=帮助
settings.java.summary=Java · 自动管理
settings.java.advanced=高级…
settings.help.about=关于
about.based_on=基于 HMCL
about.check_update=检查更新
about.check_update.latest=整合包已是最新。
about.check_update.available=有可用更新,请在首页点击“更新”。
about.check_update.not_installed=尚未安装整合包。
about.source_code=源代码
about.copy_diagnostics=复制诊断信息
home.motd=服务器公告
home.restart.title=重启游戏
home.restart.update=游戏为应用更新已退出。现在更新客户端并重新启动游戏吗?
home.restart.generic=游戏请求重启。现在更新客户端并重新启动吗?
server.join=进入服务器
server.update=更新
instance.launch_and_enter_world=进入世界
instance.launch.empty=开始游戏
instance.launch.empty=进入服务器
instance.launch.empty.installing=安装游戏
instance.launch.empty.tooltip=安装并启动服务器整合包
instance.launch.test=测试游戏
@@ -1376,6 +1401,9 @@ settings.launcher.download.threads=下载线程数
settings.launcher.download.threads.auto=自动选择线程数
settings.launcher.download.threads.custom=自定义线程数
settings.launcher.download_source=下载源
settings.download_source.default=自动
settings.download_source.official=官方源
settings.download_source.mirror=镜像源
settings.launcher.download_source.auto=自动选择下载源
settings.launcher.default_addon_source=附加内容默认下载源
settings.launcher.misc=杂项
@@ -625,9 +625,34 @@ first_run.microsoft=登入正版帳號
first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。
home.server=伺服器
home.server.unreachable=無法連線
home.progress.step.check=檢查更新
home.progress.step.java=準備 Java
home.progress.step.sync=同步整合包
home.progress.step.join=進入伺服器
home.progress.checking=正在檢查更新…
home.progress.syncing=正在同步整合包 · %d%%
home.progress.failed=同步失敗
home.progress.retry=重試
settings.group.game=遊戲
settings.group.help=說明
settings.java.summary=Java · 自動管理
settings.java.advanced=進階…
settings.help.about=關於
about.based_on=基於 HMCL
about.check_update=檢查更新
about.check_update.latest=整合包已是最新。
about.check_update.available=有可用更新,請在首頁點擊「更新」。
about.check_update.not_installed=尚未安裝整合包。
about.source_code=原始碼
about.copy_diagnostics=複製診斷資訊
home.motd=伺服器公告
home.restart.title=重啟遊戲
home.restart.update=遊戲為套用更新已結束。現在更新客戶端並重新啟動遊戲嗎?
home.restart.generic=遊戲要求重啟。現在更新客戶端並重新啟動嗎?
server.join=進入伺服器
server.update=更新
instance.launch_and_enter_world=進入世界
instance.launch.empty=開始遊戲
instance.launch.empty=進入伺服器
instance.launch.empty.installing=安裝遊戲
instance.launch.empty.tooltip=安裝並啟動伺服器整合包
instance.launch.test=測試遊戲
@@ -1370,6 +1395,9 @@ settings.launcher.download.threads=下載執行緒數
settings.launcher.download.threads.auto=自動選取執行緒數
settings.launcher.download.threads.custom=自訂執行緒數
settings.launcher.download_source=下載來源
settings.download_source.default=自動
settings.download_source.official=官方來源
settings.download_source.mirror=鏡像來源
settings.launcher.download_source.auto=自動選取下載來源
settings.launcher.default_addon_source=附加內容預設下載來源
settings.launcher.misc=雜項
@@ -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();
}
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/// Tests [RestartRequest].
public final class RestartRequestTest {
@Test
void noFileMeansNoRequest(@TempDir Path dir) {
assertNull(RestartRequest.consume(dir));
}
@Test
void readsReasonAndDeletesFile(@TempDir Path dir) throws Exception {
Path file = dir.resolve(RestartRequest.FILE_NAME);
Files.writeString(file, "{\"reason\":\"update\"}");
assertEquals("update", RestartRequest.consume(dir));
assertFalse(Files.exists(file));
assertNull(RestartRequest.consume(dir));
}
@Test
void malformedFileStillCounts(@TempDir Path dir) throws Exception {
Path file = dir.resolve(RestartRequest.FILE_NAME);
Files.writeString(file, "not json");
assertEquals("unknown", RestartRequest.consume(dir));
assertFalse(Files.exists(file));
}
}
@@ -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));
}
}
}
@@ -25,6 +25,7 @@ import java.nio.file.attribute.BasicFileAttributes;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -37,6 +38,8 @@ public final class MojangJavaDownloadTask extends Task<MojangJavaDownloadTask.Re
private final Path tempDir;
private final Task<MojangJavaRemoteFiles> javaDownloadsTask;
private final List<Task<?>> dependencies = new ArrayList<>();
private final AtomicInteger completedFiles = new AtomicInteger();
private volatile int totalFiles;
private volatile MojangJavaDownloads.JavaDownload download;
@@ -147,6 +150,21 @@ public final class MojangJavaDownloadTask extends Task<MojangJavaDownloadTask.Re
Files.createSymbolicLink(dest, Paths.get(link.getTarget()));
}
}
// Report per-file progress so the bar advances through the download, LZMA decompress and
// cache-copy phases; otherwise it stays indeterminate while files appear on disk and players
// assume the install is frozen. The callbacks run after each file completes and never touch
// the download/decompress logic above.
totalFiles = dependencies.size();
if (totalFiles > 0) {
List<Task<?>> tracked = new ArrayList<>(totalFiles);
for (Task<?> dependency : dependencies) {
tracked.add(dependency.thenRunAsync(() -> updateProgress(completedFiles.incrementAndGet(), totalFiles)));
}
dependencies.clear();
dependencies.addAll(tracked);
updateProgress(0, totalFiles);
}
}
@Override
@@ -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;
@@ -799,6 +799,10 @@ public class DefaultLauncher extends Launcher {
env.put("__NV_PRIME_RENDER_OFFLOAD", "1");
env.put("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
env.put("__VK_LAYER_NV_optimus", "NVIDIA_only");
} else if (HardwareVendor.AMD.equals(discreteGraphicsCard.getVendor())
|| HardwareVendor.NVIDIA.equals(discreteGraphicsCard.getVendor())) {
// Mesa PRIME offload (open source AMD / nouveau drivers)
env.put("DRI_PRIME", "1");
}
}
}
@@ -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);
}
}
@@ -213,6 +213,6 @@ public class ServerModpackCompletionTask extends Task<Void> {
public void postExecute() throws Exception {
if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return;
Files.createDirectories(configurationFile.getParent());
JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), this.manifest.getVersion(), remoteManifest.getFiles()));
JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), remoteManifest.getVersion(), remoteManifest.getFiles()));
}
}
@@ -32,6 +32,7 @@ public class ServerModpackLocalInstallTask extends Task<Void> {
/// Existing instance selecting update mode, or `null` for a new installation.
private final @Nullable DefaultGameInstance updateTarget;
private final DefaultDependencyManager dependencyManager;
private final DefaultGameRepository repository;
private final List<Task<?>> dependencies = new ArrayList<>();
private final List<Task<?>> dependents = new ArrayList<>(4);
@@ -97,6 +98,7 @@ public class ServerModpackLocalInstallTask extends Task<Void> {
this.manifest = manifest;
this.instanceId = instanceId;
this.updateTarget = updateTarget;
this.dependencyManager = dependencyManager;
this.repository = dependencyManager.getGameRepository();
Path run = repository.getLayout().getInstanceRoot(instanceId);
@@ -148,5 +150,9 @@ public class ServerModpackLocalInstallTask extends Task<Void> {
@Override
public void execute() throws Exception {
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
if (Lwjgl3ifySupport.isPresent(instanceRoot)) {
dependencies.add(new Lwjgl3ifyApplyTask(dependencyManager, instanceId, updateTarget));
}
}
}
@@ -6,6 +6,7 @@ import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.game.DefaultGameInstance;
import org.jackhuang.hmcl.game.LaunchOptions;
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackProvider;
@@ -29,7 +30,8 @@ public final class ServerModpackProvider implements ModpackProvider {
@Override
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
return new ServerModpackCompletionTask(dependencyManager, instance);
return new ServerModpackCompletionTask(dependencyManager, instance)
.thenComposeAsync(ignored -> Lwjgl3ifyApplyTask.afterCompletion(dependencyManager, instance));
}
@Override
@@ -51,4 +53,12 @@ public final class ServerModpackProvider implements ModpackProvider {
ServerModpackManifest manifest = JsonUtils.fromNonNullJson(json, ServerModpackManifest.class);
return manifest.toModpack();
}
@Override
public void injectLaunchOptions(String modpackConfigurationJson, LaunchOptions.Builder builder) {
Path gameDir = builder.getGameDir();
if (gameDir != null) {
Lwjgl3ifySupport.injectLaunchOptions(gameDir, builder);
}
}
}
@@ -126,10 +126,12 @@ public class ServerModpackRemoteInstallTask extends Task<Void> {
@Override
public void execute() throws Exception {
DefaultGameInstance instance = repository.getInstance(instanceId);
dependencies.add(new ServerModpackCompletionTask(
dependency,
repository.getInstance(instanceId),
new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList())));
instance,
new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))
.thenComposeAsync(ignored -> Lwjgl3ifyApplyTask.afterCompletion(dependency, instance)));
}
public static final String MODPACK_TYPE = "Server";
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* Filters JVM arguments coming from untrusted modpack / MultiMC patch input.
*
* <p>Denies agent injection and {@code -XX:On*Error} command hooks while allowing ordinary
* memory / GC / system-property args used by companions such as lwjgl3ify.
*/
public final class JvmArgumentFilter {
private JvmArgumentFilter() {
}
/// Returns whether a single JVM argument is safe to accept from modpack / patch metadata.
public static boolean isAllowed(@Nullable String arg) {
if (arg == null) {
return false;
}
String trimmed = arg.trim();
if (trimmed.isEmpty()) {
return false;
}
String lower = trimmed.toLowerCase(Locale.ROOT);
if (lower.startsWith("-javaagent")
|| lower.startsWith("-agentpath")
|| lower.startsWith("-agentlib")
|| lower.startsWith("-xbootclasspath")
|| lower.startsWith("-xx:onoutofmemoryerror")
|| lower.startsWith("-xx:onerror")
|| lower.startsWith("-xx:exitonoutofmemoryerror")) {
return false;
}
return true;
}
/// Copies {@code args}, dropping disallowed entries and logging each skip.
public static List<String> filter(Iterable<String> args) {
List<String> out = new ArrayList<>();
for (String arg : args) {
if (isAllowed(arg)) {
out.add(arg);
} else {
LOG.warning("Ignoring disallowed JVM argument from modpack/patch: " + arg);
}
}
return out;
}
}
@@ -2,10 +2,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.util;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
@@ -28,7 +30,40 @@ public final class ServerStatusPing {
/// @param online the number of players online
/// @param max the player limit
/// @param latencyMillis the round-trip time of the ping packet in milliseconds
public record Status(int online, int max, long latencyMillis) {
/// @param motd the server's message of the day as plain text without colour codes; empty if absent
public record Status(int online, int max, long latencyMillis, String motd) {
/// Creates a status without a message of the day.
public Status(int online, int max, long latencyMillis) {
this(online, max, latencyMillis, "");
}
}
/// Flattens the `description` field of a status response (a plain string or a chat component with
/// `text` and `extra`) into plain text, dropping `§x` colour and format codes.
///
/// @param description the `description` element, may be `null`
/// @return the plain text, trimmed; empty when there is nothing to show
public static String motdToPlainText(@Nullable JsonElement description) {
StringBuilder builder = new StringBuilder();
appendComponent(description, builder);
return builder.toString().replaceAll("\u00a7.", "").strip();
}
private static void appendComponent(@Nullable JsonElement element, StringBuilder out) {
if (element == null || element.isJsonNull()) {
return;
}
if (element.isJsonPrimitive()) {
out.append(element.getAsString());
} else if (element.isJsonArray()) {
for (JsonElement item : element.getAsJsonArray()) {
appendComponent(item, out);
}
} else if (element.isJsonObject()) {
JsonObject object = element.getAsJsonObject();
appendComponent(object.get("text"), out);
appendComponent(object.get("extra"), out);
}
}
/// The protocol number sent in the handshake; Minecraft 1.7.10 uses 5 and servers answer any value.
@@ -73,9 +108,11 @@ public final class ServerStatusPing {
in.readFully(json);
int online;
int max;
String motd;
try {
JsonObject players = JsonUtils.GSON.fromJson(new String(json, StandardCharsets.UTF_8), JsonObject.class)
.getAsJsonObject("players");
JsonObject response = JsonUtils.GSON.fromJson(new String(json, StandardCharsets.UTF_8), JsonObject.class);
motd = motdToPlainText(response.get("description"));
JsonObject players = response.getAsJsonObject("players");
online = players.get("online").getAsInt();
max = players.get("max").getAsInt();
} catch (JsonParseException | NullPointerException | ClassCastException | IllegalStateException e) {
@@ -94,7 +131,7 @@ public final class ServerStatusPing {
throw new IOException("Unexpected pong packet");
}
in.readLong();
return new Status(online, max, (System.nanoTime() - start) / 1_000_000);
return new Status(online, max, (System.nanoTime() - start) / 1_000_000, motd);
}
}
@@ -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")));
}
}
@@ -25,6 +25,7 @@ public final class ServerStatusPingTest {
var status = ServerStatusPing.ping("127.0.0.1", server.getLocalPort(), 3000);
assertEquals(12, status.online());
assertEquals(50, status.max());
assertEquals("TDLM 公告: 今晚维护", status.motd());
assertTrue(status.latencyMillis() >= 0);
}
}
@@ -45,7 +46,7 @@ public final class ServerStatusPingTest {
skipPacket(in); // handshake
skipPacket(in); // status request
byte[] json = "{\"players\":{\"online\":12,\"max\":50}}".getBytes(StandardCharsets.UTF_8);
byte[] json = "{\"description\":{\"text\":\"§aTDLM \",\"extra\":[{\"text\":\"公告: 今晚维护\"}]},\"players\":{\"online\":12,\"max\":50}}".getBytes(StandardCharsets.UTF_8);
byte[] body = concat(new byte[]{0x00}, varInt(json.length), json);
out.write(varInt(body.length));
out.write(body);
@@ -88,4 +89,11 @@ public final class ServerStatusPingTest {
for (byte[] part : parts) out.writeBytes(part);
return out.toByteArray();
}
@Test
void flattensPlainStringMotd() {
var json = com.google.gson.JsonParser.parseString("\"§l欢迎§r来玩\"");
assertEquals("欢迎来玩", ServerStatusPing.motdToPlainText(json));
assertEquals("", ServerStatusPing.motdToPlainText(null));
}
}
@@ -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));
}
}
+78
View File
@@ -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 `[email protected]:/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.