Compare commits

..
Author SHA1 Message Date
ZkiteflyandGlavo dda30274a4 优化模组管理布局 (#3229)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-10-03 21:20:52 +08:00
Glavo 2b43ee24a1 Fix Debug_zh.md (#4593) 2025-10-03 21:02:49 +08:00
3gf8jv4dv 66f1e60433 fix(i18n): correct the language name in literary chinese (#4592)
Fix #4591
2025-10-03 20:02:14 +08:00
Burning_TNTand3gf8jv4dv 3c3e2b8cfc Terracotta | 陶瓦联机 (#4215)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-10-03 20:01:08 +08:00
Burning_TNTandGlavo 365bb089c5 Fix #4572: Use String::lines to handle line separators on various OS (#4575)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-10-03 19:59:43 +08:00
Glavo 87af44328b 添加“提前预览 HMCL 版本”选项 (#4223) 2025-10-03 19:56:59 +08:00
Glavo 8731faaac7 更新 Jenkins CI 链接 (#4584) 2025-10-02 21:57:44 +08:00
Glavo 5abc1f1f9d 修复“不自动添加 Java 虚拟机优化参数”选项未保存至配置文件的问题 (#4583) 2025-10-02 20:47:34 +08:00
Glavo 40b4ecd42a 从 synchronized 切换至 ReentrantLock (#4564) 2025-10-02 15:50:56 +08:00
Glavo e0f5c4d91e 优化 EventBus (#4565) 2025-10-02 15:38:03 +08:00
辞庐 35ecd58460 Fix #4556 : 修复在短时间内连续点击 OptionToggleButton 只会更改一次状态的问题 (#4568) 2025-10-02 15:31:20 +08:00
辞庐 f29c2bc202 Fix 4555: 修复外观 - 不透明度滑块 刻度线在不同滑块位置时排布不统一的问题 (#4571) 2025-10-02 15:25:52 +08:00
Glavo 361e928e73 支持从 WorkflowMultiBranchProject 中检查最新版本 (#4576) 2025-10-02 15:22:15 +08:00
Glavo 484b65b513 优化对颠倒的英语的支持 (#4577) 2025-10-02 15:21:03 +08:00
3gf8jv4dv 19f02e3967 chore: update versions.txt to 1.21.9 (#4566) 2025-10-01 13:08:43 +08:00
Glavo 495da6b6b0 更新 Jenkins CI 服务器的发布模型 (#4559) 2025-10-01 13:04:19 +08:00
Glavo 168c4ac708 支持通过环境变量控制 UI 缩放比例 (#4521) 2025-09-29 16:51:57 +08:00
Glavo 7e8a6679cc 将 EventManager 中的 synchronized 替换为 ReentrantLock (#4562) 2025-09-29 15:49:18 +08:00
Glavo e90d5185f6 增强 ArchiveFileTree (#4547) 2025-09-29 15:19:37 +08:00
Glavo 8a89baee09 使用 Gradle 获取更新信息 (#4560) 2025-09-28 20:29:07 +08:00
辞庐 7f4c5fc4a0 清理代码 (#4519) 2025-09-28 00:01:00 +08:00
Glavo 75789c774d 修复切换下载源后需要重启启动器才会生效的问题 (#4296) 2025-09-27 16:26:38 +08:00
Glavo f0a16b3d14 GitHub Action 按文件类型分别上传构建产物 (#4537) 2025-09-27 15:56:28 +08:00
Glavo 710e69751f 重构 FetchTask 以使用 HttpClient 和虚拟线程 (#4546) 2025-09-26 20:54:22 +08:00
Glavo 938d8647ce 删除 InvocationDispatcher 中的 synchronized 块 (#4550) 2025-09-26 20:44:34 +08:00
Glavo 4b4dd592c3 将 Schedulers.io() 切换至虚拟线程 (#4548) 2025-09-26 20:12:01 +08:00
Glavo 225dba7448 Bump JNA to 5.18.0 (#4549) 2025-09-26 17:06:01 +08:00
Glavo 5b1414428e 添加 Debug_zh.md (#4542) 2025-09-24 15:22:50 +08:00
Glavo cb467ae240 优化本地化支持 (#4539)
- 简化代码
- 修复错误用词
- 优化语言名称显示
2025-09-23 15:47:46 +08:00
rootwhois 9416c0cfb3 修复日志页面选中条目时 hover 背景闪烁两次的问题 (#4517)
fix #4245
2025-09-23 11:46:18 +08:00
3gf8jv4dv 16ea98c95a chore(l10n): revise chinese (traditional) and english (#4530) 2025-09-23 11:30:54 +08:00
3gf8jv4dv a8552a92d1 chore(l10n): add new translations to spanish (#4531) 2025-09-23 11:30:03 +08:00
Glavo 36d71bd14e 支持颠倒的英语 (#4527) 2025-09-22 22:07:06 +08:00
3gf8jv4dv cbe6554390 feat(l10n): add boot_es.properties (#4532) 2025-09-22 21:48:09 +08:00
Glavo 1b65e0b6d7 Fix #4526: 修复将空字符串注册为 Access Token 后日志输出异常的问题 (#4528) 2025-09-22 16:07:16 +08:00
Glavo bd9ae189f8 优化本地化支持 (#4525) 2025-09-21 15:15:22 +08:00
Glavo 867a04dfba 支持在“实例管理”按钮上通过滚动滚轮切换游戏实例 (#4509) 2025-09-20 20:15:11 +08:00
Wulian233 dd7703c7a9 修复删除世界时无法删除 zip 格式世界的问题 (#4500) 2025-09-20 17:14:19 +08:00
Glavo 756b95ca8c 添加本地化说明文档 (#4516) 2025-09-20 16:41:58 +08:00
rootwhoisandGlavo ac0a0c7604 修复 AdvancedListBox 在非必要的情况下显示滚动条的问题 (#4514)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-09-20 16:28:59 +08:00
Glavo 9ee611c0f6 Bump Gradle to 9.1.0 (#4520) 2025-09-20 15:55:20 +08:00
129 changed files with 6321 additions and 1179 deletions

No files matched your search

+1
View File
@@ -1071,6 +1071,7 @@ ij_jsp_keep_indents_on_empty_lines = false
ij_jspx_keep_indents_on_empty_lines = false
[{*.markdown,*.md}]
max_line_length = 200
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true
+1 -1
View File
@@ -22,4 +22,4 @@ jobs:
java-version: '17'
java-package: 'jdk+fx'
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
+33 -55
View File
@@ -2,8 +2,8 @@ name: Check Update
on:
workflow_dispatch:
schedule:
- cron: '30 * * * *'
# schedule:
# - cron: '30 * * * *'
permissions:
contents: write
@@ -13,39 +13,27 @@ jobs:
if: ${{ github.repository_owner == 'HMCL-dev' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: |
wget -O ci.json https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/api/json
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
echo "HMCL_TAG_NAME=v$HMCL_VERSION" >> $GITHUB_ENV
run: ./gradlew checkUpdateDev --no-daemon --info --stacktrace
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
env:
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/artifact/HMCL/build/libs
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
@@ -78,39 +66,28 @@ jobs:
needs: dev-check-update
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: |
wget -O ci.json https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/api/json
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
echo "HMCL_TAG_NAME=release-$HMCL_VERSION" >> $GITHUB_ENV
run: ./gradlew checkUpdateStable --no-daemon --info --stacktrace
- name: Check for existing tags
run: if ! git tag -l | grep -q "$HMCL_TAG_NAME"; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
env:
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/artifact/HMCL/build/libs
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
@@ -122,9 +99,9 @@ jobs:
echo "" >> RELEASE_NOTE
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Create release
@@ -139,3 +116,4 @@ jobs:
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
prerelease: true
+19 -3
View File
@@ -27,8 +27,24 @@ jobs:
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload Artifacts
- name: Upload JAR
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}
path: HMCL/build/libs
name: HMCL-${{ env.SHORT_SHA }}-jar
path: |
HMCL/build/libs/HMCL-*.jar
HMCL/build/libs/HMCL-*.jar.sha256
- name: Upload EXE
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}-exe
path: |
HMCL/build/libs/HMCL-*.exe
HMCL/build/libs/HMCL-*.exe.sha256
- name: Upload SH
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: |
HMCL/build/libs/HMCL-*.sh
HMCL/build/libs/HMCL-*.sh.sha256
+1
View File
@@ -2,6 +2,7 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
*.hprof
.gradle
+80 -35
View File
@@ -1,5 +1,11 @@
import org.jackhuang.hmcl.gradle.CheckTranslations
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
import java.net.URI
import java.nio.file.FileSystems
import java.nio.file.Files
@@ -13,30 +19,36 @@ plugins {
alias(libs.plugins.shadow)
}
val isOfficial = System.getenv("HMCL_SIGNATURE_KEY") != null
|| (System.getenv("GITHUB_REPOSITORY_OWNER") == "HMCL-dev" && System.getenv("GITHUB_BASE_REF")
.isNullOrEmpty())
val projectConfig = PropertiesUtils.load(rootProject.file("config/project.properties").toPath())
val isOfficial = JenkinsUtils.IS_ON_CI || GitHubActionUtils.IS_ON_OFFICIAL_REPO
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt().let { number ->
val offset = System.getenv("BUILD_NUMBER_OFFSET")?.toInt() ?: 0
if (number != null) {
(number - offset).toString()
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
val prefix = if (isOfficial) "dev" else "unofficial"
if (!shortCommit.isNullOrEmpty()) "$prefix-$shortCommit" else "SNAPSHOT"
}
}
val versionRoot = System.getenv("VERSION_ROOT") ?: "3.6"
val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" else "unofficial"
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE")
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
version = "$versionRoot.$buildNumber"
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt()
if (buildNumber != null) {
version = if (JenkinsUtils.IS_ON_CI && versionType == "dev") {
"$versionRoot.0.$buildNumber"
} else {
"$versionRoot.$buildNumber"
}
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
version = if (shortCommit.isNullOrBlank()) {
"$versionRoot.SNAPSHOT"
} else if (isOfficial) {
"$versionRoot.dev-$shortCommit"
} else {
"$versionRoot.unofficial-$shortCommit"
}
}
val embedResources by configurations.registering
@@ -47,7 +59,7 @@ dependencies {
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
if (launcherExe == null) {
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
}
@@ -109,8 +121,25 @@ tasks.compileJava {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
}
val addOpens = listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
"java.base/jdk.internal.loader",
"javafx.base/com.sun.javafx.binding",
"javafx.base/com.sun.javafx.event",
"javafx.base/com.sun.javafx.runtime",
"javafx.graphics/javafx.css",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
val hmclProperties = buildList {
add("hmcl.version" to project.version.toString())
add("hmcl.add-opens" to addOpens.joinToString(" "))
System.getenv("GITHUB_SHA")?.let {
add("hmcl.version.hash" to it)
}
@@ -137,22 +166,6 @@ val createPropertiesFile by tasks.registering {
}
}
val addOpens = listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
"java.base/jdk.internal.loader",
"javafx.base/com.sun.javafx.binding",
"javafx.base/com.sun.javafx.event",
"javafx.base/com.sun.javafx.runtime",
"javafx.graphics/javafx.css",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
tasks.jar {
enabled = false
dependsOn(tasks["shadowJar"])
@@ -192,7 +205,7 @@ tasks.shadowJar {
"Enable-Native-Access" to "ALL-UNNAMED"
)
if (launcherExe != null) {
if (launcherExe.isNotBlank()) {
into("assets") {
from(file(launcherExe))
}
@@ -206,11 +219,20 @@ tasks.shadowJar {
tasks.processResources {
dependsOn(createPropertiesFile)
dependsOn(upsideDownTranslate)
dependsOn(createLocaleNamesResourceBundle)
dependsOn(createLanguageList)
into("assets/") {
from(hmclPropertiesFile)
from(embedResources)
}
into("assets/lang") {
from(createLanguageList.map { it.outputFile })
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
}
val makeExecutables by tasks.registering {
@@ -344,6 +366,29 @@ tasks.register<CheckTranslations>("checkTranslations") {
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
}
// l10n
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
}
val createLanguageList by tasks.registering(CreateLanguageList::class) {
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
resourceBundleBaseName.set("I18N")
additionalLanguages.set(listOf("en-Qabs"))
outputFile.set(generatedDir.map { it.file("languages.json") })
}
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
// mcmod data
tasks.register<ParseModDataTask>("parseModData") {
@@ -46,21 +46,7 @@ public final class EntryPoint {
createHMCLDirectories();
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU")))
System.getProperties().putIfAbsent("prism.forceGPU", "true");
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
setupJavaFXVMOptions();
checkDirectoryPath();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
@@ -80,6 +66,71 @@ public final class EntryPoint {
System.exit(exitCode);
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void createHMCLDirectories() {
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
try {
@@ -80,8 +80,6 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
Locale locale = Locale.getDefault();
if (LocaleUtils.isEnglish(locale))
return;
/*
* 1.0 : No language option, do not set for these versions
@@ -112,7 +110,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) {
String region = locale.getCountry();
return switch (LocaleUtils.getISO1Language(locale)) {
return switch (LocaleUtils.getRootLanguage(locale)) {
case "es" -> "es_ES";
case "ja" -> "ja_JP";
case "ru" -> "ru_RU";
@@ -129,6 +127,13 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
yield "zh_CN";
}
case "en" -> {
if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) {
yield "en_UD";
}
yield "";
}
default -> "";
};
}
@@ -77,7 +77,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
return manifest;
}
private static class HMCLModpack extends Modpack {
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) {
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
@@ -58,6 +58,7 @@ import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import java.lang.ref.WeakReference;
@@ -706,6 +707,7 @@ public final class LauncherHelper {
*/
private final class HMCLProcessListener implements ProcessListener {
private final ReentrantLock lock = new ReentrantLock();
private final HMCLGameRepository repository;
private final Version version;
private final LaunchOptions launchOptions;
@@ -849,21 +851,27 @@ public final class LauncherHelper {
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
logBuffer.add(new Log(log, level));
} else {
synchronized (this) {
lock.lock();
try {
logs.addLast(new Log(log, level));
if (logs.size() > Log.getLogLines())
logs.removeFirst();
} finally {
lock.unlock();
}
}
if (!lwjgl) {
String lowerCaseLog = log.toLowerCase(Locale.ROOT);
if (!detectWindow || lowerCaseLog.contains("lwjgl version") || lowerCaseLog.contains("lwjgl openal")) {
synchronized (this) {
lock.lock();
try {
if (!lwjgl) {
lwjgl = true;
finishLaunch();
}
} finally {
lock.unlock();
}
}
}
@@ -888,9 +896,12 @@ public final class LauncherHelper {
// Game crashed before opening the game window.
if (!lwjgl) {
synchronized (this) {
lock.lock();
try {
if (!lwjgl)
finishLaunch();
} finally {
lock.unlock();
}
}
@@ -904,7 +915,15 @@ public final class LauncherHelper {
}
public static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
public static int countMangedProcesses() {
PROCESSES.removeIf(it -> {
ManagedProcess process = it.get();
return process == null || !process.isRunning();
});
return PROCESSES.size();
}
public static void stopManagedProcesses() {
while (!PROCESSES.isEmpty())
@@ -37,8 +37,7 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.javafx.DirtyTracker;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jetbrains.annotations.Nullable;
@@ -222,7 +221,7 @@ public final class Config implements Observable {
}
@SerializedName("localization")
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(Locales.DEFAULT);
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
public ObjectProperty<SupportedLocale> localizationProperty() {
return localization;
@@ -251,6 +250,21 @@ public final class Config implements Observable {
this.promptedVersion.set(promptedVersion);
}
@SerializedName("acceptPreviewUpdate")
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
public BooleanProperty acceptPreviewUpdateProperty() {
return acceptPreviewUpdate;
}
public boolean isAcceptPreviewUpdate() {
return acceptPreviewUpdate.get();
}
public void setAcceptPreviewUpdate(boolean acceptPreviewUpdate) {
this.acceptPreviewUpdate.set(acceptPreviewUpdate);
}
@SerializedName("shownTips")
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
@@ -31,23 +31,21 @@ import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class DownloadProviders {
private DownloadProviders() {}
private DownloadProviders() {
}
private static DownloadProvider currentDownloadProvider;
private static final DownloadProviderWrapper provider;
public static final Map<String, DownloadProvider> providersById;
public static final Map<String, DownloadProvider> rawProviders;
@@ -59,6 +57,7 @@ public final class DownloadProviders {
public static final String DEFAULT_PROVIDER_ID = "balanced";
public static final String DEFAULT_RAW_PROVIDER_ID = "bmclapi";
@SuppressWarnings("unused")
private static final InvalidationListener observer;
static {
@@ -68,71 +67,68 @@ public final class DownloadProviders {
MOJANG = new MojangDownloadProvider();
BMCLAPI = new BMCLAPIDownloadProvider(bmclapiRoot);
rawProviders = mapOf(
pair("mojang", MOJANG),
pair("bmclapi", BMCLAPI)
rawProviders = Map.of(
"mojang", MOJANG,
"bmclapi", BMCLAPI
);
AdaptedDownloadProvider fileProvider = new AdaptedDownloadProvider();
fileProvider.setDownloadProviderCandidates(Arrays.asList(BMCLAPI, MOJANG));
fileProvider.setDownloadProviderCandidates(List.of(BMCLAPI, MOJANG));
BalancedDownloadProvider balanced = new BalancedDownloadProvider(MOJANG, BMCLAPI);
providersById = mapOf(
pair("official", new AutoDownloadProvider(MOJANG, fileProvider)),
pair("balanced", new AutoDownloadProvider(balanced, fileProvider)),
pair("mirror", new AutoDownloadProvider(BMCLAPI, fileProvider)));
providersById = Map.of(
"official", new AutoDownloadProvider(MOJANG, fileProvider),
"balanced", new AutoDownloadProvider(balanced, fileProvider),
"mirror", new AutoDownloadProvider(BMCLAPI, fileProvider));
observer = FXUtils.observeWeak(() -> {
FetchTask.setDownloadExecutorConcurrency(
config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads());
}, config().autoDownloadThreadsProperty(), config().downloadThreadsProperty());
provider = new DownloadProviderWrapper(MOJANG);
}
static void init() {
FXUtils.onChangeAndOperate(config().versionListSourceProperty(), versionListSource -> {
if (!providersById.containsKey(versionListSource)) {
config().setVersionListSource(DEFAULT_PROVIDER_ID);
return;
InvalidationListener onChangeDownloadSource = observable -> {
String versionListSource = Objects.requireNonNullElse(config().getVersionListSource(), "");
if (config().isAutoChooseDownloadType()) {
DownloadProvider currentDownloadProvider = providersById.get(versionListSource);
if (currentDownloadProvider == null)
currentDownloadProvider = Objects.requireNonNull(providersById.get(DEFAULT_PROVIDER_ID),
"default provider is null");
provider.setProvider(currentDownloadProvider);
} else {
provider.setProvider(fileDownloadProvider);
}
};
config().versionListSourceProperty().addListener(onChangeDownloadSource);
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
currentDownloadProvider = Optional.ofNullable(providersById.get(versionListSource))
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
});
if (!rawProviders.containsKey(config().getDownloadType())) {
config().setDownloadType(DEFAULT_RAW_PROVIDER_ID);
}
onChangeDownloadSource.invalidated(null);
FXUtils.onChangeAndOperate(config().downloadTypeProperty(), downloadType -> {
DownloadProvider primary = Optional.ofNullable(rawProviders.get(downloadType))
.orElse(rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
fileDownloadProvider.setDownloadProviderCandidates(
Stream.concat(
Stream.of(primary),
rawProviders.values().stream().filter(x -> x != primary)
).collect(Collectors.toList())
);
DownloadProvider primary = Objects.requireNonNullElseGet(
rawProviders.get(Objects.requireNonNullElse(downloadType, "")),
() -> rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
List<DownloadProvider> providers = new ArrayList<>(rawProviders.size());
providers.add(primary);
for (DownloadProvider provider : rawProviders.values()) {
if (provider != primary)
providers.add(provider);
}
fileDownloadProvider.setDownloadProviderCandidates(providers);
});
}
public static String getPrimaryDownloadProviderId() {
String downloadType = config().getDownloadType();
if (providersById.containsKey(downloadType))
return downloadType;
else
return DEFAULT_PROVIDER_ID;
}
public static DownloadProvider getDownloadProviderByPrimaryId(String primaryId) {
return Optional.ofNullable(providersById.get(primaryId))
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
}
/**
* Get current primary preferred download provider
*/
public static DownloadProvider getDownloadProvider() {
return config().isAutoChooseDownloadType() ? currentDownloadProvider : fileDownloadProvider;
return provider;
}
public static String localizeErrorMessage(Throwable exception) {
@@ -48,4 +48,8 @@ public enum VersionIconType {
public Image getIcon() {
return FXUtils.newBuiltinImage(resourceUrl);
}
public Image getIcon(int size) {
return FXUtils.newBuiltinImage(resourceUrl, size, size, true, true);
}
}
@@ -771,6 +771,7 @@ public final class VersionSetting implements Cloneable, Observable {
obj.addProperty("wrapper", src.getWrapper());
obj.addProperty("fullscreen", src.isFullscreen());
obj.addProperty("noJVMArgs", src.isNoJVMArgs());
obj.addProperty("noOptimizingJVMArgs", src.isNoOptimizingJVMArgs());
obj.addProperty("notCheckGame", src.isNotCheckGame());
obj.addProperty("notCheckJVM", src.isNotCheckJVM());
obj.addProperty("notPatchNatives", src.isNotPatchNatives());
@@ -841,6 +842,7 @@ public final class VersionSetting implements Cloneable, Observable {
vs.setNativesDir(Optional.ofNullable(obj.get("nativesDir")).map(JsonElement::getAsString).orElse(""));
vs.setFullscreen(Optional.ofNullable(obj.get("fullscreen")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNoJVMArgs(Optional.ofNullable(obj.get("noJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNoOptimizingJVMArgs(Optional.ofNullable(obj.get("noOptimizingJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotCheckGame(Optional.ofNullable(obj.get("notCheckGame")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
@@ -0,0 +1,325 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaManager {
private TerracottaManager() {
}
private static final AtomicReference<TerracottaState> STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE);
private static final ReadOnlyObjectWrapper<TerracottaState> STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain());
private static final InvocationDispatcher<TerracottaState> STATE_D = InvocationDispatcher.runOn(Platform::runLater, STATE::set);
static {
Task.runAsync(() -> {
if (TerracottaMetadata.PROVIDER == null) {
setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.OS));
LOG.warning("Terracotta hasn't support your OS: " + org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM);
} else {
switch (TerracottaMetadata.PROVIDER.status()) {
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
case READY -> launch(setState(new TerracottaState.Launching()));
}
}
}).whenComplete(exception -> {
if (exception != null) {
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
}).start();
}
public static ReadOnlyObjectProperty<TerracottaState> stateProperty() {
return STATE.getReadOnlyProperty();
}
static {
Lang.thread(() -> {
while (true) {
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
LockSupport.parkNanos(500_000);
continue;
}
int port = portSpecific.port;
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
TerracottaState next;
try {
next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(jsonString -> {
TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class));
if (object.index <= index) {
return null;
}
object.port = port;
return object;
})
.setSignificance(Task.TaskSignificance.MINOR)
.run();
} catch (Exception e) {
LOG.warning("Cannot fetch state from Terracotta.", e);
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
if (next != null) {
compareAndSet(state, next);
}
LockSupport.parkNanos(500_000);
}
}, "Terracotta Background Daemon", true);
}
public static boolean validate(Path file) {
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
}
public static TerracottaState.Preparing install(@Nullable Path file) {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
) {
return null;
}
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
return null;
}
TerracottaState.Preparing preparing;
if (state instanceof TerracottaState.Preparing it) {
preparing = it;
} else {
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
}
Task.supplyAsync(Schedulers.io(), () -> {
return file != null ? TarFileTree.open(file) : null;
}).thenComposeAsync(Schedulers.javafx(), tree -> {
return getProvider().install(preparing, tree).whenComplete(exception -> {
if (tree != null) {
tree.close();
}
if (exception != null) {
throw exception;
}
});
}).whenComplete(exception -> {
if (exception == null) {
try {
TerracottaMetadata.removeLegacyVersionFiles();
} catch (IOException e) {
LOG.warning("Unable to remove legacy terracotta files.", e);
}
TerracottaState.Launching launching = new TerracottaState.Launching();
if (compareAndSet(preparing, launching)) {
launch(launching);
}
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
LOG.warning("Cannot install terracotta from local package.", exception);
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
} else if (exception instanceof DownloadException) {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
} else {
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
}
}).start();
return setState(preparing);
}
private static ITerracottaProvider getProvider() {
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
if (provider == null) {
throw new AssertionError("Terracotta Provider must NOT be null.");
}
return provider;
}
public static TerracottaState recover(@Nullable Path file) {
FXUtils.checkFxUserThread();
TerracottaState state = STATE_V.get();
if (!(state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())) {
return null;
}
try {
return switch (getProvider().status()) {
case NOT_EXIST, LEGACY_VERSION -> install(file);
case READY -> {
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
launch(launching);
yield launching;
}
};
} catch (NullPointerException | IOException e) {
LOG.warning("Cannot determine Terracotta state.", e);
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
}
}
private static void launch(TerracottaState.Launching state) {
Task.supplyAsync(() -> {
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
long exitTime = -1;
while (true) {
if (Files.exists(path)) {
JsonObject object = JsonUtils.fromNonNullJson(Files.readString(path), JsonObject.class);
return object.get("port").getAsInt();
}
if (!process.isRunning()) {
if (exitTime == -1) {
exitTime = System.currentTimeMillis();
} else if (System.currentTimeMillis() - exitTime >= 10000) {
throw new IllegalStateException("Process has exited for 10s.");
}
}
}
}).whenComplete(Schedulers.javafx(), (port, exception) -> {
TerracottaState next;
if (exception == null) {
next = new TerracottaState.Unknown(port);
} else {
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
}
compareAndSet(state, next);
}).start();
}
public static Task<String> exportLogs() {
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR);
}
return Task.completed(null);
}
public static TerracottaState.Waiting setWaiting() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
.setSignificance(Task.TaskSignificance.MINOR)
.start();
return new TerracottaState.Waiting(-1, -1, null);
}
return null;
}
private static String getPlayerName() {
Account account = Accounts.getSelectedAccount();
return account != null ? account.getCharacter() : i18n("terracotta.player_anonymous");
}
public static TerracottaState.HostScanning setScanning() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName()))
).setSignificance(Task.TaskSignificance.MINOR).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
}
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
return new GetTask(NetworkUtils.toURI(String.format(
"http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName()
)))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
} else {
return null;
}
}
private static <T extends TerracottaState> T setState(T value) {
if (value == null) {
throw new AssertionError();
}
STATE_V.set(value);
STATE_D.accept(value);
return value;
}
private static boolean compareAndSet(TerracottaState previous, TerracottaState next) {
if (next == null) {
throw new AssertionError();
}
if (STATE_V.compareAndSet(previous, next)) {
STATE_D.accept(next);
return true;
} else {
return false;
}
}
}
@@ -0,0 +1,203 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OSVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class TerracottaMetadata {
private TerracottaMetadata() {
}
public record Link(@SerializedName("desc") LocalizedText description, String link) {
}
private record Config(
@SerializedName("version_legacy") String legacy,
@SerializedName("version_recent") List<String> recent,
@SerializedName("version_latest") String latest,
@SerializedName("classifiers") Map<String, String> classifiers,
@SerializedName("downloads") List<String> downloads,
@SerializedName("links") List<Link> links
) {
private TerracottaNative of(String classifier) {
List<URI> links = new ArrayList<>(this.downloads.size());
for (String download : this.downloads) {
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
}
String hash = Objects.requireNonNull(this.classifiers.get(classifier), String.format("Classifier %s doesn't exist.", classifier));
if (!hash.startsWith("sha256:")) {
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
}
hash = hash.substring("sha256:".length());
return new TerracottaNative(
Collections.unmodifiableList(links),
Metadata.DEPENDENCIES_DIRECTORY.resolve(
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
).toAbsolutePath(),
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
);
}
}
public static final ITerracottaProvider PROVIDER;
public static final String PACKAGE_NAME;
private static final List<Link> PACKAGE_LINKS;
private static final Pattern LEGACY;
private static final List<String> RECENT;
private static final String LATEST;
static {
Config config;
try (InputStream is = TerracottaMetadata.class.getResourceAsStream("/assets/terracotta.json")) {
config = JsonUtils.fromNonNullJsonFully(is, Config.class);
} catch (IOException e) {
throw new ExceptionInInitializerError(e);
}
LEGACY = Pattern.compile(config.legacy);
RECENT = config.recent;
LATEST = config.latest;
ProviderContext context = locateProvider(config);
PROVIDER = context != null ? context.provider() : null;
PACKAGE_NAME = context != null ? String.format("terracotta-%s-%s-pkg.tar.gz", config.latest, context.branch) : null;
if (context != null) {
List<Link> packageLinks = new ArrayList<>(config.links.size());
for (Link link : config.links) {
packageLinks.add(new Link(
link.description,
link.link.replace("${version}", LATEST)
.replace("${classifier}", context.branch)
));
}
Collections.shuffle(packageLinks);
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
} else {
PACKAGE_LINKS = null;
}
}
private record ProviderContext(ITerracottaProvider provider, String branch) {
ProviderContext(ITerracottaProvider provider, String system, String arch) {
this(provider, system + "-" + arch);
}
}
public static List<Link> getPackageLinks() {
return PACKAGE_LINKS;
}
@Nullable
private static ProviderContext locateProvider(Config config) {
String architecture = switch (Architecture.SYSTEM_ARCH) {
case X86_64 -> "x86_64";
case ARM64 -> "arm64";
default -> null;
};
if (architecture == null) {
return null;
}
return switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_8_1)) {
yield new ProviderContext(
new GeneralProvider(config.of(String.format("windows-%s.exe", architecture))),
"windows", architecture
);
}
yield null;
}
case LINUX -> new ProviderContext(
new GeneralProvider(config.of(String.format("linux-%s", architecture))),
"linux", architecture
);
case MACOS -> new ProviderContext(
new MacOSProvider(
config.of(String.format("macos-%s.pkg", architecture)),
config.of(String.format("macos-%s", architecture))
),
"macos", architecture
);
default -> null;
};
}
public static void removeLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) {
continue;
}
try {
FileUtils.deleteDirectory(path);
} catch (IOException e) {
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
}
}
}
}
public static boolean hasLegacyVersionFiles() throws IOException {
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
for (Path path : terracotta) {
String name = FileUtils.getName(path);
if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,147 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import kala.compress.archivers.tar.TarArchiveEntry;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.CancellationException;
public final class TerracottaNative {
private final List<URI> links;
private final FileDownloadTask.IntegrityCheck checking;
private final Path path;
public TerracottaNative(List<URI> links, Path path, FileDownloadTask.IntegrityCheck checking) {
this.links = links;
this.path = path;
this.checking = checking;
}
public Path getPath() {
return path;
}
public Task<?> install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) {
if (tree == null) {
return new FileDownloadTask(links, path, checking) {
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
public void withResult(boolean success) {
delegate.withResult(success);
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
if (!context.hasInstallFence()) {
throw new CancellationException("User has installed terracotta from local archives.");
}
delegate.write(buffer, offset, len);
}
@Override
public void close() throws IOException {
if (isSuccess() && !context.requestInstallFence()) {
throw new CancellationException();
}
delegate.close();
}
};
}
};
}
return Task.runAsync(() -> {
String name = FileUtils.getName(path);
TarArchiveEntry entry = tree.getRoot().getFiles().get(name);
if (entry == null) {
throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name);
}
if (!context.requestInstallFence()) {
throw new CancellationException();
}
Files.createDirectories(path.toAbsolutePath().getParent());
MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm());
try (
InputStream stream = tree.getInputStream(entry);
OutputStream os = Files.newOutputStream(path)
) {
stream.transferTo(new OutputStream() {
@Override
public void write(int b) throws IOException {
os.write(b);
digest.update((byte) b);
}
@Override
public void write(byte @NotNull [] buffer, int offset, int len) throws IOException {
os.write(buffer, offset, len);
digest.update(buffer, offset, len);
}
});
}
String checksum = HexFormat.of().formatHex(digest.digest());
if (!checksum.equalsIgnoreCase(checking.getChecksum())) {
Files.delete(path);
throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum);
}
});
}
public ITerracottaProvider.Status status() throws IOException {
if (Files.exists(path)) {
if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) {
return ITerracottaProvider.Status.READY;
}
}
try {
if (TerracottaMetadata.hasLegacyVersionFiles()) {
return ITerracottaProvider.Status.LEGACY_VERSION;
}
} catch (IOException e) {
Logger.LOG.warning("Cannot determine whether legacy versions exist.");
}
return ITerracottaProvider.Status.NOT_EXIST;
}
}
@@ -0,0 +1,304 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.value.ObservableValue;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
import org.jackhuang.hmcl.util.gson.JsonSubtype;
import org.jackhuang.hmcl.util.gson.JsonType;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract sealed class TerracottaState {
protected TerracottaState() {
}
public boolean isUIFakeState() {
return false;
}
public boolean isForkOf(TerracottaState state) {
return false;
}
public static final class Bootstrap extends TerracottaState {
static final Bootstrap INSTANCE = new Bootstrap();
private Bootstrap() {
}
}
public static final class Uninitialized extends TerracottaState {
private final boolean hasLegacy;
Uninitialized(boolean hasLegacy) {
this.hasLegacy = hasLegacy;
}
public boolean hasLegacy() {
return hasLegacy;
}
}
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
private final ReadOnlyDoubleWrapper progress;
private final AtomicBoolean installFence = new AtomicBoolean(false);
Preparing(ReadOnlyDoubleWrapper progress) {
this.progress = progress;
}
public ReadOnlyDoubleProperty progressProperty() {
return progress.getReadOnlyProperty();
}
@Override
public void bindProgress(ObservableValue<? extends Number> value) {
progress.bind(value);
}
@Override
public boolean requestInstallFence() {
return installFence.compareAndSet(false, true);
}
@Override
public boolean hasInstallFence() {
return !installFence.get();
}
}
public static final class Launching extends TerracottaState {
Launching() {
}
}
static abstract sealed class PortSpecific extends TerracottaState {
transient int port;
protected PortSpecific(int port) {
this.port = port;
}
}
@JsonType(
property = "state",
subtypes = {
@JsonSubtype(clazz = Waiting.class, name = "waiting"),
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"),
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
@JsonSubtype(clazz = Exception.class, name = "exception"),
}
)
static abstract sealed class Ready extends PortSpecific {
@SerializedName("index")
final int index;
@SerializedName("state")
private final String state;
Ready(int port, int index, String state) {
super(port);
this.index = index;
this.state = state;
}
@Override
public boolean isUIFakeState() {
return this.index == -1;
}
}
public static final class Unknown extends PortSpecific {
Unknown(int port) {
super(port);
}
}
public static final class Waiting extends Ready {
Waiting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostScanning extends Ready {
HostScanning(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostStarting extends Ready {
HostStarting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class HostOK extends Ready implements Validation {
@SerializedName("room")
private final String code;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
HostOK(int port, int index, String state, String code, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.code = code;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (code == null) {
throw new JsonParseException("code is null");
}
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getCode() {
return code;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof HostOK hostOK && this.index - hostOK.index <= profileIndex;
}
}
public static final class GuestStarting extends Ready {
GuestStarting(int port, int index, String state) {
super(port, index, state);
}
}
public static final class GuestOK extends Ready implements Validation {
@SerializedName("url")
private final String url;
@SerializedName("profile_index")
private final int profileIndex;
@SerializedName("profiles")
private final List<TerracottaProfile> profiles;
GuestOK(int port, int index, String state, String url, int profileIndex, List<TerracottaProfile> profiles) {
super(port, index, state);
this.url = url;
this.profileIndex = profileIndex;
this.profiles = profiles;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (profiles == null) {
throw new JsonParseException("profiles is null");
}
}
public String getUrl() {
return url;
}
public List<TerracottaProfile> getProfiles() {
return profiles;
}
@Override
public boolean isForkOf(TerracottaState state) {
return state instanceof GuestOK guestOK && this.index - guestOK.index <= profileIndex;
}
}
public static final class Exception extends Ready implements Validation {
public enum Type {
PING_HOST_FAIL,
PING_HOST_RST,
GUEST_ET_CRASH,
HOST_ET_CRASH,
PING_SERVER_RST,
SCAFFOLDING_INVALID_RESPONSE
}
private static final TerracottaState.Exception.Type[] LOOKUP = Type.values();
@SerializedName("type")
private final int type;
Exception(int port, int index, String state, int type) {
super(port, index, state);
this.type = type;
}
@Override
public void validate() throws JsonParseException, TolerableValidationException {
if (type < 0 || type >= LOOKUP.length) {
throw new JsonParseException(String.format("Type must between [0, %s)", LOOKUP.length));
}
}
public Type getType() {
return LOOKUP[type];
}
}
public static final class Fatal extends TerracottaState {
public enum Type {
OS,
NETWORK,
INSTALL,
TERRACOTTA,
UNKNOWN;
}
private final Type type;
public Fatal(Type type) {
this.type = type;
}
public Type getType() {
return type;
}
public boolean isRecoverable() {
return this.type != Type.OS && this.type != Type.UNKNOWN;
}
}
}
@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -15,33 +15,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.event;
package org.jackhuang.hmcl.terracotta.profile;
/**
*
* @author huang
*/
public class FailedEvent<T> extends Event {
private final int failedTime;
private T newResult;
public FailedEvent(Object source, int failedTime, T newResult) {
super(source);
this.failedTime = failedTime;
this.newResult = newResult;
}
public int getFailedTime() {
return failedTime;
}
public T getNewResult() {
return newResult;
}
public void setNewResult(T newResult) {
this.newResult = newResult;
}
import com.google.gson.annotations.SerializedName;
public enum ProfileKind {
@SerializedName("HOST")
HOST,
@SerializedName("LOCAL")
LOCAL,
@SerializedName("GUEST")
GUEST
}
@@ -0,0 +1,57 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.profile;
import com.google.gson.annotations.SerializedName;
public final class TerracottaProfile {
@SerializedName("machine_id")
private final String machineID;
@SerializedName("name")
private final String name;
@SerializedName("vendor")
private final String vendor;
@SerializedName("kind")
private final ProfileKind type;
private TerracottaProfile(String machineID, String name, String vendor, ProfileKind type) {
this.machineID = machineID;
this.name = name;
this.vendor = vendor;
this.type = type;
}
public String getMachineID() {
return machineID;
}
public String getName() {
return name;
}
public String getVendor() {
return vendor;
}
public ProfileKind getType() {
return type;
}
}
@@ -0,0 +1,67 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
public final class GeneralProvider implements ITerracottaProvider {
private final TerracottaNative target;
public GeneralProvider(TerracottaNative target) {
this.target = target;
}
@Override
public Status status() throws IOException {
return target.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
Task<?> task = target.install(context, tree);
context.bindProgress(task.progressProperty());
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)));
}
return task;
}
@Override
public List<String> ofCommandLine(Path path) {
return List.of(target.getPath().toString(), "--hmcl", path.toString());
}
}
@@ -0,0 +1,77 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import javafx.beans.value.ObservableValue;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
public interface ITerracottaProvider {
enum Status {
NOT_EXIST,
LEGACY_VERSION,
READY
}
interface Context {
void bindProgress(ObservableValue<? extends Number> value);
boolean requestInstallFence();
boolean hasInstallFence();
}
abstract class ProviderException extends IOException {
public ProviderException(String message) {
super(message);
}
public ProviderException(String message, Throwable cause) {
super(message, cause);
}
public ProviderException(Throwable cause) {
super(cause);
}
}
final class ArchiveFileMissingException extends ProviderException {
public ArchiveFileMissingException(String message) {
super(message);
}
public ArchiveFileMissingException(String message, Throwable cause) {
super(message, cause);
}
public ArchiveFileMissingException(Throwable cause) {
super(cause);
}
}
Status status() throws IOException;
Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException;
List<String> ofCommandLine(Path path);
}
@@ -0,0 +1,97 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta.provider;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaNative;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class MacOSProvider implements ITerracottaProvider {
public final TerracottaNative installer, binary;
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
this.installer = installer;
this.binary = binary;
}
@Override
public Status status() throws IOException {
assert binary != null;
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
return Status.NOT_EXIST;
}
return binary.status();
}
@Override
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
assert installer != null && binary != null;
Task<?> installerTask = installer.install(context, tree);
Task<?> binaryTask = binary.install(context, tree);
context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8
return Task.allOf(
installerTask.thenComposeAsync(() -> {
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
"osascript",
"-e",
String.format(
"do shell script \"installer -pkg %s -target /Applications\" with prompt \"%s\" with administrator privileges",
installer.getPath(),
i18n("terracotta.sudo_installing")
)
));
process.pumpInputStream(SystemUtils::onLogLine);
process.pumpErrorStream(SystemUtils::onLogLine);
return Task.fromCompletableFuture(process.getProcess().onExit());
}),
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_READ,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_READ,
PosixFilePermission.OTHERS_EXECUTE
)))
);
}
@Override
public List<String> ofCommandLine(Path path) {
assert binary != null;
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
}
}
@@ -40,6 +40,7 @@ import javafx.stage.StageStyle;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
@@ -55,8 +56,10 @@ import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
import org.jackhuang.hmcl.ui.versions.GameListPage;
import org.jackhuang.hmcl.ui.versions.VersionPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
@@ -108,6 +111,7 @@ public final class Controllers {
return accountListPage;
});
private static Lazy<LauncherSettingsPage> settingsPage = new Lazy<>(LauncherSettingsPage::new);
private static Lazy<TerracottaPage> terracottaPage = new Lazy<>(TerracottaPage::new);
private Controllers() {
}
@@ -150,6 +154,11 @@ public final class Controllers {
return downloadPage.get();
}
// FXThread
public static Node getTerracottaPage() {
return terracottaPage.get();
}
// FXThread
public static DecoratorController getDecorator() {
return decorator;
@@ -424,6 +433,30 @@ public final class Controllers {
dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build());
}
public static void confirmActionDanger(String text, String title, Runnable resolve, Runnable cancel) {
JFXButton btnYes = new JFXButton(i18n("button.ok"));
btnYes.getStyleClass().add("dialog-error");
btnYes.setOnAction(e -> resolve.run());
btnYes.setDisable(true);
int countdown = 10;
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
for (int i = 0; i < countdown; i++) {
keyFrames[i] = new KeyFrame(Duration.seconds(i),
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
}
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
new KeyValue(btnYes.disableProperty(), false));
Timeline timeline = new Timeline(keyFrames);
confirmAction(text, title, MessageType.WARNING, btnYes, () -> {
timeline.stop();
cancel.run();
});
timeline.play();
}
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult) {
return prompt(title, onResult, "");
}
@@ -470,6 +503,10 @@ public final class Controllers {
Controllers.getSettingsPage().showFeedback();
Controllers.navigate(Controllers.getSettingsPage());
break;
case "hmcl://game/launch":
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
break;
}
} else {
FXUtils.openLink(href);
@@ -93,10 +93,7 @@ import java.nio.file.PathMatcher;
import java.util.List;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -1218,7 +1215,6 @@ public final class FXUtils {
public static JFXButton newBorderButton(String text) {
JFXButton button = new JFXButton(text);
button.getStyleClass().add("jfx-button-border");
button.setButtonType(JFXButton.ButtonType.RAISED);
return button;
}
@@ -1370,6 +1366,27 @@ public final class FXUtils {
});
}
public static <T> void onScroll(Node node, List<T> list,
ToIntFunction<List<T>> finder,
Consumer<T> updater
) {
node.addEventHandler(ScrollEvent.SCROLL, event -> {
double deltaY = event.getDeltaY();
if (deltaY == 0)
return;
int index = finder.applyAsInt(list);
if (index < 0) return;
if (deltaY > 0) // up
index--;
else // down
index++;
updater.accept(list.get((index + list.size()) % list.size()));
event.consume();
});
}
public static void copyOnDoubleClick(Labeled label) {
label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
@@ -64,11 +64,9 @@ public class ListPageSkin extends SkinBase<ListPage<?>> {
vBox.setAlignment(Pos.BOTTOM_RIGHT);
vBox.setPickOnBounds(false);
JFXButton btnAdd = new JFXButton();
JFXButton btnAdd = FXUtils.newRaisedButton("");
FXUtils.setLimitWidth(btnAdd, 40);
FXUtils.setLimitHeight(btnAdd, 40);
btnAdd.getStyleClass().add("jfx-button-raised-round");
btnAdd.setButtonType(JFXButton.ButtonType.RAISED);
btnAdd.setGraphic(SVG.ADD.createIcon(Theme.whiteFill(), -1));
btnAdd.setOnAction(e -> skinnable.add());
@@ -40,6 +40,7 @@ import javafx.stage.Stage;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.Log;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.CircularArrayList;
@@ -311,6 +312,7 @@ public final class LogWindow extends Stage {
Holder<Object> lastCell = new Holder<>();
listView.setCellFactory(x -> new ListCell<Log>() {
{
x.setSelectionModel(new NoneMultipleSelectionModel<>());
getStyleClass().add("log-window-list-cell");
Region clippedContainer = (Region) listView.lookup(".clipped-container");
if (clippedContainer != null) {
@@ -48,11 +48,22 @@ public final class UpgradeDialog extends JFXDialogLayout {
setBody(new ProgressIndicator());
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
boolean isPreview = remoteVersion.isPreview();
Task.supplyAsync(Schedulers.io(), () -> {
Document document = Jsoup.parse(new URL(url), 30 * 1000);
Node node = document.selectFirst("#nowchange");
String id = null;
Node node = null;
if (isPreview) {
id = "nowpreview";
node = document.selectFirst("#" + id);
}
if (node == null) {
id = "nowchange";
node = document.selectFirst("#" + id);
}
if (node == null || !"h1".equals(node.nodeName()))
throw new IOException("Cannot find #nowchange in document");
throw new IOException("Cannot find current changelog in document");
HTMLRenderer renderer = new HTMLRenderer(uri -> {
LOG.info("Open link: " + uri);
@@ -60,7 +71,7 @@ public final class UpgradeDialog extends JFXDialogLayout {
});
do {
if ("h1".equals(node.nodeName()) && !"nowchange".equals(node.attr("id"))) {
if ("h1".equals(node.nodeName()) && !id.equals(node.attr("id"))) {
break;
}
renderer.appendNode(node);
@@ -21,7 +21,6 @@ import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Tooltip;
import org.jackhuang.hmcl.auth.Account;
@@ -77,25 +76,9 @@ public class AccountAdvancedListItem extends AdvancedListItem {
setActionButtonVisible(false);
setOnScroll(event -> {
double deltaY = event.getDeltaY();
if (deltaY == 0)
return;
Account current = account.get();
if (current == null) return;
ObservableList<Account> accounts = Accounts.getAccounts();
int currentIndex = accounts.indexOf(current);
if (currentIndex < 0) return;
if (deltaY > 0) // up
currentIndex--;
else // down
currentIndex++;
Accounts.setSelectedAccount(accounts.get((currentIndex + accounts.size()) % accounts.size()));
});
FXUtils.onScroll(this, Accounts.getAccounts(),
accounts -> accounts.indexOf(account.get()),
Accounts::setSelectedAccount);
}
public ObjectProperty<Account> accountProperty() {
@@ -19,9 +19,6 @@ package org.jackhuang.hmcl.ui.account;
import com.jfoenix.controls.*;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.beans.binding.BooleanBinding;
@@ -39,7 +36,6 @@ import javafx.scene.control.Label;
import javafx.scene.control.TextInputControl;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.CharacterSelector;
@@ -268,33 +264,10 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
};
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
JFXButton btnYes = new JFXButton(i18n("button.ok"));
btnYes.getStyleClass().add("dialog-error");
btnYes.setOnAction(e -> doCreate.run());
btnYes.setDisable(true);
int countdown = 10;
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
for (int i = 0; i < countdown; i++) {
keyFrames[i] = new KeyFrame(Duration.seconds(i),
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
}
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
new KeyValue(btnYes.disableProperty(), false));
Timeline timeline = new Timeline(keyFrames);
Controllers.confirmAction(
i18n("account.methods.offline.name.invalid"), i18n("message.warning"),
MessageDialogPane.MessageType.WARNING,
btnYes,
() -> {
timeline.stop();
body.setDisable(false);
spinner.hideSpinner();
}
);
timeline.play();
Controllers.confirmActionDanger(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), doCreate, () -> {
body.setDisable(false);
spinner.hideSpinner();
});
} else {
doCreate.run();
}
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.construct;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
@@ -40,8 +41,16 @@ public class AdvancedListBox extends ScrollPane {
setFitToHeight(true);
setFitToWidth(true);
setHbarPolicy(ScrollBarPolicy.NEVER);
setVbarPolicy(ScrollBarPolicy.NEVER);
container.getStyleClass().add("advanced-list-box-content");
this.addEventFilter(MouseEvent.MOUSE_ENTERED, event -> {
if (container.getHeight() > getHeight())
setVbarPolicy(ScrollBarPolicy.AS_NEEDED);
});
this.addEventFilter(MouseEvent.MOUSE_EXITED,
event -> setVbarPolicy(ScrollBarPolicy.NEVER));
}
public AdvancedListBox add(Node child) {
@@ -73,8 +73,7 @@ final class ComponentListCell extends StackPane {
@SuppressWarnings("unchecked")
private void updateLayout() {
if (content instanceof ComponentList) {
ComponentList list = (ComponentList) content;
if (content instanceof ComponentList list) {
content.getStyleClass().remove("options-list");
content.getStyleClass().add("options-sublist");
@@ -130,7 +129,10 @@ final class ComponentListCell extends StackPane {
groupNode.getChildren().add(headerRippler);
VBox container = new VBox();
container.setPadding(new Insets(8, 16, 10, 16));
boolean hasPadding = !(list instanceof ComponentSublist subList) || subList.hasComponentPadding();
if (hasPadding) {
container.setPadding(new Insets(8, 16, 10, 16));
}
FXUtils.setLimitHeight(container, 0);
FXUtils.setOverflowHidden(container);
container.getChildren().setAll(content);
@@ -149,7 +151,8 @@ final class ComponentListCell extends StackPane {
}
Platform.runLater(() -> {
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + 8 + 10) * (expanded ? 1 : -1);
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + (hasPadding ? 8 + 10 : 4)) * (expanded ? 1 : -1);
double newHeight = expanded ? getHeight() + newAnimatedHeight : prefHeight(list.getWidth());
double contentHeight = expanded ? newAnimatedHeight : 0;
@@ -18,7 +18,9 @@
package org.jackhuang.hmcl.ui.construct;
import javafx.beans.DefaultProperty;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Node;
@@ -27,6 +29,7 @@ public class ComponentSublist extends ComponentList {
private final ObjectProperty<Node> headerLeft = new SimpleObjectProperty<>(this, "headerLeft");
private final ObjectProperty<Node> headerRight = new SimpleObjectProperty<>(this, "headerRight");
private final BooleanProperty componentPadding = new SimpleBooleanProperty(this, "componentPadding", true);
public ComponentSublist() {
super();
@@ -55,4 +58,16 @@ public class ComponentSublist extends ComponentList {
public void setHeaderRight(Node headerRight) {
this.headerRight.set(headerRight);
}
public boolean hasComponentPadding() {
return componentPadding.get();
}
public BooleanProperty componentPaddingProperty() {
return componentPadding;
}
public void setComponentPadding(boolean componentPadding) {
this.componentPadding.set(componentPadding);
}
}
@@ -25,6 +25,8 @@ import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
@@ -61,7 +63,12 @@ public class OptionToggleButton extends StackPane {
toggleButton.setSize(8);
FXUtils.setLimitHeight(toggleButton, 30);
FXUtils.onClicked(container, () -> toggleButton.setSelected(!toggleButton.isSelected()));
container.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY) {
toggleButton.setSelected(!toggleButton.isSelected());
e.consume();
}
});
FXUtils.onChangeAndOperate(subtitleProperty(), subtitle -> {
if (StringUtils.isNotBlank(subtitle)) {
@@ -191,7 +191,7 @@ public class RipplerContainer extends StackPane {
return StyleableProperties.FACTORY.getCssMetaData();
}
private static class StyleableProperties {
private final static class StyleableProperties {
private static final StyleablePropertyFactory<RipplerContainer> FACTORY = new StyleablePropertyFactory<>(StackPane.getClassCssMetaData());
private static final CssMetaData<RipplerContainer, Paint> RIPPLER_FILL = FACTORY.createPaintCssMetaData("-jfx-rippler-fill", s -> s.ripplerFill, Color.rgb(0, 200, 255));
@@ -225,7 +225,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class HeadersRegion extends StackPane {
private final class HeadersRegion extends StackPane {
private SideAction action;
private final ObjectProperty<Side> side = new SimpleObjectProperty<Side>() {
@Override
@@ -379,7 +379,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Top extends Horizontal {
private final class Top extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -404,7 +404,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Bottom extends Horizontal {
private final class Bottom extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -519,7 +519,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Left extends Vertical {
private final class Left extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -543,7 +543,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Right extends Vertical {
private final class Right extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -40,6 +40,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class TaskExecutorDialogPane extends BorderPane {
private TaskExecutor executor;
private TaskCancellationAction onCancel;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private final Consumer<FetchTask.SpeedEvent> speedEventHandler;
private final Label lblTitle;
@@ -84,7 +85,7 @@ public class TaskExecutorDialogPane extends BorderPane {
}
});
speedEventHandler = speedEvent -> {
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
String unit = "B/s";
double speed = speedEvent.getSpeed();
if (speed > 1024) {
@@ -95,11 +96,10 @@ public class TaskExecutorDialogPane extends BorderPane {
speed /= 1024;
unit = "MiB/s";
}
double finalSpeed = speed;
String finalUnit = unit;
Platform.runLater(() -> lblProgress.setText(String.format("%.1f %s", finalSpeed, finalUnit)));
};
FileDownloadTask.speedEvent.channel(FetchTask.SpeedEvent.class).registerWeak(speedEventHandler);
String message = String.format("%.1f %s", speed, unit);
Platform.runLater(() -> lblProgress.setText(message));
});
onEscPressed(this, btnCancel::fire);
}
@@ -82,8 +82,7 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
BorderPane descriptionPane = new BorderPane();
{
btnDescription = new JFXButton(i18n("modpack.description"));
btnDescription.getStyleClass().add("jfx-button-border");
btnDescription = FXUtils.newBorderButton(i18n("modpack.description"));
btnDescription.setOnAction(e -> onDescribe());
descriptionPane.setLeft(btnDescription);
@@ -287,7 +287,7 @@ public class PartialInflaterInputStream extends FilterInputStream {
* the mark position becomes invalid.
* @see InputStream#reset()
*/
public synchronized void mark(int readlimit) {
public void mark(int readlimit) {
}
/**
@@ -302,7 +302,7 @@ public class PartialInflaterInputStream extends FilterInputStream {
* @see InputStream#mark(int)
* @see IOException
*/
public synchronized void reset() throws IOException {
public void reset() throws IOException {
throw new IOException("mark/reset not supported");
}
}
@@ -72,6 +72,7 @@ import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
@@ -115,6 +116,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
ImageView titleIcon = new ImageView(FXUtils.newBuiltinImage("/assets/img/icon-title.png"));
Label titleLabel = new Label(Metadata.FULL_TITLE);
if (I18n.isUpsideDown()) {
titleIcon.setRotate(180);
titleLabel.setRotate(180);
}
titleLabel.getStyleClass().add("jfx-decorator-title");
titleNode.getChildren().setAll(titleIcon, titleLabel);
@@ -208,20 +213,11 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchPane.getStyleClass().add("launch-pane");
launchPane.setMaxWidth(230);
launchPane.setMaxHeight(55);
launchPane.setOnScroll(event -> {
double deltaY = event.getDeltaY();
if (deltaY == 0)
return;
FXUtils.onScroll(launchPane, versions, list -> {
String currentId = getCurrentGame();
int index = Lang.indexWhere(versions, instance -> instance.getId().equals(currentId));
if (index < 0) return;
if (deltaY > 0) // up
index--;
else // down
index++;
profile.setSelectedVersion(versions.get((index + versions.size()) % versions.size()).getId());
});
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, it -> profile.setSelectedVersion(it.getId()));
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
{
JFXButton launchButton = new JFXButton();
@@ -435,6 +431,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
return state;
}
public Profile getProfile() {
return profile;
}
public String getCurrentGame() {
return currentGame.get();
}
@@ -447,6 +447,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
this.currentGame.set(currentGame);
}
public ObservableList<Version> getVersions() {
return versions;
}
public boolean isShowUpdate() {
return showUpdate.get();
}
@@ -174,6 +174,7 @@ public class PersonalizationPage extends StackPane {
}
Label textOpacity = new Label();
FXUtils.setLimitWidth(textOpacity, 35);
StringBinding valueBinding = Bindings.createStringBinding(() -> ((int) slider.getValue()) + "%", slider.valueProperty());
textOpacity.textProperty().bind(valueBinding);
@@ -154,6 +154,10 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
Versions.modifyGameSettings(profile, version);
}
});
FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> {
String currentId = getSkinnable().getMainPage().getCurrentGame();
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, it -> getSkinnable().getMainPage().getProfile().setSelectedVersion(it.getId()));
// third item in left sidebar
AdvancedListItem gameItem = new AdvancedListItem();
@@ -178,11 +182,11 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
launcherSettingsItem.setOnAction(e -> Controllers.navigate(Controllers.getSettingsPage()));
// sixth item in left sidebar
AdvancedListItem chatItem = new AdvancedListItem();
chatItem.setLeftGraphic(wrap(SVG.CHAT));
chatItem.setActionButtonVisible(false);
chatItem.setTitle(i18n("chat"));
chatItem.setOnAction(e -> FXUtils.openLink(Metadata.GROUPS_URL));
AdvancedListItem terracottaItem = new AdvancedListItem();
terracottaItem.setLeftGraphic(wrap(SVG.HOST));
terracottaItem.setActionButtonVisible(false);
terracottaItem.setTitle(i18n("terracotta"));
terracottaItem.setOnAction(e -> Controllers.navigate(Controllers.getTerracottaPage()));
// the left sidebar
AdvancedListBox sideBar = new AdvancedListBox()
@@ -194,8 +198,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
.add(downloadItem)
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
.add(launcherSettingsItem)
.add(chatItem)
;
.add(terracottaItem);
// the root page, with the sidebar in left, navigator in center.
setLeft(sideBar);
@@ -33,7 +33,7 @@ import org.jackhuang.hmcl.upgrade.UpdateChannel;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.tukaani.xz.XZInputStream;
@@ -65,7 +65,7 @@ public final class SettingsPage extends SettingsView {
FXUtils.smoothScrolling(scroll);
// ==== Languages ====
cboLanguage.getItems().setAll(Locales.LOCALES);
cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales());
selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty());
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
@@ -114,9 +114,12 @@ public final class SettingsPage extends SettingsView {
chkUpdateStable.setUserData(UpdateChannel.STABLE);
ObjectProperty<UpdateChannel> updateChannel = selectedItemPropertyFor(updateChannelGroup, UpdateChannel.class);
updateChannel.set(UpdateChannel.getChannel());
updateChannel.addListener((a, b, newValue) -> {
UpdateChecker.requestCheckUpdate(newValue);
});
InvalidationListener checkUpdateListener = e -> {
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
};
updateChannel.addListener(checkUpdateListener);
previewPane.selectedProperty().addListener(checkUpdateListener);
// ====
}
@@ -39,7 +39,7 @@ import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import java.util.Arrays;
@@ -58,6 +58,7 @@ public abstract class SettingsView extends StackPane {
protected final JFXRadioButton chkUpdateStable;
protected final JFXRadioButton chkUpdateDev;
protected final JFXButton btnUpdate;
protected final OptionToggleButton previewPane;
protected final ScrollPane scroll;
public SettingsView() {
@@ -147,6 +148,14 @@ public abstract class SettingsView extends StackPane {
settingsPane.getContent().add(updatePane);
}
{
previewPane = new OptionToggleButton();
previewPane.setTitle(i18n("update.preview"));
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
settingsPane.getContent().add(previewPane);
}
{
fileCommonLocation = new MultiFileItem<>();
fileCommonLocationSublist = new ComponentSublist();
@@ -162,9 +171,8 @@ public abstract class SettingsView extends StackPane {
));
{
JFXButton cleanButton = new JFXButton(i18n("launcher.cache_directory.clean"));
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
cleanButton.setOnAction(e -> clearCacheDirectory());
cleanButton.getStyleClass().add("jfx-button-border");
fileCommonLocationSublist.setHeaderRight(cleanButton);
}
@@ -216,9 +224,8 @@ public abstract class SettingsView extends StackPane {
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
JFXButton logButton = new JFXButton(i18n("settings.launcher.launcher_log.export"));
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
logButton.setOnAction(e -> onExportLogs());
logButton.getStyleClass().add("jfx-button-border");
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
@@ -27,6 +27,7 @@ import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
@@ -68,9 +69,7 @@ public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
actions.setPadding(new Insets(8));
actions.setAlignment(Pos.CENTER_RIGHT);
JFXButton saveButton = new JFXButton(i18n("button.save"));
saveButton.getStyleClass().add("jfx-button-raised");
saveButton.setButtonType(JFXButton.ButtonType.RAISED);
JFXButton saveButton = FXUtils.newRaisedButton(i18n("button.save"));
saveButton.setOnAction(e -> {
try {
save();
@@ -80,9 +79,7 @@ public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
}
});
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("jfx-button-raised");
cancelButton.setButtonType(JFXButton.ButtonType.RAISED);
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
onEscPressed(this, cancelButton::fire);
@@ -0,0 +1,661 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.terracotta;
import com.jfoenix.controls.JFXProgressBar;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.terracotta.TerracottaManager;
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
import org.jackhuang.hmcl.terracotta.TerracottaState;
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class TerracottaControllerPage extends StackPane {
private static final ObjectProperty<TerracottaState> UI_STATE = new SimpleObjectProperty<>();
static {
FXUtils.onChangeAndOperate(TerracottaManager.stateProperty(), state -> {
if (state != null) {
UI_STATE.set(state);
}
});
}
private final WeakListenerHolder holder = new WeakListenerHolder();
/* FIXME: It's sucked to have such a long logic, containing UI for all states defined in TerracottaState, with unclear control flows.
Consider moving UI into multiple files for each state respectively. */
public TerracottaControllerPage() {
TransitionPane transition = new TransitionPane();
ObjectProperty<String> statusProperty = new SimpleObjectProperty<>();
DoubleProperty progressProperty = new SimpleDoubleProperty();
ObservableList<Node> nodesProperty = FXCollections.observableList(new ArrayList<>());
FXUtils.applyDragListener(this, path -> {
TerracottaState state = UI_STATE.get();
if (state instanceof TerracottaState.Uninitialized ||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK
) {
return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz");
} else {
return false;
}
}, files -> {
Path path = files.get(0);
if (!TerracottaManager.validate(path)) {
Controllers.dialog(
i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)),
i18n("message.error"),
MessageDialogPane.MessageType.ERROR
);
return;
}
TerracottaState state = UI_STATE.get(), next;
if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence()) {
if (state instanceof TerracottaState.Uninitialized uninitialized && !uninitialized.hasLegacy()) {
Controllers.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
TerracottaState.Preparing s = TerracottaManager.install(path);
if (s != null) {
UI_STATE.set(s);
}
}, () -> {
});
return;
}
next = TerracottaManager.install(path);
} else if (state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
next = TerracottaManager.recover(path);
} else {
return;
}
if (next != null) {
UI_STATE.set(next);
}
});
ChangeListener<TerracottaState> listener = (_uiState, legacyState, state) -> {
if (legacyState != null && legacyState.isUIFakeState() && !state.isUIFakeState() && legacyState.getClass() == state.getClass()) {
return;
}
progressProperty.unbind();
if (state instanceof TerracottaState.Bootstrap) {
statusProperty.set(i18n("terracotta.status.bootstrap"));
progressProperty.set(-1);
nodesProperty.setAll();
} else if (state instanceof TerracottaState.Uninitialized uninitialized) {
String fork = uninitialized.hasLegacy() ? "update" : "not_exist";
statusProperty.set(i18n("terracotta.status.uninitialized." + fork));
progressProperty.set(0);
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
body.setLineSpacing(4);
LineButton download = LineButton.of();
download.setLeftImage(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
download.setTitle(i18n(String.format("terracotta.status.uninitialized.%s.title", fork)));
download.setSubtitle(i18n("terracotta.status.uninitialized.desc"));
download.setRightIcon(SVG.ARROW_FORWARD);
FXUtils.onClicked(download, () -> {
if (uninitialized.hasLegacy()) {
TerracottaState.Preparing s = TerracottaManager.install(null);
if (s != null) {
UI_STATE.set(s);
}
} else {
Controllers.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
TerracottaState.Preparing s = TerracottaManager.install(null);
if (s != null) {
UI_STATE.set(s);
}
}, () -> {
});
}
});
nodesProperty.setAll(body, download, getThirdPartyDownloadNodes());
} else if (state instanceof TerracottaState.Preparing) {
statusProperty.set(i18n("terracotta.status.preparing"));
progressProperty.bind(((TerracottaState.Preparing) state).progressProperty());
nodesProperty.setAll(getThirdPartyDownloadNodes());
} else if (state instanceof TerracottaState.Launching) {
statusProperty.set(i18n("terracotta.status.launching"));
progressProperty.set(-1);
nodesProperty.setAll();
} else if (state instanceof TerracottaState.Unknown) {
statusProperty.set(i18n("terracotta.status.unknown"));
progressProperty.set(-1);
nodesProperty.setAll();
} else if (state instanceof TerracottaState.Waiting) {
statusProperty.set(i18n("terracotta.status.waiting"));
progressProperty.set(1);
TextFlow flow = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
flow.setLineSpacing(4);
LineButton host = LineButton.of();
host.setLeftIcon(SVG.HOST);
host.setTitle(i18n("terracotta.status.waiting.host.title"));
host.setSubtitle(i18n("terracotta.status.waiting.host.desc"));
host.setRightIcon(SVG.ARROW_FORWARD);
FXUtils.onClicked(host, () -> {
if (LauncherHelper.countMangedProcesses() >= 1) {
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
if (s1 != null) {
UI_STATE.set(s1);
}
} else {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("terracotta.status.waiting.host.launch.desc"),
i18n("terracotta.status.waiting.host.launch.title"),
MessageDialogPane.MessageType.QUESTION
).addAction(i18n("version.launch"), () -> {
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
}).addCancel(i18n("terracotta.status.waiting.host.launch.skip"), () -> {
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
if (s1 != null) {
UI_STATE.set(s1);
}
}).addCancel(() -> {
}).build());
}
});
LineButton guest = LineButton.of();
guest.setLeftIcon(SVG.ADD_CIRCLE);
guest.setTitle(i18n("terracotta.status.waiting.guest.title"));
guest.setSubtitle(i18n("terracotta.status.waiting.guest.desc"));
guest.setRightIcon(SVG.ARROW_FORWARD);
FXUtils.onClicked(guest, () -> {
Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, resolve, reject) -> {
Task<TerracottaState.GuestStarting> task = TerracottaManager.setGuesting(code);
if (task != null) {
task.whenComplete(Schedulers.javafx(), (s, e) -> {
if (e != null) {
reject.accept(i18n("terracotta.status.waiting.guest.prompt.invalid"));
} else {
resolve.run();
UI_STATE.set(s);
}
}).setSignificance(Task.TaskSignificance.MINOR).start();
} else {
resolve.run();
}
});
});
nodesProperty.setAll(flow, host, guest);
} else if (state instanceof TerracottaState.HostScanning) {
statusProperty.set(i18n("terracotta.status.scanning"));
progressProperty.set(-1);
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.status.scanning.desc"), Controllers::onHyperlinkAction);
body.setLineSpacing(4);
LineButton room = LineButton.of();
room.setLeftIcon(SVG.ARROW_BACK);
room.setTitle(i18n("terracotta.back"));
room.setSubtitle(i18n("terracotta.status.scanning.back"));
FXUtils.onClicked(room, () -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
nodesProperty.setAll(body, room);
} else if (state instanceof TerracottaState.HostStarting) {
statusProperty.set(i18n("terracotta.status.host_starting"));
progressProperty.set(-1);
LineButton room = LineButton.of();
room.setLeftIcon(SVG.ARROW_BACK);
room.setTitle(i18n("terracotta.back"));
room.setSubtitle(i18n("terracotta.status.host_starting.back"));
FXUtils.onClicked(room, () -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
nodesProperty.setAll(room);
} else if (state instanceof TerracottaState.HostOK hostOK) {
if (hostOK.isForkOf(legacyState)) {
((PlayerProfileUI) nodesProperty.get(nodesProperty.size() - 1)).updateProfiles(hostOK.getProfiles());
return;
} else {
String cs = hostOK.getCode();
statusProperty.set(i18n("terracotta.status.host_ok"));
progressProperty.set(1);
VBox code = new VBox(4);
code.setAlignment(Pos.CENTER);
{
Label desc = new Label(i18n("terracotta.status.host_ok.code"));
{
ClipboardContent cp = new ClipboardContent();
cp.putString(cs);
Clipboard.getSystemClipboard().setContent(cp);
}
// FIXME: The implementation to display Room Code is ambiguous. Consider using a clearer JavaFX Element in the future.
TextField label = new TextField(cs);
label.setEditable(false);
label.setFocusTraversable(false);
label.setAlignment(Pos.CENTER);
label.setStyle("-fx-background-color: transparent; -fx-border-color: transparent;");
VBox.setMargin(label, new Insets(10, 0, 10, 0));
label.setScaleX(1.8);
label.setScaleY(1.8);
holder.add(FXUtils.onWeakChange(label.selectedTextProperty(), string -> {
if (string != null && !string.isEmpty() && !cs.equals(string)) {
label.selectAll();
}
}));
code.getChildren().setAll(desc, label);
}
FXUtils.onClicked(code, () -> FXUtils.copyText(cs));
LineButton copy = LineButton.of();
copy.setLeftIcon(SVG.CONTENT_COPY);
copy.setTitle(i18n("terracotta.status.host_ok.code.copy"));
copy.setSubtitle(i18n("terracotta.status.host_ok.code.desc"));
FXUtils.onClicked(copy, () -> FXUtils.copyText(cs));
LineButton back = LineButton.of();
back.setLeftIcon(SVG.ARROW_BACK);
back.setTitle(i18n("terracotta.back"));
back.setSubtitle(i18n("terracotta.status.host_ok.back"));
FXUtils.onClicked(back, () -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles()));
}
} else if (state instanceof TerracottaState.GuestStarting) {
statusProperty.set(i18n("terracotta.status.guest_starting"));
progressProperty.set(-1);
LineButton room = LineButton.of();
room.setLeftIcon(SVG.ARROW_BACK);
room.setTitle(i18n("terracotta.back"));
room.setSubtitle(i18n("terracotta.status.guest_starting.back"));
FXUtils.onClicked(room, () -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
nodesProperty.setAll(room);
} else if (state instanceof TerracottaState.GuestOK guestOK) {
if (guestOK.isForkOf(legacyState)) {
((PlayerProfileUI) nodesProperty.get(nodesProperty.size() - 1)).updateProfiles(guestOK.getProfiles());
return;
} else {
statusProperty.set(i18n("terracotta.status.guest_ok"));
progressProperty.set(1);
LineButton tutorial = LineButton.of();
tutorial.setTitle(i18n("terracotta.status.guest_ok.title"));
tutorial.setSubtitle(i18n("terracotta.status.guest_ok.desc", guestOK.getUrl()));
LineButton back = LineButton.of();
back.setLeftIcon(SVG.ARROW_BACK);
back.setTitle(i18n("terracotta.back"));
back.setSubtitle(i18n("terracotta.status.guest_ok.back"));
FXUtils.onClicked(back, () -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
nodesProperty.setAll(tutorial, back, new PlayerProfileUI(guestOK.getProfiles()));
}
} else if (state instanceof TerracottaState.Exception exception) {
statusProperty.set(i18n("terracotta.status.exception.desc." + exception.getType().name().toLowerCase(Locale.ROOT)));
progressProperty.set(1);
nodesProperty.setAll();
LineButton back = LineButton.of();
back.setLeftIcon(SVG.ARROW_BACK);
back.setTitle(i18n("terracotta.back"));
back.setSubtitle(i18n("terracotta.status.exception.back"));
FXUtils.onClicked(back, () -> {
TerracottaState.Waiting s = TerracottaManager.setWaiting();
if (s != null) {
UI_STATE.set(s);
}
});
SpinnerPane exportLog = new SpinnerPane();
LineButton exportLogInner = LineButton.of();
exportLogInner.setLeftIcon(SVG.OUTPUT);
exportLogInner.setTitle(i18n("terracotta.export_log"));
exportLogInner.setSubtitle(i18n("terracotta.export_log.desc"));
exportLog.setContent(exportLogInner);
exportLog.getProperties().put("ComponentList.noPadding", true);
// FIXME: SpinnerPane loses its content width in loading state.
exportLog.minHeightProperty().bind(back.heightProperty());
FXUtils.onClicked(exportLogInner, () -> {
exportLog.setLoading(true);
TerracottaManager.exportLogs().thenAcceptAsync(Schedulers.io(), data -> {
if (data == null || data.isEmpty()) {
return;
}
Path path = Path.of("terracotta-log-" + LocalDateTime.now().format(
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")
) + ".zip").toAbsolutePath();
try (Zipper zipper = new Zipper(path)) {
zipper.putTextFile(data, StandardCharsets.UTF_8, "terracotta.log");
try (OutputStream os = zipper.putStream("hmcl-latest.log")) {
Logger.LOG.exportLogs(os);
}
}
FXUtils.showFileInExplorer(path);
}).thenRunAsync(
() -> Thread.sleep(3000)
).whenComplete(
Schedulers.javafx(),
e -> exportLog.setLoading(false)
).start();
});
nodesProperty.setAll(back, exportLog);
} else if (state instanceof TerracottaState.Fatal fatal) {
String message = i18n("terracotta.status.fatal." + fatal.getType().name().toLowerCase(Locale.ROOT));
statusProperty.set(message);
progressProperty.set(1);
if (fatal.isRecoverable()) {
LineButton retry = LineButton.of();
retry.setLeftIcon(SVG.RESTORE);
retry.setTitle(i18n("terracotta.status.fatal.retry"));
retry.setSubtitle(message);
FXUtils.onClicked(retry, () -> {
TerracottaState s = TerracottaManager.recover(null);
if (s != null) {
UI_STATE.set(s);
}
});
if (fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
nodesProperty.setAll(retry, getThirdPartyDownloadNodes());
} else {
nodesProperty.setAll(retry);
}
} else {
nodesProperty.setAll();
}
} else {
throw new AssertionError(state.getClass().getName());
}
ComponentList components = new ComponentList();
{
VBox statusPane = new VBox(8);
VBox.setMargin(statusPane, new Insets(0, 0, 0, 4));
{
Label status = new Label();
status.textProperty().bind(statusProperty);
JFXProgressBar progress = new JFXProgressBar();
progress.progressProperty().bind(progressProperty);
progress.setMaxWidth(Double.MAX_VALUE);
statusPane.getChildren().setAll(status, progress);
}
ObservableList<Node> children = components.getContent();
children.add(statusPane);
children.addAll(nodesProperty);
}
transition.setContent(components, ContainerAnimations.SWIPE_LEFT_FADE_SHORT);
};
listener.changed(UI_STATE, null, UI_STATE.get());
holder.add(listener);
UI_STATE.addListener(new WeakChangeListener<>(listener));
VBox content = new VBox(10);
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("terracotta.status")), transition);
content.setPadding(new Insets(10));
content.setFillWidth(true);
ScrollPane scrollPane = new ScrollPane(content);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setFitToWidth(true);
getChildren().setAll(scrollPane);
}
private ComponentList getThirdPartyDownloadNodes() {
ComponentSublist locals = new ComponentSublist();
locals.setComponentPadding(false);
LineButton header = LineButton.of(false);
header.setLeftImage(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
header.setTitle(i18n("terracotta.from_local.title"));
header.setSubtitle(i18n("terracotta.from_local.desc"));
locals.setHeaderLeft(header);
for (TerracottaMetadata.Link link : TerracottaMetadata.getPackageLinks()) {
HBox node = new HBox();
node.setAlignment(Pos.CENTER_LEFT);
node.setPadding(new Insets(10, 16, 10, 16));
Label description = new Label(link.description().getText(I18n.getLocale().getCandidateLocales()));
HBox placeholder = new HBox();
HBox.setHgrow(placeholder, Priority.ALWAYS);
Node icon = SVG.OPEN_IN_NEW.createIcon(Theme.blackFill(), 16);
node.getChildren().setAll(description, placeholder, icon);
String url = link.link();
RipplerContainer container = new RipplerContainer(node);
container.setOnMouseClicked(ev -> Controllers.dialog(
i18n("terracotta.from_local.guide", TerracottaMetadata.PACKAGE_NAME),
i18n("message.info"), MessageDialogPane.MessageType.INFO,
() -> FXUtils.openLink(url)
));
container.getProperties().put("ComponentList.noPadding", true);
locals.getContent().add(container);
}
return locals;
}
private static final class LineButton extends RipplerContainer {
private final WeakListenerHolder holder = new WeakListenerHolder();
private final TwoLineListItem middle = new TwoLineListItem();
private final ObjectProperty<Node> left = new SimpleObjectProperty<>();
private final ObjectProperty<Node> right = new SimpleObjectProperty<>();
public static LineButton of() {
return of(true);
}
public static LineButton of(boolean padding) {
HBox container = new HBox();
if (padding) {
container.setPadding(new Insets(10, 16, 10, 16));
}
container.setAlignment(Pos.CENTER_LEFT);
container.setCursor(Cursor.HAND);
container.setSpacing(16);
LineButton button = new LineButton(container);
VBox spacing = new VBox();
HBox.setHgrow(spacing, Priority.ALWAYS);
button.holder.add(FXUtils.observeWeak(() -> {
List<Node> nodes = new ArrayList<>(4);
Node left = button.left.get();
if (left != null) {
nodes.add(left);
}
nodes.add(button.middle);
nodes.add(spacing);
Node right = button.right.get();
if (right != null) {
nodes.add(right);
}
container.getChildren().setAll(nodes);
}, button.middle.titleProperty(), button.middle.subtitleProperty(), button.left, button.right));
button.getProperties().put("ComponentList.noPadding", true);
return button;
}
private LineButton(Node container) {
super(container);
}
public void setTitle(String title) {
this.middle.setTitle(title);
}
public void setSubtitle(String subtitle) {
this.middle.setSubtitle(subtitle);
}
public void setLeftImage(Image left) {
this.left.set(new ImageView(left));
}
public void setLeftIcon(SVG left) {
this.left.set(left.createIcon(Theme.blackFill(), 28));
}
public void setRightIcon(SVG right) {
this.right.set(right.createIcon(Theme.blackFill(), 28));
}
}
private static final class PlayerProfileUI extends VBox {
private final TransitionPane transition;
public PlayerProfileUI(List<TerracottaProfile> profiles) {
super(8);
VBox.setMargin(this, new Insets(0, 0, 0, 4));
{
Label status = new Label();
status.setText(i18n("terracotta.player_list"));
transition = new TransitionPane();
getChildren().setAll(status, transition);
updateProfiles(profiles);
}
}
private void updateProfiles(List<TerracottaProfile> profiles) {
VBox pane = new VBox(8);
for (TerracottaProfile profile : profiles) {
TwoLineListItem item = new TwoLineListItem();
item.setTitle(profile.getName());
item.setSubtitle(profile.getVendor());
item.getTags().setAll(TwoLineListItem.createTagLabel(
i18n("terracotta.player_kind." + profile.getType().name().toLowerCase(Locale.ROOT)))
);
pane.getChildren().add(item);
}
this.transition.setContent(pane, ContainerAnimations.SWIPE_LEFT_FADE_SHORT);
}
}
}
@@ -0,0 +1,92 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.terracotta;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.PageAware;
import org.jackhuang.hmcl.ui.construct.TabHeader;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import java.util.Locale;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("terracotta.terracotta")));
private final TabHeader tab;
private final TabHeader.Tab<TerracottaControllerPage> statusPage = new TabHeader.Tab<>("statusPage");
private final TransitionPane transitionPane = new TransitionPane();
public TerracottaPage() {
statusPage.setNodeSupplier(TerracottaControllerPage::new);
tab = new TabHeader(statusPage);
tab.select(statusPage);
transitionPane.setContent(statusPage.getNode(), ContainerAnimations.NONE);
FXUtils.onChange(tab.getSelectionModel().selectedItemProperty(), newValue -> {
transitionPane.setContent(newValue.getNode(), ContainerAnimations.FADE);
});
AdvancedListItem chatItem = new AdvancedListItem();
chatItem.setLeftGraphic(wrap(SVG.CHAT));
chatItem.setActionButtonVisible(false);
chatItem.setTitle(i18n("chat"));
chatItem.setOnAction(e -> FXUtils.openLink(Metadata.GROUPS_URL));
AdvancedListItem easytierItem = new AdvancedListItem();
easytierItem.setLeftGraphic(wrap(SVG.HOST));
easytierItem.setActionButtonVisible(false);
easytierItem.setTitle(i18n("terracotta.easytier"));
easytierItem.setOnAction(e -> FXUtils.openLink("https://easytier.cn/"));
AdvancedListBox sideBar = new AdvancedListBox()
.addNavigationDrawerTab(tab, statusPage, i18n("terracotta.status"), SVG.TUNE)
.startCategory(i18n("help").toUpperCase(Locale.ROOT))
.add(chatItem)
.add(easytierItem);
FXUtils.setLimitWidth(sideBar, 200);
setLeft(sideBar);
setCenter(transitionPane);
}
@Override
public void onPageShown() {
tab.onPageShown();
}
@Override
public void onPageHidden() {
tab.onPageHidden();
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
}
@@ -27,6 +27,7 @@ import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SkinBase;
import javafx.scene.image.Image;
@@ -45,6 +46,7 @@ import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
@@ -64,6 +66,7 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.SoftReference;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -81,7 +84,7 @@ import static org.jackhuang.hmcl.util.StringUtils.isNotBlank;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
class ModListPageSkin extends SkinBase<ModListPage> {
final class ModListPageSkin extends SkinBase<ModListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
@@ -279,6 +282,85 @@ class ModListPageSkin extends SkinBase<ModListPage> {
}
}
private static Task<Image> loadModIcon(LocalModFile modFile, int size) {
return Task.supplyAsync(() -> {
List<String> iconPaths = new ArrayList<>();
if (StringUtils.isNotBlank(modFile.getLogoPath())) {
iconPaths.add(modFile.getLogoPath());
}
iconPaths.addAll(List.of(
"icon.png",
"logo.png",
"mod_logo.png",
"pack.png",
"logoFile.png",
"assets/icon.png",
"assets/logo.png",
"assets/mod_icon.png",
"assets/mod_logo.png",
"META-INF/icon.png",
"META-INF/logo.png",
"META-INF/mod_icon.png",
"textures/icon.png",
"textures/logo.png",
"textures/mod_icon.png",
"resources/icon.png",
"resources/logo.png",
"resources/mod_icon.png"
));
String modId = modFile.getId();
if (StringUtils.isNotBlank(modId)) {
iconPaths.addAll(List.of(
"assets/" + modId + "/icon.png",
"assets/" + modId + "/logo.png",
"assets/" + modId.replace("-", "") + "/icon.png",
"assets/" + modId.replace("-", "") + "/logo.png",
modId + ".png",
modId + "-logo.png",
modId + "-icon.png",
modId + "_logo.png",
modId + "_icon.png",
"textures/" + modId + "/icon.png",
"textures/" + modId + "/logo.png",
"resources/" + modId + "/icon.png",
"resources/" + modId + "/logo.png"
));
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(modFile.getFile())) {
for (String path : iconPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(iconPath, size, size, true, true);
if (!image.isError() &&
image.getWidth() > 0 &&
image.getHeight() > 0 &&
Math.abs(image.getWidth() - image.getHeight()) < 1) {
return image;
}
}
}
} catch (Exception e) {
LOG.warning("Failed to load mod icons", e);
}
VersionIconType defaultIcon = switch (modFile.getModLoaderType()) {
case FORGE -> VersionIconType.FORGE;
case NEO_FORGED -> VersionIconType.NEO_FORGE;
case FABRIC -> VersionIconType.FABRIC;
case QUILT -> VersionIconType.QUILT;
case LITE_LOADER -> VersionIconType.CHICKEN;
case CLEANROOM -> VersionIconType.CLEANROOM;
default -> VersionIconType.COMMAND;
};
return defaultIcon.getIcon(size);
});
}
static class ModInfoObject extends RecursiveTreeObject<ModInfoObject> implements Comparable<ModInfoObject> {
private final BooleanProperty active;
private final LocalModFile localModFile;
@@ -286,22 +368,25 @@ class ModListPageSkin extends SkinBase<ModListPage> {
private final String message;
private final ModTranslations.Mod mod;
private SoftReference<Image> iconCache;
ModInfoObject(LocalModFile localModFile) {
this.localModFile = localModFile;
this.active = localModFile.activeProperty();
StringBuilder title = new StringBuilder(localModFile.getName());
if (isNotBlank(localModFile.getVersion()))
title.append(" ").append(localModFile.getVersion());
this.title = title.toString();
StringBuilder message = new StringBuilder(localModFile.getFileName());
if (isNotBlank(localModFile.getGameVersion()))
message.append(", ").append(i18n("mods.game.version")).append(": ").append(localModFile.getGameVersion());
if (isNotBlank(localModFile.getAuthors()))
message.append(", ").append(i18n("archive.author")).append(": ").append(localModFile.getAuthors());
this.message = message.toString();
this.title = localModFile.getName();
List<String> parts = new ArrayList<>();
if (isNotBlank(localModFile.getId())) {
parts.add(localModFile.getId());
}
if (isNotBlank(localModFile.getVersion())) {
parts.add(localModFile.getVersion());
}
if (isNotBlank(localModFile.getGameVersion())) {
parts.add(i18n("game.version") + ": " + localModFile.getGameVersion());
}
this.message = String.join(", ", parts);
this.mod = ModTranslations.MOD.getModById(localModFile.getId());
}
@@ -334,77 +419,45 @@ class ModListPageSkin extends SkinBase<ModListPage> {
titleContainer.setSpacing(8);
ImageView imageView = new ImageView();
Task.supplyAsync(() -> {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(modInfo.getModInfo().getFile())) {
String logoPath = modInfo.getModInfo().getLogoPath();
if (StringUtils.isNotBlank(logoPath)) {
Path iconPath = fs.getPath(logoPath);
if (Files.exists(iconPath)) {
try {
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
if (!image.isError() && image.getWidth() == image.getHeight())
return image;
} catch (Throwable e) {
LOG.warning("Failed to load image " + logoPath, e);
}
}
}
List<String> defaultPaths = new ArrayList<>(Arrays.asList(
"icon.png",
"logo.png",
"mod_logo.png",
"pack.png",
"logoFile.png"
));
String id = modInfo.getModInfo().getId();
if (StringUtils.isNotBlank(id)) {
defaultPaths.addAll(Arrays.asList(
"assets/" + id + "/icon.png",
"assets/" + id.replace("-", "") + "/icon.png",
id + ".png",
id + "-logo.png",
id + "-icon.png",
id + "_logo.png",
id + "_icon.png"
));
}
for (String path : defaultPaths) {
Path iconPath = fs.getPath(path);
if (Files.exists(iconPath)) {
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
if (!image.isError() && image.getWidth() == image.getHeight())
return image;
}
}
} catch (Exception e) {
LOG.warning("Failed to load icon", e);
}
return null;
}).whenComplete(Schedulers.javafx(), (image, exception) -> {
if (image != null) {
imageView.setImage(image);
} else {
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/command.png", 40, 40, true, true));
}
}).start();
FXUtils.limitSize(imageView, 40, 40);
loadModIcon(modInfo.getModInfo(), 40)
.whenComplete(Schedulers.javafx(), (image, exception) -> {
imageView.setImage(image);
}).start();
TwoLineListItem title = new TwoLineListItem();
title.setTitle(modInfo.getModInfo().getName());
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
title.addTag(modInfo.getModInfo().getVersion());
if (modInfo.getMod() != null) {
title.addTag(modInfo.getMod().getDisplayName());
}
title.setSubtitle(FileUtils.getName(modInfo.getModInfo().getFile()));
List<String> subtitleParts = new ArrayList<>();
subtitleParts.add(FileUtils.getName(modInfo.getModInfo().getFile()));
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
subtitleParts.add(modInfo.getModInfo().getGameVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
subtitleParts.add(modInfo.getModInfo().getVersion());
}
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
subtitleParts.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
}
title.setSubtitle(String.join(", ", subtitleParts));
titleContainer.getChildren().setAll(FXUtils.limitingSize(imageView, 40, 40), title);
setHeading(titleContainer);
Label description = new Label(modInfo.getModInfo().getDescription().toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
setBody(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
setBody(descriptionPane);
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
for (Pair<String, ? extends RemoteModRepository> item : Arrays.asList(
@@ -512,6 +565,7 @@ class ModListPageSkin extends SkinBase<ModListPage> {
final class ModInfoListCell extends MDListCell<ModInfoObject> {
JFXCheckBox checkBox = new JFXCheckBox();
ImageView imageView = new ImageView();
TwoLineListItem content = new TwoLineListItem();
JFXButton restoreButton = new JFXButton();
JFXButton infoButton = new JFXButton();
@@ -528,6 +582,11 @@ class ModListPageSkin extends SkinBase<ModListPage> {
content.setMouseTransparent(true);
setSelectable();
imageView.setFitWidth(24);
imageView.setFitHeight(24);
imageView.setPreserveRatio(true);
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/command.png", 24, 24, true, true));
restoreButton.getStyleClass().add("toggle-icon4");
restoreButton.setGraphic(FXUtils.limitingSize(SVG.RESTORE.createIcon(Theme.blackFill(), 24), 24, 24));
@@ -539,7 +598,7 @@ class ModListPageSkin extends SkinBase<ModListPage> {
infoButton.getStyleClass().add("toggle-icon4");
infoButton.setGraphic(FXUtils.limitingSize(SVG.INFO.createIcon(Theme.blackFill(), 24), 24, 24));
container.getChildren().setAll(checkBox, content, restoreButton, revealButton, infoButton);
container.getChildren().setAll(checkBox, imageView, content, restoreButton, revealButton, infoButton);
StackPane.setMargin(container, new Insets(8));
getContainer().getChildren().setAll(container);
@@ -548,6 +607,19 @@ class ModListPageSkin extends SkinBase<ModListPage> {
@Override
protected void updateControl(ModInfoObject dataItem, boolean empty) {
if (empty) return;
SoftReference<Image> iconCache = dataItem.iconCache;
Image icon;
if (iconCache != null && (icon = iconCache.get()) != null) {
imageView.setImage(icon);
} else {
loadModIcon(dataItem.getModInfo(), 24)
.whenComplete(Schedulers.javafx(), (image, exception) -> {
dataItem.iconCache = new SoftReference<>(image);
imageView.setImage(image);
}).start();
}
content.setTitle(dataItem.getTitle());
content.getTags().clear();
switch (dataItem.getModInfo().getModLoaderType()) {
@@ -571,9 +643,14 @@ class ModListPageSkin extends SkinBase<ModListPage> {
break;
}
if (dataItem.getMod() != null && I18n.isUseChinese()) {
content.addTag(dataItem.getMod().getDisplayName());
if (isNotBlank(dataItem.getSubtitle())) {
content.setSubtitle(dataItem.getSubtitle() + ", " + dataItem.getMod().getDisplayName());
} else {
content.setSubtitle(dataItem.getMod().getDisplayName());
}
} else {
content.setSubtitle(dataItem.getSubtitle());
}
content.setSubtitle(dataItem.getSubtitle());
if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
@@ -198,7 +198,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
copyGlobalPane.setLeft(label);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
JFXButton copyAll = new JFXButton(i18n("settings.game.copy_global.copy_all"));
JFXButton copyAll = FXUtils.newBorderButton(i18n("settings.game.copy_global.copy_all"));
copyAll.disableProperty().bind(modpack);
copyGlobalPane.setRight(copyAll);
copyAll.setOnAction(e -> Controllers.confirm(i18n("settings.game.copy_global.copy_all.confirm"), null, () -> {
@@ -209,7 +209,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
PropertyUtils.copyProperties(profile.getGlobal(), lastVersionSetting, name -> !ignored.contains(name));
}, null));
copyAll.getStyleClass().add("jfx-button-border");
BorderPane.setAlignment(copyAll, Pos.CENTER_RIGHT);
}
@@ -456,7 +455,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
showAdvancedSettingPane.setLeft(label);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
JFXButton button = new JFXButton(i18n("settings.advanced.modify"));
JFXButton button = FXUtils.newBorderButton(i18n("settings.advanced.modify"));
button.setOnAction(e -> {
if (lastVersionSetting != null) {
if (advancedVersionSettingPage == null)
@@ -465,7 +464,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
Controllers.navigate(advancedVersionSettingPage);
}
});
button.getStyleClass().add("jfx-button-border");
showAdvancedSettingPane.setRight(button);
}
@@ -29,7 +29,7 @@ import java.util.Optional;
public final class RemoteVersion {
public static RemoteVersion fetch(UpdateChannel channel, String url) throws IOException {
public static RemoteVersion fetch(UpdateChannel channel, boolean preview, String url) throws IOException {
try {
JsonObject response = JsonUtils.fromNonNullJson(NetworkUtils.doGet(url), JsonObject.class);
String version = Optional.ofNullable(response.get("version")).map(JsonElement::getAsString).orElseThrow(() -> new IOException("version is missing"));
@@ -37,7 +37,7 @@ public final class RemoteVersion {
String jarHash = Optional.ofNullable(response.get("jarsha1")).map(JsonElement::getAsString).orElse(null);
boolean force = Optional.ofNullable(response.get("force")).map(JsonElement::getAsBoolean).orElse(false);
if (jarUrl != null && jarHash != null) {
return new RemoteVersion(channel, version, jarUrl, Type.JAR, new IntegrityCheck("SHA-1", jarHash), force);
return new RemoteVersion(channel, version, jarUrl, Type.JAR, new IntegrityCheck("SHA-1", jarHash), preview, force);
} else {
throw new IOException("No download url is available");
}
@@ -51,14 +51,16 @@ public final class RemoteVersion {
private final String url;
private final Type type;
private final IntegrityCheck integrityCheck;
private final boolean preview;
private final boolean force;
public RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck, boolean force) {
public RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck, boolean preview, boolean force) {
this.channel = channel;
this.version = version;
this.url = url;
this.type = type;
this.integrityCheck = integrityCheck;
this.preview = preview;
this.force = force;
}
@@ -82,6 +84,10 @@ public final class RemoteVersion {
return integrityCheck;
}
public boolean isPreview() {
return preview;
}
public boolean isForce() {
return force;
}
@@ -27,14 +27,15 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.io.IOException;
import java.util.LinkedHashMap;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.Lang.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
public final class UpdateChecker {
private UpdateChecker() {}
private UpdateChecker() {
}
private static final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>();
private static final BooleanBinding outdated = Bindings.createBooleanBinding(
@@ -55,7 +56,7 @@ public final class UpdateChecker {
private static final ReadOnlyBooleanWrapper checkingUpdate = new ReadOnlyBooleanWrapper(false);
public static void init() {
requestCheckUpdate(UpdateChannel.getChannel());
requestCheckUpdate(UpdateChannel.getChannel(), config().isAcceptPreviewUpdate());
}
public static RemoteVersion getLatestVersion() {
@@ -82,16 +83,17 @@ public final class UpdateChecker {
return checkingUpdate.getReadOnlyProperty();
}
private static RemoteVersion checkUpdate(UpdateChannel channel) throws IOException {
private static RemoteVersion checkUpdate(UpdateChannel channel, boolean preview) throws IOException {
if (!IntegrityChecker.DISABLE_SELF_INTEGRITY_CHECK && !IntegrityChecker.isSelfVerified()) {
throw new IOException("Self verification failed");
}
String url = NetworkUtils.withQuery(Metadata.HMCL_UPDATE_URL, mapOf(
pair("version", Metadata.VERSION),
pair("channel", channel.channelName)));
var query = new LinkedHashMap<String, String>();
query.put("version", Metadata.VERSION);
query.put("channel", preview ? channel.channelName + "-preview" : channel.channelName);
return RemoteVersion.fetch(channel, url);
String url = NetworkUtils.withQuery(Metadata.HMCL_UPDATE_URL, query);
return RemoteVersion.fetch(channel, preview, url);
}
private static boolean isDevelopmentVersion(String version) {
@@ -99,7 +101,7 @@ public final class UpdateChecker {
version.contains("SNAPSHOT"); // eg. 3.5.SNAPSHOT
}
public static void requestCheckUpdate(UpdateChannel channel) {
public static void requestCheckUpdate(UpdateChannel channel, boolean preview) {
Platform.runLater(() -> {
if (isCheckingUpdate())
return;
@@ -108,8 +110,8 @@ public final class UpdateChecker {
thread(() -> {
RemoteVersion result = null;
try {
result = checkUpdate(channel);
LOG.info("Latest version (" + channel + ") is " + result);
result = checkUpdate(channel, preview);
LOG.info("Latest version (" + channel + ", preview=" + preview + ") is " + result);
} catch (IOException e) {
LOG.warning("Failed to check for update", e);
}
@@ -47,6 +47,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.Platform;
import javax.swing.*;
@@ -62,7 +63,6 @@ import java.util.List;
import java.util.*;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.jar.Manifest;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toSet;
@@ -253,14 +253,7 @@ public final class SelfDependencyPatcher {
.map(DependencyDescriptor::localPath)
.toArray(Path[]::new);
String addOpens = null;
try (InputStream input = SelfDependencyPatcher.class.getResourceAsStream("/META-INF/MANIFEST.MF")) {
if (input != null)
addOpens = new Manifest(input).getMainAttributes().getValue("Add-Opens");
} catch (IOException e) {
LOG.warning("Failed to read MANIFEST.MF file", e);
}
String addOpens = JarUtils.getAttribute("hmcl.add-opens", null);
JavaFXPatcher.patch(modules, jars, addOpens != null ? addOpens.split(" ") : new String[0]);
}
@@ -19,7 +19,6 @@ package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
@@ -34,7 +33,7 @@ public final class I18n {
private I18n() {
}
private static volatile SupportedLocale locale = Locales.DEFAULT;
private static volatile SupportedLocale locale = SupportedLocale.DEFAULT;
public static void setLocale(SupportedLocale locale) {
I18n.locale = locale;
@@ -44,6 +43,10 @@ public final class I18n {
return locale;
}
public static boolean isUpsideDown() {
return LocaleUtils.getScript(locale.getLocale()).equals("Qabs");
}
public static boolean isUseChinese() {
return LocaleUtils.isChinese(locale.getLocale());
}
@@ -71,6 +74,11 @@ public final class I18n {
else
return WenyanUtils.translateGenericVersion(version.getSelfVersion());
}
if (LocaleUtils.isEnglish(locale.getLocale()) && "Qabs".equals(LocaleUtils.getScript(locale.getLocale()))) {
return UpsideDownUtils.translate(version.getSelfVersion());
}
return version.getSelfVersion();
}
@@ -1,273 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.i18n;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class Locales {
private Locales() {
}
public static final SupportedLocale DEFAULT;
static {
String language = System.getenv("HMCL_LANGUAGE");
DEFAULT = new SupportedLocale(true, "def",
StringUtils.isBlank(language) ? LocaleUtils.SYSTEM_DEFAULT : Locale.forLanguageTag(language));
}
/**
* English
*/
public static final SupportedLocale EN = new SupportedLocale("en");
/**
* Spanish
*/
public static final SupportedLocale ES = new SupportedLocale("es");
/**
* Russian
*/
public static final SupportedLocale RU = new SupportedLocale("ru");
/**
* Ukrainian
*/
public static final SupportedLocale UK = new SupportedLocale("uk");
/**
* Japanese
*/
public static final SupportedLocale JA = new SupportedLocale("ja");
/**
* Chinese (Simplified)
*/
public static final SupportedLocale ZH_HANS = new SupportedLocale("zh_CN", LocaleUtils.LOCALE_ZH_HANS);
/**
* Chinese (Traditional)
*/
public static final SupportedLocale ZH_HANT = new SupportedLocale("zh", LocaleUtils.LOCALE_ZH_HANT);
/**
* Wenyan (Classical Chinese)
*/
public static final SupportedLocale WENYAN = new SupportedLocale("lzh");
public static final List<SupportedLocale> LOCALES = List.of(DEFAULT, EN, ES, JA, RU, UK, ZH_HANS, ZH_HANT, WENYAN);
public static SupportedLocale getLocaleByName(String name) {
if (name == null) return DEFAULT;
for (SupportedLocale locale : LOCALES) {
if (locale.getName().equalsIgnoreCase(name))
return locale;
}
return DEFAULT;
}
@JsonAdapter(SupportedLocale.TypeAdapter.class)
public static final class SupportedLocale {
private final boolean isDefault;
private final String name;
private final Locale locale;
private ResourceBundle resourceBundle;
private DateTimeFormatter dateTimeFormatter;
private List<Locale> candidateLocales;
SupportedLocale(boolean isDefault, String name, Locale locale) {
this.isDefault = isDefault;
this.name = name;
this.locale = locale;
}
SupportedLocale(String name) {
this(false, name, Locale.forLanguageTag(name));
}
SupportedLocale(String name, Locale locale) {
this(false, name, locale);
}
public boolean isDefault() {
return isDefault;
}
public String getName() {
return name;
}
public Locale getLocale() {
return locale;
}
public String getDisplayName(SupportedLocale inLocale) {
if (isDefault()) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
}
Locale inJavaLocale = inLocale.getLocale();
if (LocaleUtils.isISO3Language(inJavaLocale.getLanguage())) {
String iso1 = LocaleUtils.getISO1Language(inJavaLocale);
if (LocaleUtils.isISO1Language(iso1)) {
Locale.Builder builder = new Locale.Builder()
.setLocale(inJavaLocale)
.setLanguage(iso1);
if (inJavaLocale.getScript().isEmpty())
builder.setScript(LocaleUtils.getScript(inJavaLocale));
inJavaLocale = builder.build();
}
}
if (this.locale.getLanguage().equals("lzh")) {
if (inJavaLocale.getLanguage().equals("zh"))
return "文言";
String name = locale.getDisplayName(inJavaLocale);
return name.equals("lzh") || name.equals("Literary Chinese")
? "Chinese (Classical)"
: name;
}
return locale.getDisplayName(inJavaLocale);
}
public ResourceBundle getResourceBundle() {
ResourceBundle bundle = resourceBundle;
if (resourceBundle == null)
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
DefaultResourceBundleControl.INSTANCE);
return bundle;
}
public List<Locale> getCandidateLocales() {
if (candidateLocales == null)
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
return candidateLocales;
}
public String i18n(String key, Object... formatArgs) {
try {
return String.format(getResourceBundle().getString(key), formatArgs);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
} catch (IllegalFormatException e) {
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
}
return key + Arrays.toString(formatArgs);
}
public String i18n(String key) {
try {
return getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
return key;
}
}
public String formatDateTime(TemporalAccessor time) {
DateTimeFormatter formatter = dateTimeFormatter;
if (formatter == null) {
if (locale.getLanguage().equals("lzh"))
return WenyanUtils.formatDateTime(time);
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
.withZone(ZoneId.systemDefault());
}
return formatter.format(time);
}
public String getFcMatchPattern() {
String language = locale.getLanguage();
String region = locale.getCountry();
if (LocaleUtils.isEnglish(locale))
return "";
if (LocaleUtils.isChinese(locale)) {
String lang;
String charset;
String script = LocaleUtils.getScript(locale);
switch (script) {
case "Hans":
lang = region.equals("SG") || region.equals("MY")
? "zh-" + region
: "zh-CN";
charset = "0x6e38,0x620f";
break;
case "Hant":
lang = region.equals("HK") || region.equals("MO")
? "zh-" + region
: "zh-TW";
charset = "0x904a,0x6232";
break;
default:
return "";
}
return ":lang=" + lang + ":charset=" + charset;
}
return region.isEmpty() ? language : language + "-" + region;
}
public boolean isSameLanguage(SupportedLocale other) {
return LocaleUtils.getISO1Language(this.getLocale())
.equals(LocaleUtils.getISO1Language(other.getLocale()));
}
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
@Override
public void write(JsonWriter out, SupportedLocale value) throws IOException {
out.value(value.getName());
}
@Override
public SupportedLocale read(JsonReader in) throws IOException {
return getLocaleByName(in.nextString());
}
}
}
}
@@ -27,7 +27,7 @@ public final class MinecraftWiki {
private static final Pattern SNAPSHOT_PATTERN = Pattern.compile("^[0-9]{2}w[0-9]{2}.+$");
public static String getWikiLink(Locales.SupportedLocale locale, GameRemoteVersion version) {
public static String getWikiLink(SupportedLocale locale, GameRemoteVersion version) {
String wikiVersion = version.getSelfVersion();
var gameVersion = GameVersionNumber.asGameVersion(wikiVersion);
@@ -41,7 +41,7 @@ public final class MinecraftWiki {
translatedVersion = WenyanUtils.translateGameVersion(gameVersion);
if (translatedVersion.equals(gameVersion.toString()) || gameVersion instanceof GameVersionNumber.Old) {
return getWikiLink(Locales.ZH_HANT, version);
return getWikiLink(SupportedLocale.getLocale(LocaleUtils.LOCALE_ZH_HANT), version);
} else if (SNAPSHOT_PATTERN.matcher(wikiVersion).matches()) {
return locale.i18n("wiki.version.game.snapshot", translatedVersion);
} else {
@@ -0,0 +1,280 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.i18n;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
import java.io.InputStream;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@JsonAdapter(SupportedLocale.TypeAdapter.class)
public final class SupportedLocale {
public static final SupportedLocale DEFAULT = new SupportedLocale();
private static final ConcurrentMap<Locale, SupportedLocale> LOCALES = new ConcurrentHashMap<>();
public static List<SupportedLocale> getSupportedLocales() {
List<SupportedLocale> list = new ArrayList<>();
list.add(DEFAULT);
InputStream locales = SupportedLocale.class.getResourceAsStream("/assets/lang/languages.json");
if (locales != null) {
try (locales) {
list.addAll(JsonUtils.fromNonNullJsonFully(locales, JsonUtils.listTypeOf(SupportedLocale.class)));
} catch (Throwable e) {
LOG.warning("Failed to load languages.json", e);
}
}
return List.copyOf(list);
}
public static SupportedLocale getLocale(Locale locale) {
return LOCALES.computeIfAbsent(locale, SupportedLocale::new);
}
public static SupportedLocale getLocaleByName(String name) {
if (name == null || name.isBlank() || "def".equals(name) || "default".equals(name))
return DEFAULT;
return getLocale(Locale.forLanguageTag(name.trim().replace('_', '-')));
}
private final boolean isDefault;
private final String name;
private final Locale locale;
private ResourceBundle resourceBundle;
private ResourceBundle localeNamesBundle;
private DateTimeFormatter dateTimeFormatter;
private List<Locale> candidateLocales;
SupportedLocale() {
this.isDefault = true;
this.name = "def"; // TODO: Change to "default" after updating the Config format
String language = System.getenv("HMCL_LANGUAGE");
this.locale = StringUtils.isBlank(language)
? LocaleUtils.SYSTEM_DEFAULT
: Locale.forLanguageTag(language);
}
SupportedLocale(Locale locale) {
this.isDefault = false;
this.name = locale.toLanguageTag();
this.locale = locale;
}
public boolean isDefault() {
return isDefault;
}
public String getName() {
return name;
}
public Locale getLocale() {
return locale;
}
public String getDisplayName(SupportedLocale inLocale) {
if (isDefault()) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
}
Locale currentLocale = this.getLocale();
String language = currentLocale.getLanguage();
String subLanguage;
{
String parentLanguage = LocaleUtils.getParentLanguage(language);
if (parentLanguage != null) {
subLanguage = language;
language = parentLanguage;
} else {
subLanguage = "";
}
}
ResourceBundle localeNames = inLocale.getLocaleNamesBundle();
String languageDisplayName = language;
try {
languageDisplayName = localeNames.getString(language);
} catch (Throwable e) {
LOG.warning("Failed to get localized name for language " + language, e);
}
// Currently, HMCL does not support any locales with regions or variants, so they are not handled for now
List<String> subTags = Stream.of(subLanguage, currentLocale.getScript())
.filter(it -> !it.isEmpty())
.map(it -> {
try {
return localeNames.getString(it);
} catch (Throwable e) {
LOG.warning("Failed to get localized name of " + it, e);
}
return it;
}).toList();
return subTags.isEmpty()
? languageDisplayName
: languageDisplayName + " (" + String.join(", ", subTags) + ")";
}
public ResourceBundle getResourceBundle() {
ResourceBundle bundle = resourceBundle;
if (resourceBundle == null)
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
DefaultResourceBundleControl.INSTANCE);
return bundle;
}
public ResourceBundle getLocaleNamesBundle() {
ResourceBundle bundle = localeNamesBundle;
if (localeNamesBundle == null)
localeNamesBundle = bundle = ResourceBundle.getBundle("assets.lang.LocaleNames", locale,
DefaultResourceBundleControl.INSTANCE);
return bundle;
}
public List<Locale> getCandidateLocales() {
if (candidateLocales == null)
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
return candidateLocales;
}
public String i18n(String key, Object... formatArgs) {
try {
return String.format(getResourceBundle().getString(key), formatArgs);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
} catch (IllegalFormatException e) {
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
}
return key + Arrays.toString(formatArgs);
}
public String i18n(String key) {
try {
return getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
return key;
}
}
public String formatDateTime(TemporalAccessor time) {
DateTimeFormatter formatter = dateTimeFormatter;
if (formatter == null) {
if (LocaleUtils.isEnglish(locale) && "Qabs".equals(locale.getScript())) {
return UpsideDownUtils.formatDateTime(time);
}
if (locale.getLanguage().equals("lzh")) {
return WenyanUtils.formatDateTime(time);
}
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
.withZone(ZoneId.systemDefault());
}
return formatter.format(time);
}
public String getFcMatchPattern() {
String language = locale.getLanguage();
String region = locale.getCountry();
if (LocaleUtils.isEnglish(locale))
return "";
if (LocaleUtils.isChinese(locale)) {
String lang;
String charset;
String script = LocaleUtils.getScript(locale);
switch (script) {
case "Hans":
lang = region.equals("SG") || region.equals("MY")
? "zh-" + region
: "zh-CN";
charset = "0x6e38,0x620f";
break;
case "Hant":
lang = region.equals("HK") || region.equals("MO")
? "zh-" + region
: "zh-TW";
charset = "0x904a,0x6232";
break;
default:
return "";
}
return ":lang=" + lang + ":charset=" + charset;
}
return region.isEmpty() ? language : language + "-" + region;
}
public boolean isSameLanguage(SupportedLocale other) {
return LocaleUtils.getRootLanguage(this.getLocale())
.equals(LocaleUtils.getRootLanguage(other.getLocale()));
}
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
@Override
public void write(JsonWriter out, SupportedLocale value) throws IOException {
out.value(value.getName());
}
@Override
public SupportedLocale read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL)
return DEFAULT;
String language = in.nextString();
return getLocaleByName(switch (language) {
// TODO: Remove these compatibility codes after updating the Config format
case "zh_CN" -> "zh-Hans"; // For compatibility
case "zh" -> "zh-Hant"; // For compatibility
default -> language;
});
}
}
}
@@ -0,0 +1,77 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.i18n;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class UpsideDownUtils {
private static final Map<Integer, Integer> MAPPER = loadMap();
private static Map<Integer, Integer> loadMap() {
var map = new LinkedHashMap<Integer, Integer>();
InputStream inputStream = UpsideDownUtils.class.getResourceAsStream("/assets/lang/upside_down.txt");
if (inputStream != null) {
try (inputStream) {
new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(line -> {
if (line.isBlank() || line.startsWith("#"))
return;
if (line.length() != 2) {
LOG.warning("Invalid line: " + line);
return;
}
map.put((int) line.charAt(0), (int) line.charAt(1));
});
} catch (IOException e) {
LOG.warning("Failed to load upside_down.txt", e);
}
} else {
LOG.warning("upside_down.txt not found");
}
return Collections.unmodifiableMap(map);
}
public static String translate(String str) {
StringBuilder builder = new StringBuilder(str.length());
str.codePoints().forEach(ch -> builder.appendCodePoint(MAPPER.getOrDefault(ch, ch)));
return builder.reverse().toString();
}
private static final DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a")
.withZone(ZoneId.systemDefault());
public static String formatDateTime(TemporalAccessor time) {
return translate(BASE_FORMATTER.format(time));
}
private UpsideDownUtils() {
}
}
@@ -68,5 +68,15 @@
"title" : "Java Animated PNG",
"subtitle" : "Copyright (C) 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.",
"externalLink" : "https://github.com/aellerton/japng"
},
{
"title": "Terracotta",
"subtitle": "Copyright (C) 2025 Burning_TNT.\nAll rights reserved.",
"externalLink": "https://github.com/burningtnt/Terracotta"
},
{
"title": "EasyTier",
"subtitle": "Copyright 2024-present Easytier Programme within The Commons Conservancy",
"externalLink": "https://github.com/EasyTier/EasyTier"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -692,7 +692,7 @@ input.url=The input must be a valid URL.
install=New Instance
install.change_version=Change Version
install.change_version.confirm=Are you sure you want to switch %s from version %s to %s?
install.change_version.confirm=Are you sure you want to switch %1$s from version %2$s to %3$s?
install.change_version.process=Change Version Process
install.failed=Failed to install
install.failed.downloading=Failed to download some required files.
@@ -702,7 +702,7 @@ install.failed.install_online=Failed to identify the provided file. If you are i
install.failed.malformed=The downloaded files are corrupted. You can try resolving this problem by switching to another download source in "Settings → Download → Download Source".
install.failed.optifine_conflict=Cannot install both OptiFine and Fabric on Minecraft 1.13 or later.
install.failed.optifine_forge_1.17=For Minecraft 1.17.1, Forge is only compatible with OptiFine H1 pre2 or later. You can install them by checking "Snapshots" when choosing an OptiFine version in HMCL.
install.failed.version_mismatch=This loader requires the game version %s, but the installed one is %s.
install.failed.version_mismatch=This loader requires the game version %1$s, but the installed one is %2$s.
install.installer.change_version=%s Incompatible
install.installer.choose=Choose Your %s Version
install.installer.cleanroom=Cleanroom
@@ -787,7 +787,7 @@ launch.advice.forge37_0_60=Forge versions prior to 37.0.60 are not compatible wi
launch.advice.java8_1_13=Minecraft 1.13 and later can only be run on Java 8 or later. Please use Java 8 or later versions.
launch.advice.java8_51_1_13=Minecraft 1.13 may crash on Java 8 versions prior to 1.8.0_51. Please install the latest Java 8 version.
launch.advice.java9=You cannot launch Minecraft 1.12 or earlier with Java 9 or later. Please use Java 8 instead.
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %s to launch Minecraft %s.
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %1$s to launch Minecraft %2$s.
launch.advice.modlauncher8=The Forge version you are using is not compatible with the current Java version. Please try updating Forge.
launch.advice.newer_java=You are using an older Java version to launch the game. It is recommended to update to Java 8, otherwise some mods may cause the game to crash.
launch.advice.not_enough_space=You have allocated a memory size larger than the actual %d MiB of memory installed on your computer. You may experience degraded performance or even be unable to launch the game.
@@ -906,7 +906,7 @@ modpack.installing=Installing modpack
modpack.installing.given=Installing %s modpack
modpack.introduction=Curse, Modrinth, MultiMC, and MCBBS modpacks are currently supported.
modpack.invalid=Invalid modpack, you can try downloading it again.
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %s type, but the provided one is %s type.
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %1$s type, but the provided one is %2$s type.
modpack.name=Modpack Name
modpack.not_a_valid_name=Invalid modpack name.
modpack.origin=Source
@@ -1236,7 +1236,7 @@ search.first_page=First
search.previous_page=Previous
search.next_page=Next
search.last_page=Last
search.page_n=%d / %s
search.page_n=%1$d / %2$s
selector.choose=Choose
selector.choose_file=Choose file
@@ -1256,6 +1256,7 @@ settings.advanced.custom_commands.hint=The following environment variables are p
\ · $INST_JAVA: java binary used for launch.\n\
\ · $INST_FORGE: set if Forge is installed.\n\
\ · $INST_NEOFORGE: set if NeoForge is installed.\n\
\ · $INST_CLEANROOM: set if Cleanroom is installed.\n\
\ · $INST_LITELOADER: set if LiteLoader is installed.\n\
\ · $INST_OPTIFINE: set if OptiFine is installed.\n\
\ · $INST_FABRIC: set if Fabric is installed.\n\
@@ -1333,7 +1334,7 @@ settings.game.java_directory.bit=%s bit
settings.game.java_directory.choose=Choose Java
settings.game.java_directory.invalid=Incorrect Java path
settings.game.java_directory.version=Specify Java Version
settings.game.java_directory.template=%s (%s)
settings.game.java_directory.template=%1$s (%2$s)
settings.game.management=Manage
settings.game.working_directory=Working Directory
settings.game.working_directory.choose=Choose the working directory
@@ -1412,6 +1413,76 @@ sponsor.hmcl=Hello Minecraft! Launcher is a FOSS Minecraft launcher that allows
system.architecture=Architecture
system.operating_system=Operating System
terracotta=Multiplayer
terracotta.easytier=About EasyTier
terracotta.terracotta=Terracotta | Multiplayer
terracotta.status=Lobby
terracotta.back=Exit
terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core
terracotta.from_local.title=Third-party download channels for Multiplayer Core
terracotta.from_local.desc=In some areas, the built-in default download channel may be unstable.
terracotta.from_local.guide=Please download Multiplayer Core package named %s. Once downloaded, drag the file into the current page to install it.
terracotta.from_local.file_name_mismatch=You should download the Multiplayer Core package named %1$s instead of %2$s
terracotta.export_log=Exports the Multiplayer Core log
terracotta.export_log.desc=Gathering more information for analysis
terracotta.status.bootstrap=Gathering information
terracotta.status.uninitialized.not_exist=Multiplayer Core: Not Downloaded
terracotta.status.uninitialized.not_exist.title=Download Multiplayer Core (~ 8MiB)
terracotta.status.uninitialized.update=Multiplayer Core: Update Available
terracotta.status.uninitialized.update.title=Update Multiplayer Core (~ 8MiB)
terracotta.status.uninitialized.desc=You legally promise to strictly abide by all laws and regulations of your country or region during the multiplayer process.
terracotta.confirm.title=User Notice
terracotta.confirm.desc=Terracotta is a third-party open source free software, which has little relationship with HMCL.\n\
Terracotta is based on P2P, so that the final experience depends greatly on your network condition.\n\
You promise to strictly abide by all laws and regulations in your country or region when playing online.
terracotta.status.preparing=Multiplayer Core: Downloading (DO NOT exit HMCL)
terracotta.status.launching=Multiplayer Core: Initializing
terracotta.status.unknown=Multiplayer Core: Initializing
terracotta.status.waiting=Multiplayer Core: Ready
terracotta.status.waiting.host.title=I want to host a session
terracotta.status.waiting.host.desc=Create a room and generate an invite code to play with friends
terracotta.status.waiting.host.launch.title=You seem to have forgotten to launch the game
terracotta.status.waiting.host.launch.desc=No running game found
terracotta.status.waiting.host.launch.skip=Game has launched
terracotta.status.waiting.guest.title=I want to join a session
terracotta.status.waiting.guest.desc=Enter the invite code from the host player to join the game world
terracotta.status.waiting.guest.prompt.title=Please enter the invite code from the host
terracotta.status.waiting.guest.prompt.invalid=Invalid invite code
terracotta.status.scanning=Scanning LAN worlds
terracotta.status.scanning.desc=Please <a href="hmcl://game/launch">start the game</a>, open a world, press ESC, select "Open to LAN", then select "Start LAN World".
terracotta.status.scanning.back=This will also stop scanning LAN worlds.
terracotta.status.host_starting=Room Creating
terracotta.status.host_starting.back=This will stop creating the room.
terracotta.status.host_ok=Room created
terracotta.status.host_ok.code=Invitation code (Copied)
terracotta.status.host_ok.code.copy=Copy invitation code
terracotta.status.host_ok.code.desc=Please remind your friends to select Guest mode in HMCL - Multiplayer or PCL CE and enter this invitation code.
terracotta.status.host_ok.back=This will also close the room, other guests will leave and cannot rejoin.
terracotta.status.guest_starting=Joining room
terracotta.status.guest_starting.back=This will not stop other guests from joining the room.
terracotta.status.guest_ok=Room Joined
terracotta.status.guest_ok.back=This will not stop other guests from joining the room.
terracotta.status.guest_ok.title=Please launch the game, select Multiplayer, and double-click Terracotta Lobby.
terracotta.status.guest_ok.desc=Backup address: %s
terracotta.status.exception.back=Please try again
terracotta.status.exception.desc.ping_host_fail=Failed to join room: Room is closed or network unstable
terracotta.status.exception.desc.ping_host_rst=Room connection lost: Room is closed or network unstable
terracotta.status.exception.desc.guest_et_crash=Failed to join room: EasyTier crashed, please report this issue to developers
terracotta.status.exception.desc.host_et_crash=Failed to create room: EasyTier crashed, please report this issue to developers
terracotta.status.exception.desc.ping_server_rst=Room closed: You exited the game world, room closed automatically
terracotta.status.exception.desc.scaffolding_invalid_response=Invalid Protocol:Host has sent invalid response, please report this issue to developers
terracotta.status.fatal.retry=Retry
terracotta.status.fatal.os=Sorry, HMCL cannot enable Terracotta | Multiplayer on your operating system or architecture. Please use a more modern operating system.
terracotta.status.fatal.network=Failed to download Multiplayer Core. Please check your network connection and try again.
terracotta.status.fatal.install=Fatal Error: Unable to install Multiplayer Core.
terracotta.status.fatal.terracotta=Fatal Error: Unable to connect to Multiplayer Core.
terracotta.status.fatal.unknown=Fatal Error: Unknown.
terracotta.player_list=Player List
terracotta.player_anonymous=Anonymous Player
terracotta.player_kind.host=Host
terracotta.player_kind.local=Yourself
terracotta.player_kind.guest=Guest
unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee its security.
update=Update
@@ -1442,6 +1513,7 @@ update.note=Beta and Nightly channels may have more features or fixes, but they
update.latest=This is the latest version
update.no_browser=Cannot open in system browser. But we copied the link to your clipboard, and you can open it manually.
update.tooltip=Update
update.preview=Try out the HMCL version
version=Games
version.name=Instance Name
@@ -1479,7 +1551,7 @@ version.manage.manage=Edit Instance
version.manage.manage.title=Edit Instance - %1s
version.manage.redownload_assets_index=Update Game Assets
version.manage.remove=Delete Instance
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%s"? You can still find its files in your recycle bin by the name of "%s".
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%1$s"? You can still find its files in your recycle bin by the name of "%2$s".
version.manage.remove.confirm.independent=Since this instance is stored in an isolated directory, deleting it will also delete its saves and other data. Do you still want to delete the instance "%s"?
version.manage.remove_assets=Delete All Assets
version.manage.remove_libraries=Delete All Libraries
@@ -1493,7 +1565,7 @@ version.update=Update Modpack
warning.java_interpreted_mode=HMCL is running in an interpreted Java environment, which will greatly affect performance.\n\
\n\
We recommend using a Java with JIT support to launch HMCL for the best experience.
We recommend using a Java with JIT support to open HMCL for the best experience.
warning.software_rendering=HMCL is currently using software rendering, which will greatly affect performance.
wiki.tooltip=Minecraft Wiki Page
@@ -180,6 +180,7 @@ button.change_source=Cambiar fuente de descarga
button.clear=Limpiar
button.copy_and_exit=Copiar y salir
button.delete=Borrar
button.do_not_show_again=No volver a mostrar
button.edit=Editar
button.install=Instalar
button.export=Exportar
@@ -391,6 +392,9 @@ fatal.config_loading_failure=No se pueden cargar los archivos de configuración.
Para macOS, intente colocar HMCL en un lugar con permiso que no sea «Escritorio», «Descargas» y «Documentos» y vuelva a intentarlo.
fatal.config_loading_failure.unix=No se pudo cargar el archivo de configuración porque fue creado por el usuario «%1$s».\n\
Por favor, abra HMCL como usuario root (no recomendado), o ejecute el siguiente comando en el terminal para cambiar la propiedad del archivo de configuración al usuario actual:\n%2$s
fatal.config_unsupported_version=El archivo de configuración actual fue creado por una versión más reciente del lanzador, y esta versión del lanzador no puede cargarlo correctamente.\n\
Por favor, actualice y reinicie el lanzador.\n\
Antes de actualizar el lanzador, cualquier configuración que modifiques no se guardará.
fatal.mac_app_translocation=El sistema operativo aísla Hello Minecraft! Launcher en un directorio temporal debido a los mecanismos de seguridad de macOS.\n\
Por favor, mueve HMCL a un directorio diferente antes de intentar abrirlo. De lo contrario, tus ajustes y datos de juego podrían perderse tras reiniciar.\n\
¿Todavía quieres continuar?
@@ -707,6 +711,7 @@ install.failed.optifine_forge_1.17=Para Minecraft 1.17.1, Forge sólo es compati
install.failed.version_mismatch=Este cargador requiere la versión del juego %s, pero la instalada es %s.
install.installer.change_version=%s Incompatible
install.installer.choose=Elija su versión %s
install.installer.cleanroom=Cleanroom
install.installer.depend=Requiere %s
install.installer.do_not_install=No instalar
install.installer.fabric=Fabric
@@ -778,6 +783,7 @@ launch.advice=%s ¿Todavía quieres continuar?
launch.advice.multi=Se han detectado los siguientes problemas:\n\n%s\n\nEstos problemas pueden impedir que inicies el juego o afectar a la experiencia de juego.\n\n¿Todavía quieres continuar?
launch.advice.java.auto=La versión actual de Java no es compatible con la instancia.\n\nHaga clic en «Sí» para elegir automáticamente la versión de Java más compatible. O bien, puede navegar hasta «Config. Global/Específica de instancia → Java» para elegir uno usted mismo.
launch.advice.java.modded_java_7=Minecraft 1.7.2 y versiones anteriores requieren Java 7 o anterior.
launch.advice.cleanroom=Cleanroom solo se puede ejecutar en Java 21 o versiones posteriores. Por favor, actualice su versión de Java.
launch.advice.corrected=Hemos resuelto el problema de Java. Si aún desea utilizar la versión de Java que elija, puede desactivar «No comprobar la compatibilidad con JVM» en «Config. Global/Específica de instancia → Configuración avanzada».
launch.advice.uncorrected=Si aún desea utilizar la versión de Java que elija, puede desactivar «No comprobar la compatibilidad con JVM» en «Config. Global/Específica de instancia → Configuración avanzada».
launch.advice.different_platform=Se recomienda la versión de 64 bits de Java para tu dispositivo, pero has instalado una de 32 bits.
@@ -1156,6 +1162,7 @@ world.info.player.food_level=Nivel de hambre
world.info.player.game_type=Modo de juego
world.info.player.game_type.adventure=Aventura
world.info.player.game_type.creative=Creativo
world.info.player.game_type.hardcore=Extremo
world.info.player.game_type.spectator=Espectador
world.info.player.game_type.survival=Supervivencia
world.info.player.health=Salud
@@ -1255,6 +1262,7 @@ settings.advanced.custom_commands.hint=Se proporcionan las siguientes variables
\ · $INST_JAVA: binario de java utilizado para la ejecución\n\
\ · $INST_FORGE: disponible si Forge está instalado.\n\
\ · $INST_NEOFORGE: disponible si NeoForge está instalado.\n\
\ · $INST_CLEANROOM: disponible si Cleanroom está instalado.\n\
\ · $INST_LITELOADER: disponible si LiteLoader está instalado.\n\
\ · $INST_OPTIFINE: disponible si OptiFine está instalado.\n\
\ · $INST_FABRIC: disponible si Fabric está instalado.\n\
@@ -1288,6 +1296,7 @@ settings.advanced.natives_directory.hint=Esta opción está pensada sólo para u
\n\
Antes de proceder, por favor, asegúrese de que todas las bibliotecas (por ejemplo, lwjgl.dll, libopenal.so) se proporcionan en su directorio deseado.
settings.advanced.no_jvm_args=No añadir argumentos JVM por defecto
settings.advanced.no_optimizing_jvm_args=No añadir argumentos de optimización de JVM
settings.advanced.precall_command=Comando pre-lanzamiento:
settings.advanced.precall_command.prompt=El comando se ejecuta antes de que los juegos se lance
settings.advanced.process_priority=Prioridad del proceso
@@ -1344,6 +1353,7 @@ settings.launcher=Configuración del launcher
settings.launcher.appearance=Apariencia
settings.launcher.common_path.tooltip=Esta aplicación pondrá todos los activos y dependencias del juego aquí. Si hay bibliotecas existentes en el directorio del juego, el launcher preferirá usarlas primero.
settings.launcher.debug=Depuración
settings.launcher.disable_auto_game_options=No cambiar el idioma del juego
settings.launcher.download=Descargas
settings.launcher.download.threads=Hilos
settings.launcher.download.threads.auto=Determinar automáticamente
@@ -1483,6 +1493,12 @@ version.search.prompt=Introduzca el nombre de la versión para buscar.
version.settings=Configuración
version.update=Actualizar Modpack
warning.java_interpreted_mode=El lanzador se ejecuta en un entorno Java interpretado, lo que afectará considerablemente al rendimiento.\n\
\n\
Recomendamos utilizar Java con soporte JIT para ejecutar el lanzador y obtener la mejor experiencia.
warning.software_rendering=El lanzador utiliza actualmente renderización por software, lo que afectará considerablemente al rendimiento.
wiki.tooltip=Página de Minecraft Wiki
wiki.version.game=https://es.minecraft.wiki/w/Java_Edition_%s
wiki.version.game.snapshot=https://es.minecraft.wiki/w/Java_Edition_%s
@@ -379,7 +379,7 @@ fatal.config_change_owner_root=你正在使用 root 帳戶開啟 Hello Minecraft
fatal.config_in_temp_dir=你正在暫存目錄中開啟 Hello Minecraft! Launcher,你的設定和遊戲資料可能會遺失。建議將 HMCL 移動至其他位置再開啟。\n是否繼續開啟?
fatal.config_loading_failure=Hello Minecraft! Launcher 無法載入設定檔案。\n請確保 HMCL 對「%s」目錄及該目錄下的檔案擁有讀寫權限。
fatal.config_loading_failure.unix=Hello Minecraft! Launcher 無法載入設定檔案,因為設定檔案是由使用者「%1$s」建立的。\n請使用 root 帳戶開啟 HMCL (不推薦),或在終端中執行以下指令將設定檔案的所有權變更為目前使用者:\n%2$s
fatal.config_unsupported_version=當前設定檔案是由更高版本的 Hello Minecraft! Launcher 建立的,目前版本的 HMCL 無法正常載入。請更新並重新啟動 HMCL。\n在更新啟動器之前,你所做的所有設定更改都不會被保存。
fatal.config_unsupported_version=目前設定檔案是由更高版本的 Hello Minecraft! Launcher 建立的,目前版本的 HMCL 無法正常載入。請更新並重新啟動 HMCL。\n在更新啟動器之前,你所做的所有設定更改都不會被保存。
fatal.mac_app_translocation=由於 macOS 的安全機制,Hello Minecraft! Launcher 被系統隔離至暫存目錄中。\n請將 HMCL 移動到其他目錄後再嘗試開啟,否則你的設定和遊戲資料可能會在重啟後遺失。\n是否繼續開啟?
fatal.migration_requires_manual_reboot=Hello Minecraft! Launcher 即將升級完成,請重新開啟 HMCL。
fatal.apply_update_failure=我們很抱歉 Hello Minecraft! Launcher 無法自動完成升級程式,因為出現了一些問題。\n但你依然可以從 %s 處手動下載 HMCL 來完成升級。
@@ -1049,6 +1049,7 @@ settings.advanced.custom_commands.hint=自訂指令被呼叫時將包含如下
\ · $INST_JAVA: 遊戲執行使用的 Java 路徑;\n\
\ · $INST_FORGE: 若安裝了 Forge,將會存在本環境變數;\n\
\ · $INST_NEOFORGE: 若安裝了 NeoForge,將會存在本環境變數;\n\
\ · $INST_CLEANROOM: 若安裝了 Cleanroom,將會存在本環境變數;\n\
\ · $INST_LITELOADER: 若安裝了 LiteLoader,將會存在本環境變數;\n\
\ · $INST_OPTIFINE: 若安裝了 OptiFine,將會存在本環境變數;\n\
\ · $INST_FABRIC: 若安裝了 Fabric,將會存在本環境變數;\n\
@@ -1080,7 +1081,7 @@ settings.advanced.natives_directory.default=預設 (由啟動器提供遊戲本
settings.advanced.natives_directory.default.version_id=<實例名稱>
settings.advanced.natives_directory.hint=本選項提供給 Apple Silicon 等未受遊戲官方支援的平台來自訂遊戲本機庫。如果你不知道本選項的含義,請你不要修改本選項,否則會導致遊戲無法啟動。\n\n如果你要修改本選項,你需要保證自訂目錄下有遊戲所需的本機庫檔案,如 lwjgl.dll (liblwjgl.so), openal.dll (libopenal.so) 等檔案。啟動器不會幫你補全缺少的本機庫檔案。\n\n注意:建議指定的本機庫檔案路徑使用全英文字元,否則可能導致遊戲啟動失敗。
settings.advanced.no_jvm_args=不新增預設的 Java 虛擬機參數
settings.advanced.no_optimizing_jvm_args=不自動添加 Java 虛擬機優化參數
settings.advanced.no_optimizing_jvm_args=不自動新增 Java 虛擬機最佳化參數
settings.advanced.precall_command=遊戲啟動前執行指令
settings.advanced.precall_command.prompt=將在遊戲啟動前呼叫使用
settings.advanced.process_priority=處理程式優先度
@@ -1200,6 +1201,76 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一個免費、自由、開源的 Mine
system.architecture=架構
system.operating_system=作業系統
terracotta=多人遊戲
terracotta.easytier=關於 EasyTier
terracotta.terracotta=Terracotta | 陶瓦聯機
terracotta.status=聯機大廳
terracotta.back=退出
terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心
terracotta.from_local.title=線上核心第三方下載管道
terracotta.from_local.desc=在部分地區,內建的預設下載管道可能不穩定或連線緩慢
terracotta.from_local.guide=您應下載名為 %s 的線上核心套件。下載完成後,請將檔案拖曳到目前介面來安裝。
terracotta.from_local.file_name_mismatch=您應該下載名為 %1$s 的線上核心包,而非 %2$s
terracotta.export_log=匯出線上核心日誌
terracotta.export_log.desc=為分析錯誤提供更多信息
terracotta.status.bootstrap=正在收集資訊
terracotta.status.uninitialized.not_exist=未下載聯機核心
terracotta.status.uninitialized.not_exist.title=下載聯機核心(約 8MiB)
terracotta.status.uninitialized.update=需更新聯機核心
terracotta.status.uninitialized.update.title=更新聯機核心(約 8MiB)
terracotta.status.uninitialized.desc=您承諾,在多人聯機全過程中,您將嚴格遵守您所在國家或地區的全部法律法規
terracotta.confirm.title=使用者須知
terracotta.confirm.desc=陶瓦聯機是第三方開源自由軟體,與 HMCL 無強關聯性。\n\
多人連線基於 p2p,最終線上體驗和您的網路情況有較大關係。\n\
您承諾,在多人連線全過程中,您將嚴格遵守您所在國家或地區的全部法律法規。
terracotta.status.preparing=正在下載聯機核心(請勿退出啟動器)
terracotta.status.launching=正在初始化聯機核心
terracotta.status.unknown=正在初始化聯機核心
terracotta.status.waiting=聯機核心已就緒
terracotta.status.waiting.host.title=我想當房主
terracotta.status.waiting.host.desc=建立房間並產生邀請碼,與好友一起暢玩
terracotta.status.waiting.host.launch.title=您似乎忘記啟動遊戲了
terracotta.status.waiting.host.launch.desc=未能找到正在執行的遊戲
terracotta.status.waiting.host.launch.skip=遊戲已啟動
terracotta.status.waiting.guest.title=我想當房客
terracotta.status.waiting.guest.desc=輸入房主提供的邀請碼加入遊戲世界
terracotta.status.waiting.guest.prompt.title=請輸入房主提供的邀請碼
terracotta.status.waiting.guest.prompt.invalid=邀請碼錯誤
terracotta.status.scanning=正在掃描區域網路世界
terracotta.status.scanning.desc=請<a href="hmcl://game/launch">啟動遊戲</a>,進入單人存檔,按 ESC 鍵,選擇「在區網上公開」,點擊「開始區網世界」。
terracotta.status.scanning.back=這將同時停止掃描區域網路世界。
terracotta.status.host_starting=正在建立房間
terracotta.status.host_starting.back=這將會取消建立房間。
terracotta.status.host_ok=已建立房間
terracotta.status.host_ok.code=邀請碼(已自動複製到剪貼簿)
terracotta.status.host_ok.code.copy=複製邀請碼
terracotta.status.host_ok.code.desc=請提醒您的朋友在 HMCL 或 PCL CE 多人遊戲功能中選擇房客模式,並輸入該邀請碼。
terracotta.status.host_ok.back=這將同時徹底關閉房間,其他房客將退出並不再能重新加入該房間。
terracotta.status.guest_starting=正在加入房間
terracotta.status.guest_starting.back=這不會影響其他房客加入目前房間。
terracotta.status.guest_ok=已加入房間
terracotta.status.guest_ok.back=這不會影響其他房客加入目前房間。
terracotta.status.guest_ok.title=請啟動遊戲,選擇多人遊戲,雙擊進入陶瓦聯機大廳。
terracotta.status.guest_ok.desc=備用連線位址:%s
terracotta.status.exception.back=可再試一次
terracotta.status.exception.desc.ping_host_fail=加入房間失敗:房間已關閉或網路不穩定
terracotta.status.exception.desc.ping_host_rst=房間連線中斷:房間已關閉或網路不穩定
terracotta.status.exception.desc.guest_et_crash=加入房間失敗:EasyTier 已崩潰,請向開發者回報該問題
terracotta.status.exception.desc.host_et_crash=建立房間失敗:EasyTier 已崩潰,請向開發者回報問題
terracotta.status.exception.desc.ping_server_rst=房間已關閉:您已退出遊戲存檔,房間已自動關閉
terracotta.status.exception.desc.scaffolding_invalid_response=協議錯誤:房主發送了錯誤的回應資料,請向開發者回報該問題
terracotta.status.fatal.retry=重試
terracotta.status.fatal.os=抱歉,HMCL 不能在您的作業系統或架構上啟用多人連線。請使用更主流的作業系統
terracotta.status.fatal.network=未能下載線上核心。請檢查網路連接,然後再試一次
terracotta.status.fatal.install=嚴重錯誤:無法安裝線上核心
terracotta.status.fatal.terracotta=嚴重錯誤:無法與線上核心通訊
terracotta.status.fatal.unknown=嚴重錯誤:原因未知
terracotta.player_list=玩家列表
terracotta.player_anonymous=匿名玩家
terracotta.player_kind.host=房主
terracotta.player_kind.local=你
terracotta.player_kind.guest=房客
unofficial.hint=你正在使用第三方提供的 HMCL。我們無法保證其安全性,請注意甄別。
update=啟動器更新
@@ -1226,6 +1297,7 @@ update.note=開發版與預覽版包含更多的功能以及錯誤修復,但
update.latest=目前版本為最新版本
update.no_browser=無法開啟瀏覽器。網址已經複製到剪貼簿了,你可以手動複製網址開啟頁面。
update.tooltip=更新
update.preview=提前預覽 HMCL 版本
version=遊戲
version.name=遊戲實例名稱
@@ -1275,8 +1347,8 @@ version.search.prompt=輸入版本名稱進行搜尋
version.settings=遊戲設定
version.update=更新模組包
warning.java_interpreted_mode=HMCL 正在執行於直譯器模式的 Java 環境中,效能將會受到很大影響。\n我們建議你使用支援 JIT 的 Java 啟動 HMCL 以獲得最佳體驗。
warning.software_rendering=HMCL 正在使用軟體渲染,效能將會受到很大影響。
warning.java_interpreted_mode=HMCL 正在執行於直譯器模式的 Java 環境中,效能將會受到很大影響。\n我們建議你使用支援 JIT 的 Java 開啟 HMCL 以獲得最佳體驗。
warning.software_rendering=HMCL 正在使用軟體繪製,效能將會受到很大影響。
wiki.tooltip=Minecraft Wiki 頁面
wiki.version.game=https://zh.minecraft.wiki/w/Java版%s
@@ -1211,6 +1211,76 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一个免费、自由、开放源代
system.architecture=架构
system.operating_system=操作系统
terracotta=多人联机
terracotta.easytier=关于 EasyTier
terracotta.terracotta=Terracotta | 陶瓦联机
terracotta.status=联机大厅
terracotta.back=退出
terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心
terracotta.from_local.title=联机核心第三方下载渠道
terracotta.from_local.desc=在部分地区,HMCL 内置的默认下载渠道可能不稳定或连接缓慢
terracotta.from_local.guide=您应当下载名为 %s 的联机核心包。下载完成后,请将文件拖入当前界面来安装。
terracotta.from_local.file_name_mismatch=您应当下载名为 %1$s 的联机核心包,而非 %2$s
terracotta.export_log=导出联机核心日志
terracotta.export_log.desc=为分析错误提供更多信息
terracotta.status.bootstrap=正在收集信息
terracotta.status.uninitialized.not_exist=未下载联机核心
terracotta.status.uninitialized.not_exist.title=下载联机核心(约 8MiB)
terracotta.status.uninitialized.update=需更新联机核心
terracotta.status.uninitialized.update.title=更新联机核心(约 8MiB)
terracotta.status.uninitialized.desc=您承诺,在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规
terracotta.confirm.title=用户须知
terracotta.confirm.desc=陶瓦联机是第三方开源自由软件,与 HMCL 无强关联性。\n\
多人联机基于 p2p,最终联机体验和您的网络情况有较大关系。\n\
在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规。
terracotta.status.preparing=正在下载联机核心(请勿退出启动器)
terracotta.status.launching=正在初始化联机核心
terracotta.status.unknown=正在初始化联机核心
terracotta.status.waiting=联机核心已就绪
terracotta.status.waiting.host.title=我想当房主
terracotta.status.waiting.host.desc=创建房间并生成邀请码,与好友一起畅玩
terracotta.status.waiting.host.launch.title=您似乎忘记启动游戏了
terracotta.status.waiting.host.launch.desc=未能找到正在运行的游戏
terracotta.status.waiting.host.launch.skip=游戏已启动
terracotta.status.waiting.guest.title=我想当房客
terracotta.status.waiting.guest.desc=输入房主提供的邀请码加入游戏世界
terracotta.status.waiting.guest.prompt.title=请输入房主提供的邀请码
terracotta.status.waiting.guest.prompt.invalid=邀请码错误
terracotta.status.scanning=正在扫描局域网世界
terracotta.status.scanning.desc=请<a href="hmcl://game/launch">启动游戏</a>,进入单人存档,按下 ESC 键,选择对局域网开放,点击创建局域网世界。
terracotta.status.scanning.back=这将同时停止扫描局域网世界。
terracotta.status.host_starting=正在启动房间
terracotta.status.host_starting.back=这将会取消创建房间。
terracotta.status.host_ok=已启动房间
terracotta.status.host_ok.code=邀请码(已自动复制到剪贴板)
terracotta.status.host_ok.code.copy=复制邀请码
terracotta.status.host_ok.code.desc=请提醒您的朋友在 HMCL 或 PCL CE 多人联机功能中选择房客模式,并输入该邀请码。
terracotta.status.host_ok.back=这将同时彻底关闭房间,其他房客将退出并不再能重新加入该房间。
terracotta.status.guest_starting=正在加入房间
terracotta.status.guest_starting.back=这不会影响其他房客加入当前房间。
terracotta.status.guest_ok=已加入房间
terracotta.status.guest_ok.back=这不会影响其他房客加入当前房间。
terracotta.status.guest_ok.title=请启动游戏,选择多人游戏,双击进入陶瓦联机大厅。
terracotta.status.guest_ok.desc=备用联机地址:%s
terracotta.status.exception.back=可再试一次
terracotta.status.exception.desc.ping_host_fail=加入房间失败:房间已关闭或网络不稳定
terracotta.status.exception.desc.ping_host_rst=房间连接断开:房间已关闭或网络不稳定
terracotta.status.exception.desc.guest_et_crash=加入房间失败:EasyTier 已崩溃,请向开发者反馈该问题
terracotta.status.exception.desc.host_et_crash=创建房间失败:EasyTier 已崩溃,请向开发者反馈该问题
terracotta.status.exception.desc.ping_server_rst=房间已关闭:您已退出游戏存档,房间已自动关闭
terracotta.status.exception.desc.scaffolding_invalid_response=协议错误:房主发送了错误的响应数据,请向开发者反馈该问题
terracotta.status.fatal.retry=重试
terracotta.status.fatal.os=抱歉,HMCL 不能在您的操作系统或架构上启用多人联机。请使用更主流的操作系统
terracotta.status.fatal.network=未能下载联机核心。请检查网络连接,然后再试一次
terracotta.status.fatal.install=严重错误:无法安装联机核心
terracotta.status.fatal.terracotta=严重错误:无法与联机核心通讯
terracotta.status.fatal.unknown=严重错误:原因未知
terracotta.player_list=玩家列表
terracotta.player_anonymous=匿名玩家
terracotta.player_kind.host=房主
terracotta.player_kind.local=你
terracotta.player_kind.guest=房客
unofficial.hint=你正在使用非官方构建的 HMCL。我们无法保证其安全性,请注意甄别。
update=启动器更新
@@ -1237,6 +1307,7 @@ update.note=开发版与预览版包含更多的功能以及错误修复,但
update.latest=当前版本为最新版本
update.no_browser=无法打开浏览器。网址已经复制到剪贴板,你可以手动粘贴网址打开页面。
update.tooltip=更新
update.preview=提前预览 HMCL 版本
version=游戏
version.name=游戏实例名称
@@ -0,0 +1,43 @@
{
"version_legacy": "0\\.3\\.[89]-rc.[0-9]",
"version_recent": [
"0.3.9-rc.9"
],
"version_latest": "0.3.9-rc.10",
"classifiers": {
"linux-arm64": "sha256:d5d386e9936d99dd8c31cdab2098a9db04bf55263faf5cd27d388139a42dee0b",
"linux-x86_64": "sha256:c43f111460c777315f12081d934729c08cc67b1a2048fdf3ae451e2682157dd8",
"macos-arm64": "sha256:49cbf5880aa94551951abda0e225bbbd9c43b9078ead8a738f5b995085d6022c",
"macos-arm64.pkg": "sha256:756831f50d67ed2382f8507dfcc94fcc32d6082e087cd4f6a2fc40ae39f25cdf",
"macos-x86_64": "sha256:fdf519f018ce4b3d5b4be06b86dcf9990e94b4010db9dac391175d917214cc01",
"macos-x86_64.pkg": "sha256:fc030f0205aa3667033c79ac514045bb7c94ae0175dc5946ea920eefee7ad997",
"windows-arm64.exe": "sha256:4b395093b0acaea85a36ce1e569c206d9dfa7e11bfe70409f00bc6c6100df88e",
"windows-x86_64.exe": "sha256:2b8add8f6330452d29faaa944ac63c35452e61c5a4ff009069d2c3017c7186c6"
},
"downloads": [
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}",
"https://ghfast.top/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
"https://cdn.crashmc.com/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
"https://cp.zkitefly.eu.org/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}"
],
"links": [
{
"desc": {
"default": "GitHub Release",
"zh": "GitHub 发布页",
"zh-Hant": "GitHub 發布頁"
},
"link": "https://github.com/burningtnt/Terracotta/releases/tag/v${version}"
},
{
"desc": {
"default": "Tencent QQ Group",
"zh": "QQ 群",
"zh-Hant": "QQ 群"
},
"link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
}
]
}
@@ -0,0 +1,19 @@
#
# Hello Minecraft! Launcher
# Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
boot.unsupported_java_version=HMCL requiere Java 17 o posterior para ejecutarse, pero sigue admitiendo el inicio de juegos con Java 6~16.\nPor favor, instala la última versión de Java e intenta ejecutar HMCL de nuevo.\nPuedes conservar tu versión antigua de Java. HMCL puede detectar y gestionar múltiples instalaciones de Java,\ny seleccionará automáticamente la versión de Java adecuada para tu juego.
@@ -271,7 +271,7 @@ public class OAuth {
}
}
private static class DeviceTokenResponse extends ErrorResponse {
private final static class DeviceTokenResponse extends ErrorResponse {
@SerializedName("user_code")
public String userCode;
@@ -291,7 +291,7 @@ public class OAuth {
public int interval;
}
private static class TokenResponse extends ErrorResponse {
private final static class TokenResponse extends ErrorResponse {
@SerializedName("token_type")
public String tokenType;
@@ -329,7 +329,7 @@ public class OAuth {
* redirect URI used to obtain the authorization
* code.","correlation_id":"??????"}
*/
public static class AuthorizationResponse extends ErrorResponse {
public final static class AuthorizationResponse extends ErrorResponse {
@SerializedName("token_type")
public String tokenType;
@@ -352,7 +352,7 @@ public class OAuth {
public String foci;
}
private static class RefreshResponse extends ErrorResponse {
private final static class RefreshResponse extends ErrorResponse {
@SerializedName("expires_in")
int expiresIn;
@@ -321,16 +321,16 @@ public class MicrosoftService {
public static final long ADD_FAMILY = 2148916238L;
}
public static class XBox400Exception extends AuthenticationException {
public final static class XBox400Exception extends AuthenticationException {
}
public static class NoMinecraftJavaEditionProfileException extends AuthenticationException {
public final static class NoMinecraftJavaEditionProfileException extends AuthenticationException {
}
public static class NoXuiException extends AuthenticationException {
public final static class NoXuiException extends AuthenticationException {
}
private static class XBoxLiveAuthenticationResponseDisplayClaims {
private final static class XBoxLiveAuthenticationResponseDisplayClaims {
List<Map<Object, Object>> xui;
}
@@ -356,7 +356,7 @@ public class MicrosoftService {
* XErr Candidates: 2148916233 = missing XBox account 2148916238 = child account
* not linked to a family
*/
private static class XBoxLiveAuthenticationResponse extends MicrosoftErrorResponse {
private final static class XBoxLiveAuthenticationResponse extends MicrosoftErrorResponse {
@SerializedName("IssueInstant")
String issueInstant;
@@ -370,7 +370,7 @@ public class MicrosoftService {
XBoxLiveAuthenticationResponseDisplayClaims displayClaims;
}
private static class MinecraftLoginWithXBoxResponse {
private final static class MinecraftLoginWithXBoxResponse {
@SerializedName("username")
String username;
@@ -387,14 +387,14 @@ public class MicrosoftService {
int expiresIn;
}
private static class MinecraftStoreResponseItem {
private final static class MinecraftStoreResponseItem {
@SerializedName("name")
String name;
@SerializedName("signature")
String signature;
}
private static class MinecraftStoreResponse extends MinecraftErrorResponse {
private final static class MinecraftStoreResponse extends MinecraftErrorResponse {
@SerializedName("items")
List<MinecraftStoreResponseItem> items;
@@ -405,7 +405,7 @@ public class MicrosoftService {
String keyId;
}
public static class MinecraftProfileResponseSkin implements Validation {
public final static class MinecraftProfileResponseSkin implements Validation {
public String id;
public String state;
public String url;
@@ -34,7 +34,7 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -244,7 +244,7 @@ public class Skin {
}
@Override
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
return new Context() {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -260,7 +260,7 @@ public class Skin {
setResult(new ByteArrayInputStream(baos.toByteArray()));
if (checkETag) {
repository.cacheBytes(connection, baos.toByteArray());
repository.cacheBytes(response, baos.toByteArray());
}
}
};
@@ -246,11 +246,11 @@ public class YggdrasilService {
}
}
private static class TextureResponse {
private final static class TextureResponse {
public Map<TextureType, Texture> textures;
}
private static class AuthenticationResponse extends ErrorResponse {
private final static class AuthenticationResponse extends ErrorResponse {
public String accessToken;
public String clientToken;
public GameProfile selectedProfile;
@@ -17,8 +17,9 @@
*/
package org.jackhuang.hmcl.download;
import org.jetbrains.annotations.Unmodifiable;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@@ -29,10 +30,10 @@ import java.util.stream.Collectors;
*/
public class AdaptedDownloadProvider implements DownloadProvider {
private List<DownloadProvider> downloadProviderCandidates;
private @Unmodifiable List<DownloadProvider> downloadProviderCandidates;
public void setDownloadProviderCandidates(List<DownloadProvider> downloadProviderCandidates) {
this.downloadProviderCandidates = new ArrayList<>(downloadProviderCandidates);
this.downloadProviderCandidates = List.copyOf(downloadProviderCandidates);
}
public DownloadProvider getPreferredDownloadProvider() {
@@ -0,0 +1,82 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.download;
import java.net.URI;
import java.util.List;
import java.util.Objects;
/**
* @author Glavo
*/
public final class DownloadProviderWrapper implements DownloadProvider {
private DownloadProvider provider;
public DownloadProviderWrapper(DownloadProvider provider) {
this.provider = provider;
}
public DownloadProvider getProvider() {
return this.provider;
}
public void setProvider(DownloadProvider provider) {
this.provider = Objects.requireNonNull(provider);
}
@Override
public List<URI> getAssetObjectCandidates(String assetObjectLocation) {
return getProvider().getAssetObjectCandidates(assetObjectLocation);
}
@Override
public String getVersionListURL() {
return getProvider().getVersionListURL();
}
@Override
public String getAssetBaseURL() {
return getProvider().getAssetBaseURL();
}
@Override
public String injectURL(String baseURL) {
return getProvider().injectURL(baseURL);
}
@Override
public List<URI> injectURLWithCandidates(String baseURL) {
return getProvider().injectURLWithCandidates(baseURL);
}
@Override
public List<URI> injectURLsWithCandidates(List<String> urls) {
return getProvider().injectURLsWithCandidates(urls);
}
@Override
public VersionList<?> getVersionListById(String id) {
return getProvider().getVersionListById(id);
}
@Override
public int getConcurrency() {
return getProvider().getConcurrency();
}
}
@@ -62,7 +62,7 @@ public final class CleanroomVersionList extends VersionList<CleanroomRemoteVersi
});
}
private static class ReleaseResult {
private final static class ReleaseResult {
String name;
String created_at;
}
@@ -17,29 +17,32 @@
*/
package org.jackhuang.hmcl.event;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.NotNull;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
*
* @author huangyuhui
*/
public final class EventBus {
/// @author huangyuhui
public final class EventBus extends ClassValue<EventManager<?>> {
private final ConcurrentHashMap<Class<?>, EventManager<?>> events = new ConcurrentHashMap<>();
public static final EventBus EVENT_BUS = new EventBus();
private EventBus() {
}
@Override
protected EventManager<?> computeValue(@NotNull Class<?> type) {
return new EventManager<>();
}
@SuppressWarnings("unchecked")
public <T extends Event> EventManager<T> channel(Class<T> clazz) {
return (EventManager<T>) events.computeIfAbsent(clazz, ignored -> new EventManager<>());
return (EventManager<T>) get(clazz);
}
@SuppressWarnings("unchecked")
public Event.Result fireEvent(Event obj) {
LOG.info(obj + " gets fired");
return channel((Class<Event>) obj.getClass()).fireEvent(obj);
return ((EventManager<Event>) get(obj.getClass())).fireEvent(obj);
}
public static final EventBus EVENT_BUS = new EventBus();
}
@@ -18,26 +18,28 @@
package org.jackhuang.hmcl.event;
import org.jackhuang.hmcl.util.SimpleMultimap;
import org.jetbrains.annotations.Contract;
import java.lang.ref.WeakReference;
import java.util.EnumMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
/**
*
* @author huangyuhui
*/
/// @author huangyuhui
public final class EventManager<T extends Event> {
private final ReentrantLock lock = new ReentrantLock();
private final SimpleMultimap<EventPriority, Consumer<T>, CopyOnWriteArraySet<Consumer<T>>> handlers
= new SimpleMultimap<>(() -> new EnumMap<>(EventPriority.class), CopyOnWriteArraySet::new);
@Contract("_ -> param1")
public Consumer<T> registerWeak(Consumer<T> consumer) {
register(new WeakListener(consumer));
return consumer;
}
@Contract("_, _ -> param1")
public Consumer<T> registerWeak(Consumer<T> consumer, EventPriority priority) {
register(new WeakListener(consumer), priority);
return consumer;
@@ -47,9 +49,13 @@ public final class EventManager<T extends Event> {
register(consumer, EventPriority.NORMAL);
}
public synchronized void register(Consumer<T> consumer, EventPriority priority) {
if (!handlers.get(priority).contains(consumer))
handlers.put(priority, consumer);
public void register(Consumer<T> consumer, EventPriority priority) {
lock.lock();
try {
handlers.get(priority).add(consumer);
} finally {
lock.unlock();
}
}
public void register(Runnable runnable) {
@@ -60,23 +66,30 @@ public final class EventManager<T extends Event> {
register(t -> runnable.run(), priority);
}
public synchronized Event.Result fireEvent(T event) {
for (EventPriority priority : EventPriority.values()) {
for (Consumer<T> handler : handlers.get(priority))
handler.accept(event);
public Event.Result fireEvent(T event) {
lock.lock();
try {
for (EventPriority priority : EventPriority.values()) {
for (Consumer<T> handler : handlers.get(priority))
handler.accept(event);
}
} finally {
lock.unlock();
}
if (event.hasResult())
return event.getResult();
else
return Event.Result.DEFAULT;
return event.hasResult() ? event.getResult() : Event.Result.DEFAULT;
}
public synchronized void unregister(Consumer<T> consumer) {
handlers.removeValue(consumer);
public void unregister(Consumer<T> consumer) {
lock.lock();
try {
handlers.removeValue(consumer);
} finally {
lock.unlock();
}
}
private class WeakListener implements Consumer<T> {
private final class WeakListener implements Consumer<T> {
private final WeakReference<Consumer<T>> ref;
public WeakListener(Consumer<T> listener) {
@@ -279,7 +279,7 @@ public final class World {
if (isLocked()) {
throw new WorldLockedException("The world " + getFile() + " has been locked");
}
FileUtils.deleteDirectory(file);
FileUtils.forceDelete(file);
}
public CompoundTag readLevelDat() throws IOException {
@@ -24,7 +24,7 @@ import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -45,6 +45,9 @@ public final class CacheFileTask extends FetchTask<Path> {
public CacheFileTask(@NotNull URI uri) {
super(List.of(uri));
setName(uri.toString());
if (!NetworkUtils.isHttpUri(uri))
throw new IllegalArgumentException(uri.toString());
}
@Override
@@ -69,8 +72,9 @@ public final class CacheFileTask extends FetchTask<Path> {
}
@Override
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
assert checkETag;
assert response != null;
Path temp = Files.createTempFile("hmcl-download-", null);
OutputStream fileOutput = Files.newOutputStream(temp);
@@ -99,7 +103,7 @@ public final class CacheFileTask extends FetchTask<Path> {
}
try {
setResult(repository.cacheRemoteFile(connection, temp));
setResult(repository.cacheRemoteFile(response, temp));
} finally {
try {
Files.deleteIfExists(temp);
@@ -19,31 +19,43 @@ package org.jackhuang.hmcl.task;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.ContentEncoding;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class FetchTask<T> extends Task<T> {
private static final HttpClient HTTP_CLIENT;
static {
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.version(useHttp2 ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1)
.build();
}
protected static final int DEFAULT_RETRY = 3;
protected final List<URI> uris;
@@ -58,7 +70,7 @@ public abstract class FetchTask<T> extends Task<T> {
if (this.uris.isEmpty())
throw new IllegalArgumentException("At least one URL is required");
setExecutor(download());
setExecutor(DOWNLOAD_EXECUTOR);
}
public void setRetry(int retry) {
@@ -79,12 +91,14 @@ public abstract class FetchTask<T> extends Task<T> {
protected abstract EnumCheckETag shouldCheckETag();
protected abstract Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException;
private Context getContext() throws IOException {
return getContext(null, false, null);
}
protected abstract Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException;
@Override
public void execute() throws Exception {
Exception exception = null;
URI failedURI = null;
boolean checkETag;
switch (shouldCheckETag()) {
case CHECK_E_TAG:
@@ -97,174 +111,258 @@ public abstract class FetchTask<T> extends Task<T> {
return;
}
int repeat = 0;
download:
for (URI uri : uris) {
if (checkETag) {
// Handle cache
ArrayList<DownloadException> exceptions = null;
if (SEMAPHORE != null)
SEMAPHORE.acquire();
try {
for (URI uri : uris) {
try {
Path cache = repository.getCachedRemoteFile(uri, true);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
if (NetworkUtils.isHttpUri(uri))
downloadHttp(uri, checkETag);
else
downloadNotHttp(uri);
return;
} catch (IOException ignored) {
} catch (DownloadException e) {
if (exceptions == null)
exceptions = new ArrayList<>();
exceptions.add(e);
}
}
} catch (InterruptedException ignored) {
// Cancelled
} finally {
if (SEMAPHORE != null)
SEMAPHORE.release();
}
for (int retryTime = 0; retryTime < retry; retryTime++) {
if (isCancelled()) {
break download;
if (exceptions != null) {
DownloadException last = exceptions.remove(exceptions.size() - 1);
for (DownloadException exception : exceptions) {
last.addSuppressed(exception);
}
throw last;
}
}
private void download(Context context,
InputStream inputStream,
long contentLength,
ContentEncoding contentEncoding) throws IOException, InterruptedException {
try (var ignored = context;
var counter = new CounterInputStream(inputStream);
var input = contentEncoding.wrap(counter)) {
long lastDownloaded = 0L;
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
while (true) {
if (isCancelled()) break;
int len = input.read(buffer);
if (len == -1) break;
context.write(buffer, 0, len);
if (contentLength >= 0) {
// Update progress information per second
updateProgress(counter.downloaded, contentLength);
}
List<URI> redirects = null;
String bmclapiHash = null;
try {
beforeDownload(uri);
updateProgress(0);
updateDownloadSpeed(counter.downloaded - lastDownloaded);
lastDownloaded = counter.downloaded;
}
URLConnection conn = NetworkUtils.createConnection(uri);
if (isCancelled())
throw new InterruptedException();
if (conn instanceof HttpURLConnection httpConnection) {
httpConnection.setRequestProperty("Accept-Encoding", "gzip");
updateDownloadSpeed(counter.downloaded - lastDownloaded);
if (checkETag) repository.injectConnection(httpConnection);
Map<String, List<String>> requestProperties = httpConnection.getRequestProperties();
if (contentLength >= 0 && counter.downloaded != contentLength)
throw new IOException("Unexpected file size: " + counter.downloaded + ", expected: " + contentLength);
bmclapiHash = httpConnection.getHeaderField("x-bmclapi-hash");
if (DigestUtils.isSha1Digest(bmclapiHash)) {
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
if (cache.isPresent()) {
useCachedResult(cache.get());
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
}
} else {
bmclapiHash = null;
}
context.withResult(true);
}
}
while (true) {
int code = httpConnection.getResponseCode();
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
if (redirects == null) {
redirects = new ArrayList<>();
} else if (redirects.size() >= 20) {
httpConnection.disconnect();
throw new IOException("Too much redirects");
}
String location = httpConnection.getHeaderField("Location");
httpConnection.disconnect();
if (StringUtils.isBlank(location))
throw new IOException("Redirected to an empty location");
URI target = NetworkUtils.toURI(httpConnection.getURL())
.resolve(NetworkUtils.toURI(location));
redirects.add(target);
if (!NetworkUtils.isHttpUri(target))
throw new IOException("Redirected to not http URI: " + target);
HttpURLConnection redirected = NetworkUtils.createHttpConnection(target);
redirected.setUseCaches(checkETag);
requestProperties
.forEach((key, values) ->
values.forEach(element ->
redirected.addRequestProperty(key, element)));
httpConnection = redirected;
} else {
break;
}
}
conn = httpConnection;
int responseCode = ((HttpURLConnection) conn).getResponseCode();
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(NetworkUtils.toURI(conn.getURL()), false);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
} catch (CacheRepository.CacheExpiredException e) {
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
} catch (IOException e) {
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
repository.removeRemoteEntry(conn.getURL().toURI());
// Now we must reconnect the server since 304 may result in empty content,
// if we want to redownload the file, we must reconnect the server without etag settings.
retryTime--;
continue;
}
} else if (responseCode / 100 == 4) {
throw new FileNotFoundException(uri.toString());
} else if (responseCode / 100 != 2) {
throw new ResponseCodeException(uri, responseCode);
}
}
long contentLength = conn.getContentLengthLong();
var encoding = ContentEncoding.fromConnection(conn);
try (var context = getContext(conn, checkETag, bmclapiHash);
var counter = new CounterInputStream(conn.getInputStream());
var input = encoding.wrap(counter)) {
long lastDownloaded = 0L;
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
while (true) {
if (isCancelled()) break;
int len = input.read(buffer);
if (len == -1) break;
context.write(buffer, 0, len);
if (contentLength >= 0) {
// Update progress information per second
updateProgress(counter.downloaded, contentLength);
}
updateDownloadSpeed(counter.downloaded - lastDownloaded);
lastDownloaded = counter.downloaded;
}
if (isCancelled()) break download;
updateDownloadSpeed(counter.downloaded - lastDownloaded);
if (contentLength >= 0 && counter.downloaded != contentLength)
throw new IOException("Unexpected file size: " + counter.downloaded + ", expected: " + contentLength);
context.withResult(true);
}
return;
} catch (FileNotFoundException ex) {
failedURI = uri;
exception = ex;
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
break; // we will not try this URL again
} catch (IOException ex) {
failedURI = uri;
exception = ex;
LOG.warning("Failed to download " + uri + ", repeat times: " + (++repeat) + (redirects == null ? "" : ", redirects: " + redirects), ex);
}
private void downloadHttp(URI uri, boolean checkETag) throws DownloadException, InterruptedException {
if (checkETag) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(uri, true);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
} catch (IOException ignored) {
}
}
if (exception != null)
throw new DownloadException(failedURI, exception);
ArrayList<IOException> exceptions = null;
for (int retryTime = 0; retryTime < retry; retryTime++) {
if (isCancelled()) {
throw new InterruptedException();
}
List<URI> redirects = null;
try {
beforeDownload(uri);
updateProgress(0);
HttpResponse<InputStream> response;
String bmclapiHash;
URI currentURI = uri;
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
headers.put("accept-encoding", "gzip");
if (checkETag)
headers.putAll(repository.injectConnection(uri));
do {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI);
requestBuilder.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT));
headers.forEach(requestBuilder::header);
response = HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
bmclapiHash = response.headers().firstValue("x-bmclapi-hash").orElse(null);
if (DigestUtils.isSha1Digest(bmclapiHash)) {
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
if (cache.isPresent()) {
useCachedResult(cache.get());
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
}
}
int code = response.statusCode();
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
if (redirects == null) {
redirects = new ArrayList<>();
} else if (redirects.size() >= 20) {
throw new IOException("Too much redirects");
}
String location = response.headers().firstValue("Location").orElse(null);
if (StringUtils.isBlank(location))
throw new IOException("Redirected to an empty location");
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
redirects.add(target);
if (!NetworkUtils.isHttpUri(target))
throw new IOException("Redirected to not http URI: " + target);
currentURI = target;
} else {
break;
}
} while (true);
int responseCode = response.statusCode();
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(currentURI, false);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
} catch (CacheRepository.CacheExpiredException e) {
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
} catch (IOException e) {
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
repository.removeRemoteEntry(currentURI);
// Now we must reconnect the server since 304 may result in empty content,
// if we want to redownload the file, we must reconnect the server without etag settings.
retryTime--;
continue;
}
} else if (responseCode / 100 == 4) {
throw new FileNotFoundException(uri.toString());
} else if (responseCode / 100 != 2) {
throw new ResponseCodeException(uri, responseCode);
}
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
var contentEncoding = ContentEncoding.fromResponse(response);
download(getContext(response, checkETag, bmclapiHash),
response.body(),
contentLength,
contentEncoding);
return;
} catch (FileNotFoundException ex) {
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
} catch (IOException ex) {
if (exceptions == null)
exceptions = new ArrayList<>();
exceptions.add(ex);
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime + (redirects == null ? "" : ", redirects: " + redirects), ex);
}
}
throw toDownloadException(uri, null, exceptions);
}
private void downloadNotHttp(URI uri) throws DownloadException, InterruptedException {
ArrayList<IOException> exceptions = null;
for (int retryTime = 0; retryTime < retry; retryTime++) {
if (isCancelled()) {
throw new InterruptedException();
}
try {
beforeDownload(uri);
updateProgress(0);
URLConnection conn = NetworkUtils.createConnection(uri);
download(getContext(),
conn.getInputStream(),
conn.getContentLengthLong(),
ContentEncoding.fromConnection(conn));
return;
} catch (FileNotFoundException ex) {
LOG.warning("Failed to download " + uri + ", not found", ex);
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
} catch (IOException ex) {
if (exceptions == null)
exceptions = new ArrayList<>();
exceptions.add(ex);
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime, ex);
}
}
throw toDownloadException(uri, null, exceptions);
}
private static DownloadException toDownloadException(URI uri, @Nullable IOException last, @Nullable ArrayList<IOException> exceptions) {
if (exceptions == null || exceptions.isEmpty()) {
return new DownloadException(uri, last != null
? last
: new IOException("No exceptions"));
} else {
if (last == null)
last = exceptions.remove(exceptions.size() - 1);
for (IOException e : exceptions) {
last.addSuppressed(e);
}
return new DownloadException(uri, last);
}
}
private static final Timer timer = new Timer("DownloadSpeedRecorder", true);
private static final AtomicLong downloadSpeed = new AtomicLong(0L);
public static final EventBus speedEvent = new EventBus();
public static final EventManager<SpeedEvent> SPEED_EVENT = EventBus.EVENT_BUS.channel(SpeedEvent.class);
static {
timer.schedule(new TimerTask() {
@Override
public void run() {
speedEvent.channel(SpeedEvent.class).fireEvent(new SpeedEvent(speedEvent, downloadSpeed.getAndSet(0)));
SPEED_EVENT.fireEvent(new SpeedEvent(SPEED_EVENT, downloadSpeed.getAndSet(0)));
}
}, 0, 1000);
}
@@ -326,7 +424,7 @@ public abstract class FetchTask<T> extends Task<T> {
public abstract void write(byte[] buffer, int offset, int len) throws IOException;
public final void withResult(boolean success) {
public void withResult(boolean success) {
this.success = success;
}
@@ -341,48 +439,74 @@ public abstract class FetchTask<T> extends Task<T> {
CACHED
}
private static final HttpResponse.BodyHandler<InputStream> BODY_HANDLER = responseInfo -> {
if (responseInfo.statusCode() / 100 == 2)
return HttpResponse.BodySubscribers.ofInputStream();
else
return HttpResponse.BodySubscribers.replacing(null);
};
public static int DEFAULT_CONCURRENCY = Math.min(Runtime.getRuntime().availableProcessors() * 4, 64);
private static int downloadExecutorConcurrency = DEFAULT_CONCURRENCY;
private static volatile ThreadPoolExecutor DOWNLOAD_EXECUTOR;
/**
* Get singleton instance of the thread pool for file downloading.
*
* @return Thread pool for FetchTask
*/
protected static ExecutorService download() {
if (DOWNLOAD_EXECUTOR == null) {
synchronized (Schedulers.class) {
if (DOWNLOAD_EXECUTOR == null) {
DOWNLOAD_EXECUTOR = threadPool("Download", true, downloadExecutorConcurrency, 10, TimeUnit.SECONDS);
}
}
// For Java 21 or later, DOWNLOAD_EXECUTOR dispatches tasks to virtual threads, and concurrency is controlled by SEMAPHORE.
// For versions earlier than Java 21, DOWNLOAD_EXECUTOR is a ThreadPoolExecutor, SEMAPHORE is null, and concurrency is controlled by the thread pool size.
private static final ExecutorService DOWNLOAD_EXECUTOR;
private static final @Nullable Semaphore SEMAPHORE;
static {
ExecutorService executorService = Schedulers.newVirtualThreadPerTaskExecutor("Download");
if (executorService != null) {
DOWNLOAD_EXECUTOR = executorService;
SEMAPHORE = new Semaphore(DEFAULT_CONCURRENCY);
} else {
DOWNLOAD_EXECUTOR = threadPool("Download", true, downloadExecutorConcurrency, 10, TimeUnit.SECONDS);
SEMAPHORE = null;
}
return DOWNLOAD_EXECUTOR;
}
@FXThread
public static void setDownloadExecutorConcurrency(int concurrency) {
concurrency = Math.max(concurrency, 1);
synchronized (Schedulers.class) {
downloadExecutorConcurrency = concurrency;
ThreadPoolExecutor downloadExecutor = DOWNLOAD_EXECUTOR;
if (downloadExecutor != null) {
if (downloadExecutor.getMaximumPoolSize() <= concurrency) {
downloadExecutor.setMaximumPoolSize(concurrency);
downloadExecutor.setCorePoolSize(concurrency);
} else {
downloadExecutor.setCorePoolSize(concurrency);
downloadExecutor.setMaximumPoolSize(concurrency);
int prevDownloadExecutorConcurrency = downloadExecutorConcurrency;
int change = concurrency - prevDownloadExecutorConcurrency;
if (change == 0)
return;
downloadExecutorConcurrency = concurrency;
if (SEMAPHORE != null) {
if (change > 0) {
SEMAPHORE.release(change);
} else {
int permits = -change;
if (!SEMAPHORE.tryAcquire(permits)) {
Schedulers.io().execute(() -> {
try {
for (int i = 0; i < permits; i++) {
SEMAPHORE.acquire();
}
} catch (InterruptedException e) {
throw new AssertionError("Unreachable", e);
}
});
}
}
} else {
var downloadExecutor = (ThreadPoolExecutor) DOWNLOAD_EXECUTOR;
if (downloadExecutor.getMaximumPoolSize() <= concurrency) {
downloadExecutor.setMaximumPoolSize(concurrency);
downloadExecutor.setCorePoolSize(concurrency);
} else {
downloadExecutor.setCorePoolSize(concurrency);
downloadExecutor.setMaximumPoolSize(concurrency);
}
}
}
public static int getDownloadExecutorConcurrency() {
synchronized (Schedulers.class) {
return downloadExecutorConcurrency;
}
return downloadExecutorConcurrency;
}
}
@@ -26,7 +26,7 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -185,7 +185,7 @@ public class FileDownloadTask extends FetchTask<Void> {
}
@Override
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
Path temp = Files.createTempFile(null, null);
String algorithm;
@@ -260,7 +260,7 @@ public class FileDownloadTask extends FetchTask<Void> {
}
if (checkETag) {
repository.cacheRemoteFile(connection, file);
repository.cacheRemoteFile(response, file);
}
}
};
@@ -24,7 +24,9 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -59,9 +61,11 @@ public final class GetTask extends FetchTask<String> {
}
@Override
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) {
int length = connection.getContentLength();
final var baos = new ByteArrayOutputStream(length <= 0 ? 8192 : length);
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
long length = -1;
if (response != null)
length = response.headers().firstValueAsLong("content-length").orElse(-1L);
final var baos = new ByteArrayOutputStream(length <= 0 ? 8192 : (int) length);
return new Context() {
@Override
@@ -73,11 +77,15 @@ public final class GetTask extends FetchTask<String> {
public void close() throws IOException {
if (!isSuccess()) return;
String result = baos.toString(NetworkUtils.getCharsetFromContentType(connection.getContentType()));
Charset charset = StandardCharsets.UTF_8;
if (response != null)
charset = NetworkUtils.getCharsetFromContentType(response.headers().firstValue("content-type").orElse(null));
String result = baos.toString(charset);
setResult(result);
if (checkETag) {
repository.cacheText(connection, result);
repository.cacheText(response, result);
}
}
};
@@ -18,61 +18,103 @@
package org.jackhuang.hmcl.task;
import javafx.application.Platform;
import org.jackhuang.hmcl.util.Lang;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.concurrent.*;
import java.util.function.Function;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
*
* @author huangyuhui
*/
/// @author huangyuhui
public final class Schedulers {
private Schedulers() {
}
private static volatile ExecutorService IO_EXECUTOR;
private static final @Nullable Function<String, ExecutorService> NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR;
/**
* Get singleton instance of the thread pool for I/O operations,
* usually for reading files from disk, or Internet connections.
*
* This thread pool has no more than 4 threads, and number of threads will get
* reduced if concurrency is less than thread number.
*
* @return Thread pool for I/O operations.
*/
public static ExecutorService io() {
if (IO_EXECUTOR == null) {
synchronized (Schedulers.class) {
if (IO_EXECUTOR == null) {
IO_EXECUTOR = threadPool("IO", true, 4, 10, TimeUnit.SECONDS);
}
static {
if (Runtime.version().feature() >= 21) {
try {
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
Class<?> vtBuilderCls = Class.forName("java.lang.Thread$Builder$OfVirtual");
MethodHandle ofVirtualHandle = lookup.findStatic(Thread.class, "ofVirtual", MethodType.methodType(vtBuilderCls));
MethodHandle setNameHandle = lookup.findVirtual(vtBuilderCls, "name", MethodType.methodType(vtBuilderCls, String.class, long.class));
MethodHandle toFactoryHandle = lookup.findVirtual(vtBuilderCls, "factory", MethodType.methodType(ThreadFactory.class));
MethodHandle newThreadPerTaskExecutorFactory = lookup.findStatic(Executors.class, "newThreadPerTaskExecutor", MethodType.methodType(ExecutorService.class, ThreadFactory.class));
NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR = name -> {
try {
Object virtualThreadBuilder = ofVirtualHandle.invoke();
setNameHandle.invoke(virtualThreadBuilder, name, 1L);
ThreadFactory threadFactory = (ThreadFactory) toFactoryHandle.invoke(virtualThreadBuilder);
return (ExecutorService) newThreadPerTaskExecutorFactory.invokeExact(threadFactory);
} catch (Throwable e) {
throw new AssertionError("Unreachable", e);
}
};
} catch (Throwable e) {
throw new AssertionError("Unreachable", e);
}
} else {
NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR = null;
}
}
/// @return Returns null if the Java version is below 21, otherwise always returns a non-null value.
public static ExecutorService newVirtualThreadPerTaskExecutor(String name) {
if (NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR == null) {
return null;
}
return IO_EXECUTOR;
return NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR.apply(name);
}
/// This thread pool is suitable for network and local I/O operations.
///
/// For Java 21 or later, all tasks will be dispatched to virtual threads.
///
/// @return Thread pool for I/O operations.
public static ExecutorService io() {
return Holder.IO_EXECUTOR;
}
public static Executor javafx() {
return Platform::runLater;
}
/// Default thread pool, equivalent to [ForkJoinPool#commonPool()].
///
/// It is recommended to perform computation tasks on this thread pool. For I/O operations, please use [#io()].
public static Executor defaultScheduler() {
return ForkJoinPool.commonPool();
}
public static synchronized void shutdown() {
public static void shutdown() {
LOG.info("Shutting down executor services.");
// shutdownNow will interrupt all threads.
// So when we want to close the app, no threads need to be waited for finish.
// Sometimes it resolves the problem that the app does not exit.
}
if (IO_EXECUTOR != null)
IO_EXECUTOR.shutdownNow();
private static final class Holder {
private static final ExecutorService IO_EXECUTOR;
static {
//noinspection resource
ExecutorService vtExecutor = newVirtualThreadPerTaskExecutor("IO");
IO_EXECUTOR = vtExecutor != null
? vtExecutor
: Executors.newCachedThreadPool(Lang.counterThreadFactory("IO", true));
}
}
}
@@ -23,12 +23,13 @@ import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
@@ -184,15 +185,35 @@ public class CacheRepository {
}
}
public void injectConnection(HttpURLConnection conn) {
conn.setUseCaches(true);
URI uri;
public @NotNull Map<String, String> injectConnection(URI uri) {
try {
uri = NetworkUtils.dropQuery(NetworkUtils.toURI(conn.getURL()));
uri = NetworkUtils.dropQuery(uri);
} catch (IllegalArgumentException e) {
return Map.of();
}
ETagItem eTagItem;
lock.readLock().lock();
try {
eTagItem = index.get(uri);
} finally {
lock.readLock().unlock();
}
if (eTagItem == null) return Map.of();
if (eTagItem.eTag != null)
return Map.of("if-none-match", eTagItem.eTag);
// if (eTagItem.getRemoteLastModified() != null)
// conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified());
return Map.of();
}
public void injectConnection(URI uri, HttpRequest.Builder requestBuilder) {
try {
uri = NetworkUtils.dropQuery(uri);
} catch (IllegalArgumentException e) {
return;
}
ETagItem eTagItem;
lock.readLock().lock();
try {
@@ -202,25 +223,25 @@ public class CacheRepository {
}
if (eTagItem == null) return;
if (eTagItem.eTag != null)
conn.setRequestProperty("If-None-Match", eTagItem.eTag);
requestBuilder.header("if-none-match", eTagItem.eTag);
// if (eTagItem.getRemoteLastModified() != null)
// conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified());
}
public Path cacheRemoteFile(URLConnection connection, Path downloaded) throws IOException {
return cacheData(connection, () -> {
public Path cacheRemoteFile(HttpResponse<?> response, Path downloaded) throws IOException {
return cacheData(response, () -> {
String hash = DigestUtils.digestToString(SHA1, downloaded);
Path cached = cacheFile(downloaded, SHA1, hash);
return new CacheResult(hash, cached);
});
}
public Path cacheText(URLConnection connection, String text) throws IOException {
return cacheBytes(connection, text.getBytes(UTF_8));
public Path cacheText(HttpResponse<?> response, String text) throws IOException {
return cacheBytes(response, text.getBytes(UTF_8));
}
public Path cacheBytes(URLConnection connection, byte[] bytes) throws IOException {
return cacheData(connection, () -> {
public Path cacheBytes(HttpResponse<?> response, byte[] bytes) throws IOException {
return cacheData(response, () -> {
String hash = DigestUtils.digestToString(SHA1, bytes);
Path cached = getFile(SHA1, hash);
Files.createDirectories(cached.getParent());
@@ -231,20 +252,15 @@ public class CacheRepository {
private static final Pattern MAX_AGE = Pattern.compile("(s-maxage|max-age)=(?<time>[0-9]+)");
private Path cacheData(URLConnection connection, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
String eTag = connection.getHeaderField("ETag");
private Path cacheData(HttpResponse<?> response, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
String eTag = response.headers().firstValue("etag").orElse(null);
if (StringUtils.isBlank(eTag)) return null;
URI uri;
try {
uri = NetworkUtils.dropQuery(NetworkUtils.toURI(connection.getURL()));
} catch (IllegalArgumentException e) {
throw new IOException(e);
}
URI uri = NetworkUtils.dropQuery(response.uri());
long expires = 0L;
expires:
try {
String cacheControl = connection.getHeaderField("Cache-Control");
String cacheControl = response.headers().firstValue("cache-control").orElse(null);
if (StringUtils.isNotBlank(cacheControl)) {
if (cacheControl.contains("no-store"))
return null;
@@ -257,7 +273,7 @@ public class CacheRepository {
}
}
String expiresHeader = connection.getHeaderField("Expires");
String expiresHeader = response.headers().firstValue("expires").orElse(null);
if (StringUtils.isNotBlank(expiresHeader)) {
expires = ZonedDateTime.parse(expiresHeader.trim(), DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant().toEpochMilli();
@@ -266,7 +282,7 @@ public class CacheRepository {
LOG.warning("Failed to parse expires time", e);
}
String lastModified = connection.getHeaderField("Last-Modified");
String lastModified = response.headers().firstValue("last-modified").orElse(null);
CacheResult cacheResult = cacheSupplier.get();
ETagItem eTagItem = new ETagItem(uri.toString(),
@@ -21,15 +21,14 @@ import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
* When {@link #accept(T)} is called, this class invokes the handler on another thread.
* If {@link #accept(T)} is called more than one time before the handler starts processing,
* the handler will only be invoked once, taking the latest argument as its input.
*
* @author yushijinhun
*/
/// When [#accept(T)] is called, this class invokes the handler on another thread.
/// If [#accept(T)] is called more than one time before the handler starts processing,
/// the handler will only be invoked once, taking the latest argument as its input.
///
/// @author yushijinhun
public final class InvocationDispatcher<T> implements Consumer<T> {
/// @param executor The executor must dispatch all tasks to a single thread.
public static <T> InvocationDispatcher<T> runOn(Executor executor, Consumer<T> action) {
return new InvocationDispatcher<>(executor, action);
}
@@ -47,9 +46,10 @@ public final class InvocationDispatcher<T> implements Consumer<T> {
public void accept(T t) {
if (pendingArg.getAndSet(new Holder<>(t)) == null) {
executor.execute(() -> {
synchronized (InvocationDispatcher.this) {
action.accept(pendingArg.getAndSet(null).value);
}
// If the executor supports multiple underlying threads,
// we need to add synchronization, but for now we can omit it :)
// synchronized (InvocationDispatcher.this)
action.accept(pendingArg.getAndSet(null).value);
});
}
}
@@ -236,14 +236,22 @@ public final class Lang {
}
public static ThreadPoolExecutor threadPool(String name, boolean daemon, int threads, long timeout, TimeUnit timeunit) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
threads, threads,
timeout, timeunit,
new LinkedBlockingQueue<>(),
counterThreadFactory(name, daemon));
pool.allowCoreThreadTimeOut(true);
return pool;
}
public static ThreadFactory counterThreadFactory(String name, boolean daemon) {
AtomicInteger counter = new AtomicInteger(1);
ThreadPoolExecutor pool = new ThreadPoolExecutor(threads, threads, timeout, timeunit, new LinkedBlockingQueue<>(), r -> {
return r -> {
Thread t = new Thread(r, name + "-" + counter.getAndIncrement());
t.setDaemon(daemon);
return t;
});
pool.allowCoreThreadTimeOut(true);
return pool;
};
}
public static int parseInt(Object string, int defaultValue) {
@@ -17,7 +17,6 @@
*/
package org.jackhuang.hmcl.util.i18n;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
@@ -31,7 +30,7 @@ import java.util.ResourceBundle;
/// - For all Chinese locales, `zh-CN` is always added to the candidate list. If `zh-Hans` already exists in the candidate list,
/// `zh-CN` is inserted before `zh`; otherwise, it is inserted after `zh`.
/// - For all Traditional Chinese locales, `zh-TW` is always added to the candidate list (before `zh`).
/// - For all [supported][LocaleUtils#toISO1Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
/// - For all [supported][LocaleUtils#mapToISO2Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
/// a candidate list with the language code replaced by the ISO 639-1 (Macro)language code is added to the end of the candidate list.
///
/// @author Glavo
@@ -42,67 +41,15 @@ public class DefaultResourceBundleControl extends ResourceBundle.Control {
public DefaultResourceBundleControl() {
}
private static List<Locale> ensureEditable(List<Locale> list) {
return list instanceof ArrayList<?>
? list
: new ArrayList<>(list);
@Override
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
return LocaleUtils.getCandidateLocales(locale);
}
@Override
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
if (locale.getLanguage().isEmpty())
return getCandidateLocales(baseName, Locale.ENGLISH);
else if (LocaleUtils.isChinese(locale)) {
String script = locale.getScript();
if (script.isEmpty()) {
script = LocaleUtils.getScript(locale);
if (!script.isEmpty())
return getCandidateLocales(baseName, new Locale.Builder()
.setLocale(locale)
.setScript(script)
.build());
}
}
String language = locale.getLanguage();
List<Locale> locales = super.getCandidateLocales(baseName, locale);
// Is ISO 639-3 language tag
if (language.length() == 3) {
String iso1 = LocaleUtils.toISO1Language(locale.getLanguage());
if (iso1.length() == 2) {
locales = ensureEditable(locales);
locales.removeIf(it -> !it.getLanguage().equals(language));
locales.addAll(getCandidateLocales(baseName, new Locale.Builder()
.setLocale(locale)
.setLanguage(iso1)
.build()));
}
} else if (language.equals("zh")) {
if (locales.contains(LocaleUtils.LOCALE_ZH_HANT) && !locales.contains(Locale.TRADITIONAL_CHINESE)) {
locales = ensureEditable(locales);
int chineseIdx = locales.indexOf(Locale.CHINESE);
if (chineseIdx >= 0)
locales.add(chineseIdx, Locale.TRADITIONAL_CHINESE);
}
if (!locales.contains(Locale.SIMPLIFIED_CHINESE)) {
int chineseIdx = locales.indexOf(Locale.CHINESE);
if (chineseIdx >= 0) {
locales = ensureEditable(locales);
if (locales.contains(LocaleUtils.LOCALE_ZH_HANS))
locales.add(chineseIdx, Locale.SIMPLIFIED_CHINESE);
else
locales.add(chineseIdx + 1, Locale.SIMPLIFIED_CHINESE);
}
}
}
return locales;
public Locale getFallbackLocale(String baseName, Locale locale) {
// By default, when only the base bundle is found, it will attempt to fall back to Locale.getDefault() for further lookup.
// Since we always use the base bundle as the English resource file, we want to suppress this behavior.
return null;
}
}
@@ -17,7 +17,9 @@
*/
package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
@@ -25,7 +27,16 @@ import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -42,6 +53,61 @@ public final class LocaleUtils {
public static final String DEFAULT_LANGUAGE_KEY = "default";
private static final Map<String, String> subLanguageToParent = new HashMap<>();
private static final Map<String, String> iso3To2 = new HashMap<>();
static {
try {
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")).lines())) {
if (line.startsWith("#") || line.isBlank()) {
continue;
}
String[] languages = line.split(",");
if (languages.length < 2) {
LOG.warning("Invalid line in sublanguages.csv: " + line);
continue;
}
String parent = languages[0];
for (int i = 1; i < languages.length; i++) {
subLanguageToParent.put(languages[i], parent);
}
}
} catch (Throwable e) {
LOG.warning("Failed to load sublanguages.csv", e);
}
try {
// Line Format: (?<iso2>[a-z]{2}),(?<iso3>[a-z]{3})
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/iso_languages.csv")).lines())) {
if (line.startsWith("#") || line.isBlank()) {
continue;
}
String[] parts = line.split(",", 3);
if (parts.length != 2) {
LOG.warning("Invalid line in iso_languages.csv: " + line);
continue;
}
iso3To2.put(parts[1], parts[0]);
}
} catch (Throwable e) {
LOG.warning("Failed to load iso_languages.csv", e);
}
}
private static Locale getInstance(String language, String script, String region,
String variant) {
Locale.Builder builder = new Locale.Builder();
if (!language.isEmpty()) builder.setLanguage(language);
if (!script.isEmpty()) builder.setScript(script);
if (!region.isEmpty()) builder.setRegion(region);
if (!variant.isEmpty()) builder.setVariant(variant);
return builder.build();
}
/// Convert a locale to the language key.
///
/// The language key is in the format of BCP 47 language tag.
@@ -52,24 +118,38 @@ public final class LocaleUtils {
: locale.stripExtensions().toLanguageTag();
}
public static boolean isISO1Language(String language) {
return language.length() == 2;
public static @NotNull String getRootLanguage(Locale locale) {
return getRootLanguage(locale.getLanguage());
}
public static boolean isISO3Language(String language) {
return language.length() == 3;
}
public static @NotNull String getISO1Language(Locale locale) {
String language = locale.getLanguage();
/// - If `language` is a sublanguage of a [macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
/// return the macrolanguage;
/// - If `language` is an ISO 639 alpha-3 language code and there is a corresponding ISO 639 alpha-2 language code, return the ISO 639 alpha-2 code;
/// - If `language` is empty, return `en`;
/// - Otherwise, return the `language`.
public static @NotNull String getRootLanguage(String language) {
if (language.isEmpty()) return "en";
return isISO3Language(language) ? toISO1Language(language) : language;
if (language.length() <= 2)
return language;
String iso2 = mapToISO2Language(language);
if (iso2 != null)
return iso2;
String parent = getParentLanguage(language);
return parent != null ? parent : language;
}
/// Get the script of the locale. If the script is empty and the language is Chinese,
/// the script will be inferred based on the language, the region and the variant.
public static @NotNull String getScript(Locale locale) {
if (locale.getScript().isEmpty()) {
if (isEnglish(locale)) {
if ("UD".equals(locale.getCountry())) {
return "Qabs";
}
}
if (isChinese(locale)) {
if (CHINESE_LATN_VARIANTS.contains(locale.getVariant()))
return "Latn";
@@ -83,10 +163,94 @@ public final class LocaleUtils {
return locale.getScript();
}
private static final ConcurrentMap<Locale, List<Locale>> CANDIDATE_LOCALES = new ConcurrentHashMap<>();
public static @NotNull List<Locale> getCandidateLocales(Locale locale) {
return DefaultResourceBundleControl.INSTANCE.getCandidateLocales("", locale);
return CANDIDATE_LOCALES.computeIfAbsent(locale, LocaleUtils::createCandidateLocaleList);
}
// -------------
private static List<Locale> createCandidateLocaleList(Locale locale) {
String language = locale.getLanguage();
if (language.isEmpty())
return List.of(Locale.ENGLISH, Locale.ROOT);
String script = getScript(locale);
String region = locale.getCountry();
List<String> variants = locale.getVariant().isEmpty()
? List.of()
: List.of(locale.getVariant().split("[_\\-]"));
ArrayList<Locale> result = new ArrayList<>();
do {
String currentLanguage;
if (language.length() <= 2) {
currentLanguage = language;
} else {
String iso2 = mapToISO2Language(language);
currentLanguage = iso2 != null
? iso2
: language;
}
addCandidateLocales(result, currentLanguage, script, region, variants);
} while ((language = getParentLanguage(language)) != null);
result.add(Locale.ROOT);
return List.copyOf(result);
}
private static void addCandidateLocales(ArrayList<Locale> list,
String language,
String script,
String region,
List<String> variants) {
if (!variants.isEmpty()) {
for (String v : variants) {
list.add(getInstance(language, script, region, v));
}
}
if (!region.isEmpty()) {
list.add(getInstance(language, script, region, ""));
}
if (!script.isEmpty()) {
list.add(getInstance(language, script, "", ""));
if (!variants.isEmpty()) {
for (String v : variants) {
list.add(getInstance(language, "", region, v));
}
}
if (!region.isEmpty()) {
list.add(getInstance(language, "", region, ""));
}
}
list.add(getInstance(language, "", "", ""));
if (language.equals("zh")) {
if (list.contains(LocaleUtils.LOCALE_ZH_HANT) && !list.contains(Locale.TRADITIONAL_CHINESE)) {
int chineseIdx = list.indexOf(Locale.CHINESE);
if (chineseIdx >= 0)
list.add(chineseIdx, Locale.TRADITIONAL_CHINESE);
}
if (!list.contains(Locale.SIMPLIFIED_CHINESE)) {
int chineseIdx = list.indexOf(Locale.CHINESE);
if (chineseIdx >= 0) {
if (list.contains(LocaleUtils.LOCALE_ZH_HANS))
list.add(chineseIdx, Locale.SIMPLIFIED_CHINESE);
else
list.add(chineseIdx + 1, Locale.SIMPLIFIED_CHINESE);
}
}
}
}
// -------------
public static <T> @Nullable T getByCandidateLocales(Map<String, T> map, List<Locale> candidateLocales) {
for (Locale locale : candidateLocales) {
String key = toLanguageKey(locale);
@@ -178,29 +342,27 @@ public final class LocaleUtils {
// ---
/// Try to convert ISO 639-3 language codes to ISO 639-1 language codes.
public static String toISO1Language(String languageTag) {
return switch (languageTag) {
case "eng" -> "en";
case "spa" -> "es";
case "jpa" -> "ja";
case "rus" -> "ru";
case "ukr" -> "uk";
case "zho", "cmn", "lzh", "cdo", "cjy", "cpx", "czh",
"gan", "hak", "hsn", "mnp", "nan", "wuu", "yue" -> "zh";
default -> languageTag;
};
/// Map ISO 639 alpha-3 language codes to ISO 639 alpha-2 language codes.
/// Returns `null` if there is no corresponding ISO 639 alpha-2 language code.
public static @Nullable String mapToISO2Language(String iso3Language) {
return iso3To2.get(iso3Language);
}
/// If `language` is a sublanguage of a [macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
/// return the macrolanguage; otherwise, return `null`.
public static @Nullable String getParentLanguage(String language) {
return subLanguageToParent.get(language);
}
public static boolean isEnglish(Locale locale) {
return "en".equals(getISO1Language(locale));
return "en".equals(getRootLanguage(locale));
}
public static final Set<String> CHINESE_TRADITIONAL_REGIONS = Set.of("TW", "HK", "MO");
public static final Set<String> CHINESE_LATN_VARIANTS = Set.of("pinyin", "wadegile", "tongyong");
public static boolean isChinese(Locale locale) {
return "zh".equals(getISO1Language(locale));
return "zh".equals(getRootLanguage(locale));
}
private LocaleUtils() {
@@ -22,6 +22,7 @@ import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.util.zip.GZIPInputStream;
/**
@@ -52,5 +53,16 @@ public enum ContentEncoding {
}
}
public static @NotNull ContentEncoding fromResponse(HttpResponse<?> connection) throws IOException {
String encoding = connection.headers().firstValue("content-encoding").orElse("");
if (encoding.isEmpty() || "identity".equals(encoding)) {
return IDENTITY;
} else if ("gzip".equalsIgnoreCase(encoding)) {
return GZIP;
} else {
throw new IOException("Unsupported content encoding: " + encoding);
}
}
public abstract InputStream wrap(InputStream inputStream) throws IOException;
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util.io;
import org.jackhuang.hmcl.util.function.ExceptionalPredicate;
import org.jetbrains.annotations.NotNull;
import java.io.*;
import java.nio.charset.Charset;
@@ -153,6 +154,31 @@ public final class Zipper implements Closeable {
zos.closeEntry();
}
public OutputStream putStream(String path) throws IOException {
zos.putNextEntry(new ZipEntry(normalize(path)));
return new OutputStream() {
public void write(int b) throws IOException {
zos.write(b);
}
public void write(@NotNull byte[] b) throws IOException {
zos.write(b);
}
public void write(@NotNull byte[] b, int off, int len) throws IOException {
zos.write(b, off, len);
}
public void flush() throws IOException {
zos.flush();
}
public void close() throws IOException {
zos.closeEntry();
}
};
}
public void putLines(Stream<String> lines, String path) throws IOException {
zos.putNextEntry(new ZipEntry(normalize(path)));
@@ -25,6 +25,7 @@ import javafx.beans.value.ObservableValue;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -144,6 +145,7 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
private static final class AsyncMappedBinding<T, U> extends BindingMapping<T, U> {
private final ReentrantLock lock = new ReentrantLock();
private boolean initialized = false;
private T prev;
private U value;
@@ -159,13 +161,16 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
}
private void tryUpdateValue(T currentPrev) {
synchronized (this) {
lock.lock();
try {
if ((initialized && Objects.equals(prev, currentPrev))
|| isComputing(currentPrev)) {
return;
}
computing = true;
computingPrev = currentPrev;
} finally {
lock.unlock();
}
CompletableFuture<? extends U> task;
@@ -189,7 +194,8 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
}
private void valueUpdate(T currentPrev, U computed) {
synchronized (this) {
lock.lock();
try {
if (isComputing(currentPrev)) {
computing = false;
computingPrev = null;
@@ -197,15 +203,20 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
value = computed;
initialized = true;
}
} finally {
lock.unlock();
}
}
private void valueUpdateFailed(T currentPrev) {
synchronized (this) {
lock.lock();
try {
if (isComputing(currentPrev)) {
computing = false;
computingPrev = null;
}
} finally {
lock.unlock();
}
}
@@ -24,6 +24,7 @@ import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
@@ -37,6 +38,7 @@ import javafx.beans.binding.ObjectBinding;
*/
public final class ObservableCache<K, V, E extends Exception> {
private final ReentrantLock lock = new ReentrantLock();
private final ExceptionalFunction<K, V, E> source;
private final BiConsumer<K, Throwable> exceptionHandler;
private final V fallbackValue;
@@ -54,22 +56,29 @@ public final class ObservableCache<K, V, E extends Exception> {
}
public Optional<V> getImmediately(K key) {
synchronized (this) {
lock.lock();
try {
return Optional.ofNullable(cache.get(key));
} finally {
lock.unlock();
}
}
public void put(K key, V value) {
synchronized (this) {
lock.lock();
try {
cache.put(key, value);
invalidated.remove(key);
} finally {
lock.unlock();
}
Platform.runLater(observable::invalidate);
}
private CompletableFuture<V> query(K key, Executor executor) {
CompletableFuture<V> future;
synchronized (this) {
lock.lock();
try {
CompletableFuture<V> prev = pendings.get(key);
if (prev != null) {
return prev;
@@ -77,6 +86,8 @@ public final class ObservableCache<K, V, E extends Exception> {
future = new CompletableFuture<>();
pendings.put(key, future);
}
} finally {
lock.unlock();
}
executor.execute(() -> {
@@ -84,18 +95,24 @@ public final class ObservableCache<K, V, E extends Exception> {
try {
result = source.apply(key);
} catch (Throwable ex) {
synchronized (this) {
lock.lock();
try {
pendings.remove(key);
} finally {
lock.unlock();
}
exceptionHandler.accept(key, ex);
future.completeExceptionally(ex);
return;
}
synchronized (this) {
lock.lock();
try {
cache.put(key, result);
invalidated.remove(key);
pendings.remove(key, future);
} finally {
lock.unlock();
}
future.complete(result);
Platform.runLater(observable::invalidate);
@@ -106,11 +123,14 @@ public final class ObservableCache<K, V, E extends Exception> {
public V get(K key) {
V cached;
synchronized (this) {
lock.lock();
try {
cached = cache.get(key);
if (cached != null && !invalidated.containsKey(key)) {
return cached;
}
} finally {
lock.unlock();
}
try {
@@ -143,7 +163,9 @@ public final class ObservableCache<K, V, E extends Exception> {
return Bindings.createObjectBinding(() -> {
V result;
boolean refresh;
synchronized (this) {
lock.lock();
try {
result = cache.get(key);
if (result == null) {
result = fallbackValue;
@@ -151,6 +173,8 @@ public final class ObservableCache<K, V, E extends Exception> {
} else {
refresh = invalidated.containsKey(key);
}
} finally {
lock.unlock();
}
if (!quiet && refresh) {
query(key, executor);
@@ -160,10 +184,13 @@ public final class ObservableCache<K, V, E extends Exception> {
}
public void invalidate(K key) {
synchronized (this) {
lock.lock();
try {
if (cache.containsKey(key)) {
invalidated.put(key, Boolean.TRUE);
}
} finally {
lock.unlock();
}
Platform.runLater(observable::invalidate);
}
@@ -47,6 +47,9 @@ public final class Logger {
private static volatile String[] accessTokens = new String[0];
public static synchronized void registerAccessToken(String token) {
if (token == null || token.length() <= 1)
return;
final String[] oldAccessTokens = accessTokens;
final String[] newAccessTokens = Arrays.copyOf(oldAccessTokens, oldAccessTokens.length + 1);
@@ -22,17 +22,17 @@ import org.jackhuang.hmcl.util.Lang;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.function.Predicate;
/**
* The managed process.
*
* @author huangyuhui
* @see org.jackhuang.hmcl.launch.ExitWaiter
* @see org.jackhuang.hmcl.launch.StreamPump
*/
/// The managed process.
///
/// @author huangyuhui
/// <!-- @see org.jackhuang.hmcl.launch.ExitWaiter -->
/// @see org.jackhuang.hmcl.launch.StreamPump
public final class ManagedProcess {
private final ReentrantLock lock = new ReentrantLock();
private final Process process;
private final List<String> commands;
private final String classpath;
@@ -54,7 +54,7 @@ public final class ManagedProcess {
*/
public ManagedProcess(Process process, List<String> commands) {
this.process = process;
this.commands = Collections.unmodifiableList(new ArrayList<>(commands));
this.commands = List.copyOf(commands);
this.classpath = null;
}
@@ -67,7 +67,7 @@ public final class ManagedProcess {
*/
public ManagedProcess(Process process, List<String> commands, String classpath) {
this.process = process;
this.commands = Collections.unmodifiableList(new ArrayList<>(commands));
this.commands = List.copyOf(commands);
this.classpath = classpath;
}
@@ -111,9 +111,11 @@ public final class ManagedProcess {
*
* @see #addLine
*/
public synchronized List<String> getLines(Predicate<String> lineFilter) {
public List<String> getLines(Predicate<String> lineFilter) {
lock.lock();
if (lineFilter == null)
return Collections.unmodifiableList(Arrays.asList(lines.toArray(new String[0])));
return List.copyOf(lines);
ArrayList<String> res = new ArrayList<>();
for (String line : this.lines) {
@@ -123,8 +125,13 @@ public final class ManagedProcess {
return Collections.unmodifiableList(res);
}
public synchronized void addLine(String line) {
lines.add(line);
public void addLine(String line) {
lock.lock();
try {
lines.add(line);
} finally {
lock.unlock();
}
}
/**
@@ -133,15 +140,20 @@ public final class ManagedProcess {
* If a thread is monitoring this raw process,
* you are required to add the instance by this method.
*/
public synchronized void addRelatedThread(Thread thread) {
relatedThreads.add(thread);
public void addRelatedThread(Thread thread) {
lock.lock();
try {
relatedThreads.add(thread);
} finally {
lock.unlock();
}
}
public synchronized void pumpInputStream(Consumer<String> onLogLine) {
public void pumpInputStream(Consumer<String> onLogLine) {
addRelatedThread(Lang.thread(new StreamPump(process.getInputStream(), onLogLine, OperatingSystem.NATIVE_CHARSET), "ProcessInputStreamPump", true));
}
public synchronized void pumpErrorStream(Consumer<String> onLogLine) {
public void pumpErrorStream(Consumer<String> onLogLine) {
addRelatedThread(Lang.thread(new StreamPump(process.getErrorStream(), onLogLine, OperatingSystem.NATIVE_CHARSET), "ProcessErrorStreamPump", true));
}
@@ -172,8 +184,13 @@ public final class ManagedProcess {
destroyRelatedThreads();
}
public synchronized void destroyRelatedThreads() {
relatedThreads.forEach(Thread::interrupt);
public void destroyRelatedThreads() {
lock.lock();
try {
relatedThreads.forEach(Thread::interrupt);
} finally {
lock.unlock();
}
}
@Override
@@ -112,7 +112,7 @@ public final class SystemUtils {
return Thread.currentThread().getContextClassLoader().getResource("com/sun/tools/attach/VirtualMachine.class") != null;
}
private static void onLogLine(String log) {
public static void onLogLine(String log) {
LOG.info(log);
}
}
@@ -24,15 +24,20 @@ import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import java.io.Closeable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.FileTime;
import java.util.*;
/**
* @author Glavo
*/
public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Closeable {
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Closeable {
public static ArchiveFileTree<?, ?> open(Path file) throws IOException {
Path namePath = file.getFileName();
@@ -50,23 +55,55 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
}
}
protected final F file;
protected final Dir<E> root = new Dir<>("");
protected final R reader;
protected final Dir<E> root = new Dir<>("", "");
public ArchiveFileTree(F file) {
this.file = file;
public ArchiveFileTree(R reader) {
this.reader = reader;
}
public F getFile() {
return file;
public R getReader() {
return reader;
}
public Dir<E> getRoot() {
return root;
}
public @Nullable E getEntry(@NotNull String entryPath) {
Dir<E> dir = root;
String fileName;
if (entryPath.indexOf('/') < 0) {
fileName = entryPath;
if (fileName.isEmpty())
return root.getEntry();
} else {
String[] path = entryPath.split("/");
if (path.length == 0)
return root.getEntry();
for (int i = 0; i < path.length - 1; i++) {
String item = path[i];
if (item.isEmpty())
continue;
dir = dir.getSubDirs().get(item);
if (dir == null)
return null;
}
fileName = path[path.length - 1];
E entry = dir.getFiles().get(fileName);
if (entry != null)
return entry;
}
Dir<E> subDir = dir.getSubDirs().get(fileName);
return subDir != null ? subDir.getEntry() : null;
}
protected void addEntry(E entry) throws IOException {
String[] path = entry.getName().split("/");
List<String> pathList = Arrays.asList(path);
Dir<E> dir = root;
@@ -81,7 +118,9 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
throw new IOException("A file and a directory have the same name: " + entry.getName());
}
dir = dir.subDirs.computeIfAbsent(item, Dir::new);
final int nameEnd = i + 1;
dir = dir.subDirs.computeIfAbsent(item, name ->
new Dir<>(name, String.join("/", pathList.subList(0, nameEnd))));
}
if (entry.isDirectory()) {
@@ -106,6 +145,55 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
public abstract InputStream getInputStream(E entry) throws IOException;
public @NotNull InputStream getInputStream(String entryPath) throws IOException {
E entry = getEntry(entryPath);
if (entry == null)
throw new FileNotFoundException("Entry not found: " + entryPath);
return getInputStream(entry);
}
public byte[] readBinaryEntry(@NotNull E entry) throws IOException {
try (InputStream input = getInputStream(entry)) {
return input.readAllBytes();
}
}
public String readTextEntry(@NotNull String entryPath) throws IOException {
E entry = getEntry(entryPath);
if (entry == null)
throw new FileNotFoundException("Entry not found: " + entryPath);
return readTextEntry(entry);
}
public String readTextEntry(@NotNull E entry) throws IOException {
return new String(readBinaryEntry(entry), StandardCharsets.UTF_8);
}
protected void copyAttributes(@NotNull E source, @NotNull Path targetFile) throws IOException {
FileTime lastModifiedTime = source.getLastModifiedTime();
if (lastModifiedTime != null)
Files.setLastModifiedTime(targetFile, lastModifiedTime);
}
public void extractTo(@NotNull String entryPath, @NotNull Path targetFile) throws IOException {
E entry = getEntry(entryPath);
if (entry == null)
throw new FileNotFoundException("Entry not found: " + entryPath);
extractTo(entry, targetFile);
}
public void extractTo(@NotNull E entry, @NotNull Path targetFile) throws IOException {
try (InputStream input = getInputStream(entry)) {
Files.copy(input, targetFile, StandardCopyOption.REPLACE_EXISTING);
}
try {
copyAttributes(entry, targetFile);
} catch (Throwable e) {
LOG.warning("Failed to copy attributes to " + targetFile, e);
}
}
public abstract boolean isLink(E entry);
public abstract String getLink(E entry) throws IOException;
@@ -117,13 +205,15 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
public static final class Dir<E extends ArchiveEntry> {
private final String name;
private final String fullName;
private E entry;
final Map<String, Dir<E>> subDirs = new HashMap<>();
final Map<String, E> files = new HashMap<>();
public Dir(String name) {
public Dir(String name, String fullName) {
this.name = name;
this.fullName = fullName;
}
public boolean isRoot() {
@@ -134,6 +224,11 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
return name;
}
/// Get the normalized full path. Leading `/` and all `.` in the path will be removed.
public @NotNull String getFullName() {
return fullName;
}
public @Nullable E getEntry() {
return entry;
}
@@ -20,11 +20,13 @@ package org.jackhuang.hmcl.util.tree;
import kala.compress.archivers.tar.TarArchiveEntry;
import kala.compress.archivers.tar.TarArchiveReader;
import org.jetbrains.annotations.NotNull;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributeView;
import java.util.zip.GZIPInputStream;
/**
@@ -98,9 +100,22 @@ public final class TarFileTree extends ArchiveFileTree<TarArchiveReader, TarArch
this.shutdownHook = null;
}
@Override
protected void copyAttributes(@NotNull TarArchiveEntry source, @NotNull Path targetFile) throws IOException {
var fileAttributeView = Files.getFileAttributeView(targetFile, BasicFileAttributeView.class);
if (fileAttributeView == null)
return;
fileAttributeView.setTimes(
source.getLastModifiedTime(),
source.getLastAccessTime(),
source.getCreationTime()
);
}
@Override
public InputStream getInputStream(TarArchiveEntry entry) throws IOException {
return file.getInputStream(entry);
return reader.getInputStream(entry);
}
@Override
@@ -121,7 +136,7 @@ public final class TarFileTree extends ArchiveFileTree<TarArchiveReader, TarArch
@Override
public void close() throws IOException {
try {
file.close();
reader.close();
} finally {
if (tempFile != null) {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
@@ -20,9 +20,17 @@ package org.jackhuang.hmcl.util.tree;
import kala.compress.archivers.zip.ZipArchiveEntry;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermission;
import java.util.EnumSet;
import java.util.Set;
/**
* @author Glavo
@@ -51,12 +59,53 @@ public final class ZipFileTree extends ArchiveFileTree<ZipArchiveReader, ZipArch
@Override
public void close() throws IOException {
if (closeReader)
file.close();
reader.close();
}
@Override
@SuppressWarnings("OctalInteger")
protected void copyAttributes(@NotNull ZipArchiveEntry source, @NotNull Path targetFile) throws IOException {
BasicFileAttributeView targetView = Files.getFileAttributeView(targetFile, PosixFileAttributeView.class);
// target might not support posix even if source does
if (targetView == null)
targetView = Files.getFileAttributeView(targetFile, BasicFileAttributeView.class);
if (targetView == null)
return;
targetView.setTimes(
source.getLastModifiedTime(),
source.getLastAccessTime(),
source.getCreationTime()
);
int unixMode = source.getUnixMode();
if (unixMode != 0 && targetView instanceof PosixFileAttributeView posixView) {
Set<PosixFilePermission> permissions = EnumSet.noneOf(PosixFilePermission.class);
// Owner permissions
if ((unixMode & 0400) != 0) permissions.add(PosixFilePermission.OWNER_READ);
if ((unixMode & 0200) != 0) permissions.add(PosixFilePermission.OWNER_WRITE);
if ((unixMode & 0100) != 0) permissions.add(PosixFilePermission.OWNER_EXECUTE);
// Group permissions
if ((unixMode & 0040) != 0) permissions.add(PosixFilePermission.GROUP_READ);
if ((unixMode & 0020) != 0) permissions.add(PosixFilePermission.GROUP_WRITE);
if ((unixMode & 0010) != 0) permissions.add(PosixFilePermission.GROUP_EXECUTE);
// Others permissions
if ((unixMode & 0004) != 0) permissions.add(PosixFilePermission.OTHERS_READ);
if ((unixMode & 0002) != 0) permissions.add(PosixFilePermission.OTHERS_WRITE);
if ((unixMode & 0001) != 0) permissions.add(PosixFilePermission.OTHERS_EXECUTE);
posixView.setPermissions(permissions);
}
}
@Override
public InputStream getInputStream(ZipArchiveEntry entry) throws IOException {
return getFile().getInputStream(entry);
return getReader().getInputStream(entry);
}
@Override
@@ -66,7 +115,7 @@ public final class ZipFileTree extends ArchiveFileTree<ZipArchiveReader, ZipArch
@Override
public String getLink(ZipArchiveEntry entry) throws IOException {
return getFile().getUnixSymlink(entry);
return getReader().getUnixSymlink(entry);
}
@Override
@@ -831,4 +831,19 @@
1.21.7-rc2
1.21.7
1.21.8-rc1
1.21.8
1.21.8
25w31a
25w32a
25w33a
25w34a
25w34b
25w35a
25w36a
25w36b
25w37a
1.21.9-pre1
1.21.9-pre2
1.21.9-pre3
1.21.9-pre4
1.21.9-rc1
1.21.9
@@ -0,0 +1,187 @@
aa,aar
ab,abk
ae,ave
af,afr
ak,aka
am,amh
an,arg
ar,ara
as,asm
av,ava
ay,aym
az,aze
ba,bak
be,bel
bg,bul
bh,bih
bi,bis
bm,bam
bn,ben
bo,bod
br,bre
bs,bos
ca,cat
ce,che
ch,cha
co,cos
cr,cre
cs,ces
cu,chu
cv,chv
cy,cym
da,dan
de,deu
dv,div
dz,dzo
ee,ewe
el,ell
en,eng
eo,epo
es,spa
et,est
eu,eus
fa,fas
ff,ful
fi,fin
fj,fij
fo,fao
fr,fra
fy,fry
ga,gle
gd,gla
gl,glg
gn,grn
gu,guj
gv,glv
ha,hau
he,heb
hi,hin
ho,hmo
hr,hrv
ht,hat
hu,hun
hy,hye
hz,her
ia,ina
id,ind
ie,ile
ig,ibo
ii,iii
ik,ipk
in,ind
io,ido
is,isl
it,ita
iu,iku
iw,heb
ja,jpn
ji,yid
jv,jav
ka,kat
kg,kon
ki,kik
kj,kua
kk,kaz
kl,kal
km,khm
kn,kan
ko,kor
kr,kau
ks,kas
ku,kur
kv,kom
kw,cor
ky,kir
la,lat
lb,ltz
lg,lug
li,lim
ln,lin
lo,lao
lt,lit
lu,lub
lv,lav
mg,mlg
mh,mah
mi,mri
mk,mkd
ml,mal
mn,mon
mo,mol
mr,mar
ms,msa
mt,mlt
my,mya
na,nau
nb,nob
nd,nde
ne,nep
ng,ndo
nl,nld
nn,nno
no,nor
nr,nbl
nv,nav
ny,nya
oc,oci
oj,oji
om,orm
or,ori
os,oss
pa,pan
pi,pli
pl,pol
ps,pus
pt,por
qu,que
rm,roh
rn,run
ro,ron
ru,rus
rw,kin
sa,san
sc,srd
sd,snd
se,sme
sg,sag
si,sin
sk,slk
sl,slv
sm,smo
sn,sna
so,som
sq,sqi
sr,srp
ss,ssw
st,sot
su,sun
sv,swe
sw,swa
ta,tam
te,tel
tg,tgk
th,tha
ti,tir
tk,tuk
tl,tgl
tn,tsn
to,ton
tr,tur
ts,tso
tt,tat
tw,twi
ty,tah
ug,uig
uk,ukr
ur,urd
uz,uzb
ve,ven
vi,vie
vo,vol
wa,wln
wo,wol
xh,xho
yi,yid
za,zha
zh,zho
zu,zul
1 aa aar
2 ab abk
3 ae ave
4 af afr
5 ak aka
6 am amh
7 an arg
8 ar ara
9 as asm
10 av ava
11 ay aym
12 az aze
13 ba bak
14 be bel
15 bg bul
16 bh bih
17 bi bis
18 bm bam
19 bn ben
20 bo bod
21 br bre
22 bs bos
23 ca cat
24 ce che
25 ch cha
26 co cos
27 cr cre
28 cs ces
29 cu chu
30 cv chv
31 cy cym
32 da dan
33 de deu
34 dv div
35 dz dzo
36 ee ewe
37 el ell
38 en eng
39 eo epo
40 es spa
41 et est
42 eu eus
43 fa fas
44 ff ful
45 fi fin
46 fj fij
47 fo fao
48 fr fra
49 fy fry
50 ga gle
51 gd gla
52 gl glg
53 gn grn
54 gu guj
55 gv glv
56 ha hau
57 he heb
58 hi hin
59 ho hmo
60 hr hrv
61 ht hat
62 hu hun
63 hy hye
64 hz her
65 ia ina
66 id ind
67 ie ile
68 ig ibo
69 ii iii
70 ik ipk
71 in ind
72 io ido
73 is isl
74 it ita
75 iu iku
76 iw heb
77 ja jpn
78 ji yid
79 jv jav
80 ka kat
81 kg kon
82 ki kik
83 kj kua
84 kk kaz
85 kl kal
86 km khm
87 kn kan
88 ko kor
89 kr kau
90 ks kas
91 ku kur
92 kv kom
93 kw cor
94 ky kir
95 la lat
96 lb ltz
97 lg lug
98 li lim
99 ln lin
100 lo lao
101 lt lit
102 lu lub
103 lv lav
104 mg mlg
105 mh mah
106 mi mri
107 mk mkd
108 ml mal
109 mn mon
110 mo mol
111 mr mar
112 ms msa
113 mt mlt
114 my mya
115 na nau
116 nb nob
117 nd nde
118 ne nep
119 ng ndo
120 nl nld
121 nn nno
122 no nor
123 nr nbl
124 nv nav
125 ny nya
126 oc oci
127 oj oji
128 om orm
129 or ori
130 os oss
131 pa pan
132 pi pli
133 pl pol
134 ps pus
135 pt por
136 qu que
137 rm roh
138 rn run
139 ro ron
140 ru rus
141 rw kin
142 sa san
143 sc srd
144 sd snd
145 se sme
146 sg sag
147 si sin
148 sk slk
149 sl slv
150 sm smo
151 sn sna
152 so som
153 sq sqi
154 sr srp
155 ss ssw
156 st sot
157 su sun
158 sv swe
159 sw swa
160 ta tam
161 te tel
162 tg tgk
163 th tha
164 ti tir
165 tk tuk
166 tl tgl
167 tn tsn
168 to ton
169 tr tur
170 ts tso
171 tt tat
172 tw twi
173 ty tah
174 ug uig
175 uk ukr
176 ur urd
177 uz uzb
178 ve ven
179 vi vie
180 vo vol
181 wa wln
182 wo wol
183 xh xho
184 yi yid
185 za zha
186 zh zho
187 zu zul
@@ -0,0 +1 @@
zh,cdo,cjy,cmn,cnp,cpx,csp,czh,czo,gan,hak,hnm,hsn,luh,lzh,mnp,nan,sjc,wuu,yue
1 zh cdo cjy cmn cnp cpx csp czh czo gan hak hnm hsn luh lzh mnp nan sjc wuu yue
@@ -0,0 +1,71 @@
aɐ
bq
cɔ
dp
eǝ
fɟ
gᵷ
hɥ
iᴉ
jɾ
kʞ
lꞁ
mɯ
nu
oo
pd
qb
rɹ
ss
tʇ
un
vʌ
wʍ
xx
yʎ
zz
AⱯ
Bᗺ
CƆ
Dᗡ
EƎ
FℲ
G⅁
HH
II
Jſ
Kʞ
LꞀ
MW
NN
OO
PԀ
QὉ
Rᴚ
SS
T⟘
U∩
VΛ
WM
XX
Yʎ
ZZ
00
1Ɩ
2ᘔ
3Ɛ
4ㄣ
5ϛ
69
7ㄥ
88
96
_‾
,'
;⸵
.˙
?¿
!¡
//
\\
',
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more