Compare commits

...
Author SHA1 Message Date
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <layue13@icloud.com>
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 <layue13@icloud.com>
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 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
15 changed files with 616 additions and 126 deletions

No files matched your search

+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,9 +141,9 @@ public final class Metadata {
return null;
}
/// Default client Server Modpack fileApi published by TDLMPack (Gitea generic package `latest`).
/// Default client Server Modpack fileApi: self-hosted static mirror on the TDLM VPS (nginx, port 19527).
public static final String DEFAULT_CLIENT_FILE_API =
"https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-client/latest";
"http://159.75.5.225:19527/tdlm-client";
/// Stable client update root (`server-manifest.json` and `overrides/` live under this URL).
///
@@ -165,11 +165,14 @@ public final class Metadata {
return getClientFileApi() + "/server-manifest.json";
}
/// Fallback join address when the installed modpack manifest has no `serverAddress` / routes.
public static final String DEFAULT_SERVER_ADDRESS = "mc.potatolab.uk";
/// Server address used when the installed modpack manifest does not provide one.
///
/// Can be set at build or run time with the `hmcl.server.address` system property; empty by default.
/// Override at build or run time with `-Dhmcl.server.address=…`; defaults to [#DEFAULT_SERVER_ADDRESS].
public static String getDefaultServerAddress() {
return System.getProperty("hmcl.server.address", "").trim();
return System.getProperty("hmcl.server.address", DEFAULT_SERVER_ADDRESS).trim();
}
/// The server name shown in the title bar.
@@ -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
@@ -27,16 +27,11 @@ import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.ui.account.AccountListPage;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.construct.*;
@@ -359,8 +354,8 @@ public final class Controllers {
);
}
/// Installs a launcher-bundled modpack when present; otherwise, when the repository has no
/// instances yet, pulls the org client [Metadata#getClientManifestUrl] and opens the install wizard.
/// 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) {
@@ -384,35 +379,7 @@ public final class Controllers {
}
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
);
return;
}
if (repository.getDisplayInstances().findAny().isPresent()) {
return;
}
String manifestUrl = Metadata.getClientManifestUrl();
LOG.info("No instances yet; fetching client Server Modpack from " + manifestUrl);
Controllers.taskDialog(
new GetTask(WebURL.parse(manifestUrl))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
LOG.warning("Failed to fetch client Server Modpack manifest", exception);
return;
}
ServerModpackManifest manifest =
JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
if (manifest == null) {
LOG.warning("Malformed client Server Modpack manifest from " + manifestUrl);
return;
}
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
}),
i18n("message.downloading"),
TaskCancellationAction.NO_CANCEL
);
}
/// Shows the content in the main-window dialog stack without waiting for it to close.
@@ -14,6 +14,7 @@ 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;
@@ -25,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;
@@ -48,63 +54,198 @@ public final class Instances {
private Instances() {
}
/// Installs or updates the TDLM client.
/// Returns whether the installed server modpack is behind the remote `server-manifest.json`.
///
/// Prefers a launcher-bundled zip, then the org [Metadata#getClientManifestUrl] Server Modpack,
/// then the generic local/remote import wizard.
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() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (!repository.isLoaded()) {
return;
}
// Already installed: join immediately. Launch runs ServerModpackCompletionTask for updates.
if (repository.getSelectedInstance() != null && Metadata.findBundledModpackFile() == null) {
joinServer(repository.getSelectedInstance());
return;
}
Path bundled = Metadata.findBundledModpackFile();
if (bundled != null) {
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, bundled),
i18n("install.modpack"));
installBundledThenJoin(repository, bundled);
return;
}
Controllers.taskDialog(
new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
Controllers.dialog(
exception.getMessage(),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR);
importModpack();
return;
}
.thenComposeAsync(Schedulers.javafx(), result -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
result, ServerModpackManifest.class);
if (manifest == null) {
throw new IOException(i18n("modpack.type.server.malformed"));
}
return ensureRemoteClientInstalled(repository, manifest);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install or update TDLM client", exception);
Controllers.dialog(
i18n("modpack.type.server.malformed"),
i18n("message.error"),
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
importModpack();
return;
}
Controllers.getDecorator().startWizard(
new ModpackInstallWizardProvider(repository, manifest),
i18n("install.modpack"));
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
}),
i18n("message.downloading"),
i18n("modpack.installing"),
TaskCancellationAction.NORMAL);
}
public static void importModpack() {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (repository.isLoaded()) {
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository), i18n("install.modpack"));
}
/// Silent first-install / update from the org client manifest — no wizard UI.
public static void installServerModpack() {
ensureClientAndJoin();
}
/// @deprecated use [#installServerModpack()]
/// 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) {
Controllers.taskDialog(
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);
Controllers.dialog(
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
return;
}
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
}),
i18n("modpack.installing"),
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) {
@@ -257,8 +398,10 @@ public final class Instances {
.addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false)));
}
/// 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.
@@ -338,14 +481,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;
}
@@ -412,11 +548,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");
}
}
@@ -58,6 +58,9 @@ 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;
{
HBox titleNode = new HBox(8);
titleNode.setPadding(new Insets(0, 0, 0, 2));
@@ -135,18 +138,20 @@ public final class MainPage extends StackPane implements DecoratorPage {
card.setMaxHeight(Region.USE_PREF_SIZE);
StackPane.setAlignment(card, 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);
@@ -181,6 +186,25 @@ public final class MainPage extends StackPane implements DecoratorPage {
getChildren().addAll(art, hero, card);
}
/// 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.
@@ -214,14 +238,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();
}
@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);
}
@@ -809,6 +809,7 @@ first_run.invalid=Use 1 to 16 letters, digits or underscores.
home.server=Server
home.server.unreachable=Unreachable
server.join=Join Server
server.update=Update
instance.launch_and_enter_world=Play World
instance.launch.empty=Start Game
instance.launch.empty.installing=Installing Game
@@ -629,6 +629,7 @@ first_run.invalid=一至十六字,限英文數字底綫。
home.server=服
home.server.unreachable=不通
server.join=入服
server.update=更新
# instance.launch_and_enter_world=
instance.launch.empty=啟戲
instance.launch.empty.installing=裝戲
@@ -632,6 +632,7 @@ first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。
home.server=服务器
home.server.unreachable=连接不上
server.join=进入服务器
server.update=更新
instance.launch_and_enter_world=进入世界
instance.launch.empty=开始游戏
instance.launch.empty.installing=安装游戏
@@ -626,6 +626,7 @@ first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。
home.server=伺服器
home.server.unreachable=無法連線
server.join=進入伺服器
server.update=更新
instance.launch_and_enter_world=進入世界
instance.launch.empty=開始遊戲
instance.launch.empty.installing=安裝遊戲
@@ -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());
@@ -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
@@ -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()));
}
}
+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 `ubuntu@159.75.5.225:/var/www/tdlm-dl/tdlm-client`.
Auth is the repo secret `TDLM_VPS_SSH_KEY`, an ed25519 deploy key confined on the VPS by
`command="/usr/bin/rrsync /var/www/tdlm-dl/tdlm-client"` (rsync-only, no shell, no forwarding).
The public port 19527 serves GET/HEAD only — there is no public write surface.