Compare commits

..
Author SHA1 Message Date
8d876fbff5 [release/3.6] 为不同类型的 Dialog 指定相应的默认标题 (#5671)
https://github.com/HMCL-dev/HMCL/pull/5079
https://github.com/HMCL-dev/HMCL/pull/5152
https://github.com/HMCL-dev/HMCL/pull/5633

Co-authored-by: neveler <55753029+neveler@users.noreply.github.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-03-23 22:07:21 +08:00
辞庐andGlavo 4c1f15c98c [release/3.6] 修复 CurseForge 源无法搜索到内容的问题 (#5733)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-03-08 22:13:54 +08:00
3gf8jv4dv e8b9d98c99 [release/3.6] chore: update copyright year and attribution (#5670)
https://github.com/HMCL-dev/HMCL/pull/5622
2026-03-07 21:50:58 +08:00
3gf8jv4dv 61b7eb34f6 [release/3.6] fix(l10n): replace mcedit with minecraft wiki's tutorial page (#5672)
https://github.com/HMCL-dev/HMCL/pull/5524
2026-03-07 21:39:05 +08:00
3gf8jv4dv a2df345b3c [release/3.6] style(l10n): simplify update button text in mod management header (#5673)
https://github.com/HMCL-dev/HMCL/pull/4712
2026-03-07 21:37:56 +08:00
3gf8jv4dv 70030a9558 [release/3.6] fix(i18n): update i18n for open dir button on instance mods tab (#5674)
https://github.com/HMCL-dev/HMCL/pull/4734
2026-03-03 21:45:00 +08:00
Glavoand3gf8jv4dv f03a4ff807 [release/3.6] 添加“提前预览 HMCL 版本”选项 (#4668)
https://github.com/HMCL-dev/HMCL/pull/4223
https://github.com/HMCL-dev/HMCL/pull/4598
https://github.com/HMCL-dev/HMCL/pull/4599

---------

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-10-13 15:38:29 +08:00
Glavo de9a105f2f [release/3.6] Update versions.txt to 1.21.10 (#4667)
https://github.com/HMCL-dev/HMCL/pull/4626
2025-10-13 15:27:48 +08:00
Glavo b5d3653c9a [release/3.6] 移除 Pack200 支持 (#4666)
https://github.com/HMCL-dev/HMCL/pull/4267
2025-10-13 15:23:53 +08:00
Glavo 554503fff0 [release/3.6] 在 CacheRepository.ETagItem 中添加 expires 字段 (#4662) 2025-10-13 15:12:51 +08:00
Glavo 785f334241 [release/3.6] 更新 Jenkins CI 服务器的发布模型 (#4582)
https://github.com/HMCL-dev/HMCL/pull/4559
2025-10-02 20:38:58 +08:00
GlavoandShulkerSakura 0b87959716 [release/3.6] Added 16 characters length detection of username (#4580)
https://github.com/HMCL-dev/HMCL/pull/4088

Co-authored-by: ShulkerSakura <50770360+ShulkerSakura@users.noreply.github.com>
2025-10-01 20:44:11 +08:00
Glavoand8Mi_Yile ae18ca7567 [release/3.6] 更新 unlisted-versions-of-minecraft 镜像源地址 (#4579)
https://github.com/HMCL-dev/HMCL/pull/4120

Co-authored-by: 8Mi_Yile <admin@8mi.tech>
2025-10-01 20:34:56 +08:00
Glavoand3gf8jv4dv 7ebdc783c6 [release/3.6] chore: update versions.txt to 1.21.9 (#4578)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-10-01 20:22:18 +08:00
Glavo 39abdf1ecf [release/3.6] 修复启用代理身份认证且未设置密码时启动器崩溃的问题 (#4536)
https://github.com/HMCL-dev/HMCL/pull/4465
2025-09-23 11:11:19 +08:00
Glavo 8df6563d56 [release/3.6] 修复滚动切换游戏实例/账户功能 (#4535)
https://github.com/HMCL-dev/HMCL/pull/4466
2025-09-22 22:39:38 +08:00
Glavo f62c12fd3c [release/3.6] 优化 Java 自动选择规则 (#4534)
https://github.com/HMCL-dev/HMCL/pull/4515
2025-09-22 22:32:11 +08:00
Glavo 1205212e52 [release/3.6] 修复使用 JavaFX 25 时游戏下载界面布局错误的问题 (#4533)
https://github.com/HMCL-dev/HMCL/pull/4508
2025-09-22 22:25:32 +08:00
Glavo 0d9a275fb3 [release/3.6] 检测到配置文件由高版本启动器创建时禁止保存配置文件 (#4449)
https://github.com/HMCL-dev/HMCL/pull/4414
2025-09-11 01:19:56 +08:00
Glavoand3gf8jv4dv ab72ac3d10 [release/3.6] chore(l10n): change translation from 版本 to 实例 in zh-cn for term INSTANCE (#4448)
https://github.com/HMCL-dev/HMCL/pull/4376

---------

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-09-11 00:24:53 +08:00
Glavoand辞庐 9d5f8a9f0e [release/3.6] 修复 LWJGL 路径显示错误的问题 (#4447)
https://github.com/HMCL-dev/HMCL/pull/4192

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2025-09-11 00:15:13 +08:00
GlavoandWulian233 fe2e96345b [release/3.6] 修复世界管理页面不支持显示极限模式的问题 (#4446)
https://github.com/HMCL-dev/HMCL/pull/4321

Co-authored-by: Wulian233 <1055917385@qq.com>
2025-09-11 00:05:23 +08:00
Glavoand辞庐 881fa2858f [release/3.6] 修复整合包格式不匹配提示未被正确格式化的问题 (#4445)
https://github.com/HMCL-dev/HMCL/pull/4176

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2025-09-11 00:01:11 +08:00
Glavo a0b95866be [release/3.6] Update microsoft_auth.html (#4444)
https://github.com/HMCL-dev/HMCL/pull/4388
2025-09-10 23:53:35 +08:00
Glavo 93bbef385d [release/3.6] 修复 RemoteMod::getIntegrityCheck 未选择正确的算法的问题 (#4443)
https://github.com/HMCL-dev/HMCL/pull/4238
2025-09-10 23:44:09 +08:00
Glavoand神奇的赵二 9dcc2727aa [release/3.6] 新增内存显示条禁用样式 (#4442)
https://github.com/HMCL-dev/HMCL/pull/4185

Co-authored-by: 神奇的赵二 (OrzMiku) <miku@ecy.pink>
2025-09-10 23:39:12 +08:00
Glavoand辞庐 865c8588d8 [release/3.6] 修复“下载线程数”的指示器被提示覆盖的问题 (#4441)
https://github.com/HMCL-dev/HMCL/pull/4153

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2025-09-10 23:34:52 +08:00
3gf8jv4dvand辞庐 419612bd3e [release/3.6] backport language file changes to the 3.6 branch (#4319)
https://github.com/HMCL-dev/HMCL/pull/3690
https://github.com/HMCL-dev/HMCL/pull/4149
https://github.com/HMCL-dev/HMCL/pull/4188
https://github.com/HMCL-dev/HMCL/pull/4189
https://github.com/HMCL-dev/HMCL/pull/4199
https://github.com/HMCL-dev/HMCL/pull/4200
https://github.com/HMCL-dev/HMCL/pull/4303

---------

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2025-09-10 23:34:13 +08:00
Glavo d16eded1ad [release/3.6] 弃用 Java 11 支持 (#4422) 2025-09-10 23:24:51 +08:00
Glavo c3a47655f0 [release/3.6] 更新模组和整合包数据 (#4440)
https://github.com/HMCL-dev/HMCL/pull/4439
2025-09-10 23:21:30 +08:00
Glavo 34842a5f26 [release/3.6] 修复无法加载 WebP 图标的问题 (#4436)
https://github.com/HMCL-dev/HMCL/pull/4171
2025-09-10 23:20:27 +08:00
Glavo 6dd7d53c20 [release/3.6] 修复 backgroundType 为 null 时启动器崩溃的问题 (#4438)
https://github.com/HMCL-dev/HMCL/pull/4437
2025-09-10 22:58:18 +08:00
Glavo 1654c652e0 [release/3.6] 在 ./.hmcl/java 中搜索 Java (#4434)
https://github.com/HMCL-dev/HMCL/pull/4179
2025-09-10 21:37:45 +08:00
Glavoand辞庐 881f5e7874 [release/3.6] 模组下载列表翻页后自动回到顶部 (#4433)
https://github.com/HMCL-dev/HMCL/pull/4170

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2025-09-10 21:28:03 +08:00
Glavo 73f0eb472a [release/3.6] 修复首次打开子页面时切换动画丢失的问题 (#4431)
https://github.com/HMCL-dev/HMCL/pull/4365
2025-09-10 21:17:00 +08:00
Glavo e13b8385e0 [release/3.6] 在 Windows 7/8/8.1 上使用 llvmpipe-loader 加载 mesa (#4430)
https://github.com/HMCL-dev/HMCL/pull/4224
2025-09-10 21:01:36 +08:00
Glavo a6e4651307 [release/3.6] 修复非 Windows 平台上游戏进程的 user.home 可能被设置为 null 的问题 (#4427)
https://github.com/HMCL-dev/HMCL/pull/4241
2025-09-10 20:55:33 +08:00
Glavo 554e573d7f [release/3.6] 支持通过环境变量强制使用 GPU 渲染和控制动画帧率 (#4426)
https://github.com/HMCL-dev/HMCL/pull/4164
2025-09-10 20:46:51 +08:00
Glavo 3096f933af [release/3.6] 修复导入异常格式的数据包时启动器崩溃的问题 (#4425)
https://github.com/HMCL-dev/HMCL/pull/4152
2025-09-10 20:41:29 +08:00
GlavoandWulian233 ff3b486f58 [release/3.6] 修复游戏版本列表错误地过滤掉部分版本的问题 (#4424)
https://github.com/HMCL-dev/HMCL/pull/4066
https://github.com/HMCL-dev/HMCL/pull/4244

---------

Co-authored-by: Wulian233 <1055917385@qq.com>
2025-09-10 20:36:01 +08:00
GlavoandRoj234 708e0326a7 [release/3.6] 修复多次启动游戏时内存泄露的问题 (#4423)
https://github.com/HMCL-dev/HMCL/pull/4121

Co-authored-by: Roj234 <82699138+roj234@users.noreply.github.com>
2025-09-10 20:27:12 +08:00
GlavoandWulian233 f7a5c7a310 [release/3.6] 修复 1.21.5 以上的地图跳转种子地图错误 (#4317)
https://github.com/HMCL-dev/HMCL/pull/4274

Co-authored-by: Wulian233 <1055917385@qq.com>
2025-08-22 20:48:56 +08:00
Glavo 633e8f8fe2 [release/3.6] Update amdgpu.ids (#4316) 2025-08-22 20:44:36 +08:00
Glavoand辞庐 440ea1b21d [release/3.6] fix: 无法从 BMCLAPI 下载 NeoForge 所有所需文件 (#4313)
https://github.com/HMCL-dev/HMCL/pull/4174

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2025-08-22 20:36:18 +08:00
493 changed files with 6727 additions and 23667 deletions

No files matched your search

-1
View File
@@ -1071,7 +1071,6 @@ 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
+25
View File
@@ -0,0 +1,25 @@
name: Check Style
on:
push:
paths:
- '**.java'
pull_request:
paths:
- '**.java'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '11'
java-package: 'jdk+fx'
- name: Check style main
run: ./gradlew checkstyleMain --no-daemon
- name: Check style test
run: ./gradlew checkstyleTest --no-daemon
@@ -1,13 +1,11 @@
name: Check Codes
name: Check Translations
on:
push:
paths:
- '**.java'
- '**.properties'
pull_request:
paths:
- '**.java'
- '**.properties'
jobs:
@@ -15,11 +13,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
java-version: '11'
java-package: 'jdk+fx'
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel
- name: Check style test
run: ./gradlew checkTranslations --no-daemon
+34 -15
View File
@@ -13,16 +13,26 @@ jobs:
if: ${{ github.repository_owner == 'HMCL-dev' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- uses: actions/checkout@v4
- name: Fetch tags
run: git fetch --all --tags
- name: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: ./gradlew checkUpdateDev --no-daemon --info --stacktrace
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
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
@@ -68,17 +78,26 @@ jobs:
needs: dev-check-update
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- uses: actions/checkout@v3
- name: Fetch tags
run: git fetch --all --tags
- name: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: ./gradlew checkUpdateStable --no-daemon --info --stacktrace
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
- 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
+5 -21
View File
@@ -13,11 +13,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
java-version: '11'
java-package: 'jdk+fx'
- name: Build with Gradle
run: ./gradlew build --no-daemon
@@ -27,24 +27,8 @@ jobs:
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload JAR
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
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
name: HMCL-${{ env.SHORT_SHA }}
path: HMCL/build/libs
+1 -7
View File
@@ -2,7 +2,6 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
*.hprof
.gradle
@@ -14,16 +13,13 @@ hs_err_pid*
/externalgames
NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
/.java/
# gradle build
/build/
/HMCL/build/
/HMCLCore/build/
/HMCLBoot/build/
/HMCLTransformerDiscoveryService/build/
/minecraft/libraries/HMCLTransformerDiscoveryService/build/
/minecraft/libraries/HMCLMultiMCBootstrap/build/
/buildSrc/build/
# idea
@@ -32,14 +28,12 @@ hmcl-exported-logs-*
/HMCL/out/
/HMCLCore/out/
/minecraft/libraries/HMCLTransformerDiscoveryService/out/
/minecraft/libraries/HMCLMultiMCBootstrap/out/
# eclipse
/bin/
/HMCL/bin/
/HMCLCore/bin/
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
.classpath
.project
.settings
+70 -150
View File
@@ -1,8 +1,7 @@
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.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
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
@@ -16,45 +15,46 @@ 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 embedResources by configurations.registering
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"
}
}
dependencies {
implementation(project(":HMCLCore"))
implementation(project(":HMCLBoot"))
implementation("libs:JFoenix")
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
if (launcherExe == null) {
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
}
embedResources(libs.authlib.injector)
}
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -98,64 +98,18 @@ fun attachSignature(jar: File) {
}
}
tasks.withType<JavaCompile> {
sourceCompatibility = "17"
targetCompatibility = "17"
val java11 = sourceSets.create("java11") {
java {
srcDir("src/main/java11")
}
}
tasks.checkstyleMain {
// Third-party code is not checked
exclude("**/org/jackhuang/hmcl/ui/image/apng/**")
}
tasks.compileJava {
tasks.getByName<JavaCompile>(java11.compileJavaTaskName) {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
sourceCompatibility = "11"
targetCompatibility = "11"
}
val hmclProperties = buildList {
add("hmcl.version" to project.version.toString())
System.getenv("GITHUB_SHA")?.let {
add("hmcl.version.hash" to it)
}
add("hmcl.version.type" to versionType)
add("hmcl.microsoft.auth.id" to microsoftAuthId)
add("hmcl.microsoft.auth.secret" to microsoftAuthSecret)
add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
}
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
val createPropertiesFile by tasks.registering {
outputs.file(hmclPropertiesFile)
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
doLast {
val targetFile = hmclPropertiesFile.get().asFile
targetFile.parentFile.mkdir()
targetFile.bufferedWriter().use {
for ((k, v) in hmclProperties) {
it.write("$k=$v\n")
}
}
}
}
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"])
@@ -164,8 +118,6 @@ tasks.jar {
val jarPath = tasks.jar.get().archiveFile.get().asFile
tasks.shadowJar {
dependsOn(createPropertiesFile)
archiveClassifier.set(null as String?)
exclude("**/package-info.class")
@@ -175,7 +127,7 @@ tasks.shadowJar {
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
listOf(
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*","freebsd-*", "linux-*", "darwin-*",
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
).forEach { exclude("com/sun/jna/$it/**") }
@@ -183,19 +135,42 @@ tasks.shadowJar {
exclude(dependency("com.google.code.gson:.*:.*"))
exclude(dependency("net.java.dev.jna:jna:.*"))
exclude(dependency("libs:JFoenix:.*"))
exclude(project(":HMCLBoot"))
}
manifest.attributes(
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
"Implementation-Version" to project.version.toString(),
"Main-Class" to "org.jackhuang.hmcl.Main",
"Multi-Release" to "true",
"Add-Opens" to addOpens.joinToString(" "),
"Enable-Native-Access" to "ALL-UNNAMED"
)
manifest {
attributes(
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
"Main-Class" to "org.jackhuang.hmcl.Main",
"Multi-Release" to "true",
"Implementation-Version" to project.version,
"Microsoft-Auth-Id" to microsoftAuthId,
"Microsoft-Auth-Secret" to microsoftAuthSecret,
"CurseForge-Api-Key" to curseForgeApiKey,
"Build-Channel" to versionType,
"Add-Opens" to 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",
).joinToString(" "),
"Enable-Native-Access" to "ALL-UNNAMED"
)
if (launcherExe != null) {
System.getenv("GITHUB_SHA")?.also {
attributes("GitHub-SHA" to it)
}
}
if (launcherExe.isNotBlank()) {
into("assets") {
from(file(launcherExe))
}
@@ -208,21 +183,10 @@ 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 })
into("META-INF/versions/11") {
from(sourceSets["java11"].output)
}
dependsOn(tasks["java11Classes"])
}
val makeExecutables by tasks.registering {
@@ -307,16 +271,6 @@ fun parseToolOptions(options: String?): MutableList<String> {
return result
}
// For IntelliJ IDEA
tasks.withType<JavaExec> {
if (name != "run") {
jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" })
// if (javaVersion >= JavaVersion.VERSION_24) {
// jvmArgs("--enable-native-access=ALL-UNNAMED")
// }
}
}
tasks.register<JavaExec>("run") {
dependsOn(tasks.jar)
@@ -345,40 +299,6 @@ tasks.register<JavaExec>("run") {
}
}
// Check Translations
tasks.register<CheckTranslations>("checkTranslations") {
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
englishFile.set(dir.file("I18N.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
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") {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

@@ -19,7 +19,6 @@ package org.jackhuang.hmcl;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ObservableBooleanValue;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
@@ -28,7 +27,6 @@ import javafx.scene.input.DataFormat;
import javafx.stage.Stage;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.SambaException;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.task.Schedulers;
@@ -56,8 +54,6 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
@@ -75,21 +71,23 @@ public final class Launcher extends Application {
CookieHandler.setDefault(COOKIE_MANAGER);
LOG.info("JavaFX Version: " + System.getProperty("javafx.runtime.version"));
LOG.info("Prism Pipeline: " + FXUtils.GRAPHICS_PIPELINE);
LOG.info("Dark Mode: " + Optional.ofNullable(FXUtils.DARK_MODE).map(ObservableBooleanValue::get).orElse(false));
LOG.info("Reduced Motion: " + Objects.requireNonNullElse(FXUtils.REDUCED_MOTION, false));
try {
Object pipeline = Class.forName("com.sun.prism.GraphicsPipeline").getMethod("getPipeline").invoke(null);
LOG.info("Prism pipeline: " + (pipeline == null ? "null" : pipeline.getClass().getName()));
} catch (Throwable e) {
LOG.warning("Failed to get prism pipeline", e);
}
try {
try {
ConfigHolder.init();
} catch (SambaException e) {
showAlert(AlertType.WARNING, i18n("fatal.samba"));
} catch (SambaException ignored) {
Main.showWarningAndContinue(i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigInTempDir();
checkConfigOwner();
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
EntryPoint.exit(1);
Main.showErrorAndExit(i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
}
// https://lapcatsoftware.com/articles/app-translocation.html
@@ -112,7 +110,7 @@ public final class Launcher extends Application {
}
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
Main.showWarningAndContinue(i18n("fatal.illegal_char"));
}
// runLater to ensure ConfigHolder.init() finished initialization
@@ -175,7 +173,7 @@ public final class Launcher extends Application {
private static void checkConfigInTempDir() {
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
&& showAlert(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) {
EntryPoint.exit(0);
Main.exit(0);
}
}
@@ -215,7 +213,7 @@ public final class Launcher extends Application {
Clipboard.getSystemClipboard()
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
}
EntryPoint.exit(1);
Main.exit(1);
}
@Override
@@ -237,8 +235,8 @@ public final class Launcher extends Application {
try {
LOG.info("*** " + Metadata.TITLE + " ***");
LOG.info("Operating System: " + (OperatingSystem.OS_RELEASE_PRETTY_NAME == null
? OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion()
: OperatingSystem.OS_RELEASE_PRETTY_NAME + " (" + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() + ')'));
? OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION
: OperatingSystem.OS_RELEASE_PRETTY_NAME + " (" + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION + ')'));
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
LOG.info("Processor Identifier: " + System.getenv("PROCESSOR_IDENTIFIER"));
}
@@ -17,25 +17,41 @@
*/
package org.jackhuang.hmcl;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.ui.AwtUtils;
import org.jackhuang.hmcl.util.ModuleHelper;
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
import org.jackhuang.hmcl.util.SwingUtils;
import org.jackhuang.hmcl.ui.SwingUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.File;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.Collections;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class EntryPoint {
public final class Main {
private EntryPoint() {
private Main() {
}
public static void main(String[] args) {
@@ -63,6 +79,10 @@ public final class EntryPoint {
checkDirectoryPath();
if (JavaRuntime.CURRENT_VERSION < 9)
// This environment check will take ~300ms
thread(Main::fixLetsEncrypt, "CA Certificate Check", true);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
initIcon();
@@ -92,8 +112,6 @@ public final class EntryPoint {
}
}
} catch (IOException e) {
// Logger has not been started yet, so print directly to System.err
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
e.printStackTrace(System.err);
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY));
}
@@ -109,23 +127,16 @@ public final class EntryPoint {
}
private static void initIcon() {
try {
if (java.awt.Taskbar.isTaskbarSupported()) {
var image = java.awt.Toolkit.getDefaultToolkit().getImage(EntryPoint.class.getResource("/assets/img/icon-mac.png"));
java.awt.Taskbar.getTaskbar().setIconImage(image);
}
} catch (Throwable e) {
LOG.warning("Failed to set application icon", e);
}
java.awt.Image image = java.awt.Toolkit.getDefaultToolkit().getImage(Main.class.getResource("/assets/img/icon-mac.png"));
AwtUtils.setAppleIcon(image);
}
private static void checkDirectoryPath() {
String currentDir = System.getProperty("user.dir", "");
if (currentDir.contains("!")) {
LOG.error("The current working path contains an exclamation mark: " + currentDir);
String currentDirectory = new File("").getAbsolutePath();
if (currentDirectory.contains("!")) {
// No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path.
showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n"
+ "The path is " + currentDir);
+ "The path is " + currentDirectory);
}
}
@@ -133,10 +144,10 @@ public final class EntryPoint {
try {
SelfDependencyPatcher.patch();
} catch (SelfDependencyPatcher.PatchException e) {
LOG.error("Unable to patch JVM", e);
LOG.error("unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.missing"));
} catch (SelfDependencyPatcher.IncompatibleVersionException e) {
LOG.error("Unable to patch JVM", e);
LOG.error("unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.incompatible"));
} catch (CancellationException e) {
LOG.error("User cancels downloading JavaFX", e);
@@ -153,7 +164,7 @@ public final class EntryPoint {
Class.forName("javafx.stage.Stage"); // javafx.graphics
Class.forName("javafx.scene.control.Skin"); // javafx.controls
} catch (Exception e) {
LOG.warning("JavaFX is incomplete or not found", e);
e.printStackTrace(System.err);
showErrorAndExit(i18n("fatal.javafx.incomplete"));
}
}
@@ -161,20 +172,9 @@ public final class EntryPoint {
private static void addEnableNativeAccess() {
if (JavaRuntime.CURRENT_VERSION > 21) {
try {
// javafx.graphics
Module module = Class.forName("javafx.stage.Stage").getModule();
if (module.isNamed()) {
try {
MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(Module.class, MethodHandles.lookup());
MethodHandle implAddEnableNativeAccess = lookup.findVirtual(Module.class,
"implAddEnableNativeAccess", MethodType.methodType(Module.class));
Module ignored = (Module) implAddEnableNativeAccess.invokeExact(module);
} catch (Throwable e) {
e.printStackTrace(System.err);
}
}
ModuleHelper.addEnableNativeAccess(Class.forName("javafx.stage.Stage")); // javafx.graphics
} catch (ClassNotFoundException e) {
LOG.error("Failed to add enable native access for JavaFX", e);
e.printStackTrace(System.err);
showErrorAndExit(i18n("fatal.javafx.incomplete"));
}
}
@@ -185,11 +185,11 @@ public final class EntryPoint {
if (JavaRuntime.CURRENT_VERSION == 24 || JavaRuntime.CURRENT_VERSION == 25) {
try {
Class<?> clazz = Class.forName("sun.misc.Unsafe");
boolean ignored = (boolean) MethodHandles.privateLookupIn(clazz, MethodHandles.lookup())
.findStatic(clazz, "trySetMemoryAccessWarned", MethodType.methodType(boolean.class))
.invokeExact();
Method trySetMemoryAccessWarned = clazz.getDeclaredMethod("trySetMemoryAccessWarned");
trySetMemoryAccessWarned.setAccessible(true);
trySetMemoryAccessWarned.invoke(null);
} catch (Throwable e) {
LOG.warning("Failed to enable unsafe memory access", e);
e.printStackTrace(System.err);
}
}
}
@@ -197,8 +197,70 @@ public final class EntryPoint {
/**
* Indicates that a fatal error has occurred, and that the application cannot start.
*/
private static void showErrorAndExit(String message) {
static void showErrorAndExit(String message) {
System.err.println(message);
System.err.println("A fatal error has occurred, forcibly exiting.");
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.ERROR, message).showAndWait();
exit(1);
}
} catch (Throwable ignored) {
}
SwingUtils.showErrorDialog(message);
exit(1);
}
/**
* Indicates that potential issues have been detected, and that the application may not function properly (but it can still run).
*/
static void showWarningAndContinue(String message) {
System.err.println(message);
System.err.println("Potential issues have been detected.");
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.WARNING, message).showAndWait();
return;
}
} catch (Throwable ignored) {
}
SwingUtils.showWarningDialog(message);
}
private static void fixLetsEncrypt() {
try {
KeyStore defaultKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
Path ksPath = Paths.get(System.getProperty("java.home"), "lib", "security", "cacerts");
try (InputStream ksStream = Files.newInputStream(ksPath)) {
defaultKeyStore.load(ksStream, "changeit".toCharArray());
}
KeyStore letsEncryptKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
try (InputStream letsEncryptFile = Main.class.getResourceAsStream("/assets/lekeystore.jks")) {
letsEncryptKeyStore.load(letsEncryptFile, "supersecretpassword".toCharArray());
}
KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType());
merged.load(null, new char[0]);
for (String alias : Collections.list(letsEncryptKeyStore.aliases()))
merged.setCertificateEntry(alias, letsEncryptKeyStore.getCertificate(alias));
for (String alias : Collections.list(defaultKeyStore.aliases()))
merged.setCertificateEntry(alias, defaultKeyStore.getCertificate(alias));
TrustManagerFactory instance = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
instance.init(merged);
SSLContext tls = SSLContext.getInstance("TLS");
tls.init(null, instance.getTrustManagers(), null);
HttpsURLConnection.setDefaultSSLSocketFactory(tls.getSocketFactory());
LOG.info("Added Lets Encrypt root certificates as additional trust");
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException |
KeyManagementException e) {
LOG.error("Failed to load lets encrypt certificate. Expect problems", e);
}
}
}
@@ -36,15 +36,14 @@ public final class Metadata {
public static final String NAME = "HMCL";
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getManifestAttribute("Implementation-Version", "@develop@"));
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 8;
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17;
public static final String TITLE = NAME + " " + VERSION;
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17;
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17;
public static final int RECOMMENDED_JAVA_VERSION = 21;
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
public static final String ABOUT_URL = PUBLISH_URL + "/about";
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
@@ -56,8 +55,8 @@ public final class Metadata {
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529";
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
public static final String BUILD_CHANNEL = JarUtils.getManifestAttribute("Build-Channel", "nightly");
public static final String GITHUB_SHA = JarUtils.getManifestAttribute("GitHub-SHA", null);
public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
@@ -118,6 +117,7 @@ public final class Metadata {
supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.ARM64);
else
supportedArchitectures = EnumSet.noneOf(Architecture.class);
if (supportedArchitectures.contains(Architecture.SYSTEM_ARCH))
return String.format("https://docs.hmcl.net/downloads/%s/%s.html",
OperatingSystem.CURRENT_OS.getCheckedName(),
@@ -42,7 +42,7 @@ public class CrashReport {
"\n Content: \n " +
stackTrace + "\n\n" +
"-- System Details --\n" +
" Operating System: " + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() + "\n" +
" Operating System: " + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION + "\n" +
" System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName() + "\n" +
" Java Architecture: " + Architecture.CURRENT_ARCH.getDisplayName() + "\n" +
" Java Version: " + System.getProperty("java.version") + ", " + System.getProperty("java.vendor") + "\n" +
@@ -21,17 +21,15 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.launch.DefaultLauncher;
import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
import java.util.Map;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
@@ -60,82 +58,61 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
private void generateOptionsTxt() {
if (config().isDisableAutoGameOptions())
File optionsFile = new File(repository.getRunDirectory(version.getId()), "options.txt");
File configFolder = new File(repository.getRunDirectory(version.getId()), "config");
if (optionsFile.exists()) {
return;
}
Path runDir = repository.getRunDirectory(version.getId());
Path optionsFile = runDir.resolve("options.txt");
Path configFolder = runDir.resolve("config");
if (Files.exists(optionsFile))
return;
if (Files.isDirectory(configFolder)) {
try (Stream<Path> stream = Files.walk(configFolder, 2, FileVisitOption.FOLLOW_LINKS)) {
if (stream.anyMatch(file -> "options.txt".equals(FileUtils.getName(file))))
return;
} catch (IOException e) {
LOG.warning("Failed to visit config folder", e);
if (configFolder.isDirectory()) {
if (findFiles(configFolder, "options.txt")) {
return;
}
}
Locale locale = Locale.getDefault();
if (!I18n.isUseChinese()) {
return;
}
String lang;
/*
* 1.0 : No language option, do not set for these versions
* 1.1 ~ 1.5 : zh_CN works fine, zh_cn will crash (the last two letters must be uppercase, otherwise it will cause an NPE crash)
* 1.6 ~ 1.10 : zh_CN works fine, zh_cn will automatically switch to English
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
1.0- :没有语言选项,遇到这些版本时不设置
1.1 ~ 5 :zh_CN 时正常,zh_cn 时崩溃(最后两位字母必须大写,否则将会 NPE 崩溃)
1.6 ~ 10 :zh_CN 时正常,zh_cn 时自动切换为英文
1.11 ~ 12:zh_cn 时正常,zh_CN 时虽然显示了中文但语言设置会错误地显示选择英文
1.13+ :zh_cn 时正常,zh_CN 时自动切换为英文
*/
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
if (gameVersion.compareTo("1.1") < 0)
return;
if (gameVersion.compareTo("1.1") < 0) {
lang = null;
} else if (gameVersion.compareTo("1.11") < 0) {
lang = "zh_CN";
} else {
lang = "zh_cn";
}
String lang = normalizedLanguageTag(locale, gameVersion);
if (lang.isEmpty())
return;
if (gameVersion.compareTo("1.11") >= 0)
lang = lang.toLowerCase(Locale.ROOT);
try {
Files.createDirectories(optionsFile.getParent());
Files.writeString(optionsFile, String.format("lang:%s\n", lang));
} catch (IOException e) {
LOG.warning("Unable to generate options.txt", e);
if (lang != null) {
try {
FileUtils.writeText(optionsFile, String.format("lang:%s\n", lang));
} catch (IOException e) {
LOG.warning("Unable to generate options.txt", e);
}
}
}
private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) {
String region = locale.getCountry();
return switch (LocaleUtils.getRootLanguage(locale)) {
case "es" -> "es_ES";
case "ja" -> "ja_JP";
case "ru" -> "ru_RU";
case "uk" -> "uk_UA";
case "zh" -> {
if ("lzh".equals(locale.getLanguage()) && gameVersion.compareTo("1.16") >= 0)
yield "lzh";
String script = LocaleUtils.getScript(locale);
if ("Hant".equals(script)) {
if ((region.equals("HK") || region.equals("MO") && gameVersion.compareTo("1.16") >= 0))
yield "zh_HK";
yield "zh_TW";
}
yield "zh_CN";
private boolean findFiles(File folder, String fileName) {
File[] fs = folder.listFiles();
if (fs != null) {
for (File f : fs) {
if (f.isDirectory())
if (f.listFiles((dir, name) -> name.equals(fileName)) != null)
return true;
if (f.getName().equals(fileName))
return true;
}
case "en" -> {
if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) {
yield "en_UD";
}
yield "";
}
default -> "";
};
}
return false;
}
@Override
@@ -145,7 +122,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
@Override
public void makeLaunchScript(Path scriptFile) throws IOException {
public void makeLaunchScript(File scriptFile) throws IOException {
generateOptionsTxt();
super.makeLaunchScript(scriptFile);
}
@@ -44,9 +44,9 @@ import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
@@ -57,7 +57,7 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
public final class HMCLGameRepository extends DefaultGameRepository {
public class HMCLGameRepository extends DefaultGameRepository {
private final Profile profile;
// local version settings
@@ -66,7 +66,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
public final EventManager<Event> onVersionIconChanged = new EventManager<>();
public HMCLGameRepository(Profile profile, Path baseDirectory) {
public HMCLGameRepository(Profile profile, File baseDirectory) {
super(baseDirectory);
this.profile = profile;
}
@@ -85,20 +85,18 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
@Override
public Path getRunDirectory(String id) {
public File getRunDirectory(String id) {
switch (getGameDirectoryType(id)) {
case VERSION_FOLDER:
return getVersionRoot(id);
case ROOT_FOLDER:
return super.getRunDirectory(id);
case CUSTOM:
try {
return Path.of(getVersionSetting(id).getGameDir());
} catch (InvalidPathException ignored) {
return getVersionRoot(id);
}
File dir = new File(getVersionSetting(id).getGameDir());
if (!FileUtils.isValidPath(dir)) return getVersionRoot(id);
return dir;
default:
throw new AssertionError("Unreachable");
throw new Error();
}
}
@@ -121,24 +119,25 @@ public final class HMCLGameRepository extends DefaultGameRepository {
});
try {
Path file = getBaseDirectory().resolve("launcher_profiles.json");
if (!Files.exists(file) && !versions.isEmpty()) {
Files.createDirectories(file.getParent());
Files.writeString(file, PROFILE);
}
File file = new File(getBaseDirectory(), "launcher_profiles.json");
if (!file.exists() && !versions.isEmpty())
FileUtils.writeText(file, PROFILE);
} catch (IOException ex) {
LOG.warning("Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex);
}
// https://github.com/HMCL-dev/HMCL/issues/938
System.gc();
}
public void changeDirectory(Path newDirectory) {
public void changeDirectory(File newDirectory) {
setBaseDirectory(newDirectory);
refreshVersionsAsync().start();
}
private void clean(Path directory) throws IOException {
FileUtils.deleteDirectory(directory.resolve("crash-reports"));
FileUtils.deleteDirectory(directory.resolve("logs"));
private void clean(File directory) throws IOException {
FileUtils.deleteDirectory(new File(directory, "crash-reports"));
FileUtils.deleteDirectory(new File(directory, "logs"));
}
public void clean(String id) throws IOException {
@@ -147,8 +146,8 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException {
Path srcDir = getVersionRoot(srcId);
Path dstDir = getVersionRoot(dstId);
Path srcDir = getVersionRoot(srcId).toPath();
Path dstDir = getVersionRoot(dstId).toPath();
Version fromVersion = getVersion(srcId);
@@ -159,8 +158,6 @@ public final class HMCLGameRepository extends DefaultGameRepository {
blackList.add("saves");
if (Files.exists(dstDir)) throw new IOException("Version exists");
Files.createDirectories(dstDir);
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
Path fromJson = srcDir.resolve(srcId + ".json");
@@ -173,7 +170,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
Files.copy(fromJson, toJson);
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId));
FileUtils.writeText(toJson.toFile(), JsonUtils.GSON.toJson(fromVersion.setId(dstId)));
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
@@ -182,22 +179,22 @@ public final class HMCLGameRepository extends DefaultGameRepository {
VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting);
saveVersionSetting(dstId);
Path srcGameDir = getRunDirectory(srcId);
Path dstGameDir = getRunDirectory(dstId);
File srcGameDir = getRunDirectory(srcId);
File dstGameDir = getRunDirectory(dstId);
if (originalGameDirType != GameDirectoryType.VERSION_FOLDER)
FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null));
FileUtils.copyDirectory(srcGameDir.toPath(), dstGameDir.toPath(), path -> Modpack.acceptFile(path, blackList, null));
}
private Path getLocalVersionSettingFile(String id) {
return getVersionRoot(id).resolve("hmclversion.cfg");
private File getLocalVersionSettingFile(String id) {
return new File(getVersionRoot(id), "hmclversion.cfg");
}
private void loadLocalVersionSetting(String id) {
Path file = getLocalVersionSettingFile(id);
if (Files.exists(file))
File file = getLocalVersionSettingFile(id);
if (file.exists())
try {
VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class);
VersionSetting versionSetting = GSON.fromJson(FileUtils.readText(file), VersionSetting.class);
initLocalVersionSetting(id, versionSetting);
} catch (Exception ex) {
// If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated.
@@ -260,12 +257,12 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return vs;
}
public Optional<Path> getVersionIconFile(String id) {
Path root = getVersionRoot(id);
public Optional<File> getVersionIconFile(String id) {
File root = getVersionRoot(id);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = root.resolve("icon." + extension);
if (Files.exists(file)) {
File file = new File(root, "icon." + extension);
if (file.exists()) {
return Optional.of(file);
}
}
@@ -273,7 +270,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return Optional.empty();
}
public void setVersionIconFile(String id, Path iconFile) throws IOException {
public void setVersionIconFile(String id, File iconFile) throws IOException {
String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) {
throw new IllegalArgumentException("Unsupported icon file: " + ext);
@@ -281,18 +278,13 @@ public final class HMCLGameRepository extends DefaultGameRepository {
deleteIconFile(id);
FileUtils.copyFile(iconFile, getVersionRoot(id).resolve("icon." + ext));
FileUtils.copyFile(iconFile, new File(getVersionRoot(id), "icon." + ext));
}
public void deleteIconFile(String id) {
Path root = getVersionRoot(id);
File root = getVersionRoot(id);
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = root.resolve("icon." + extension);
try {
Files.deleteIfExists(file);
} catch (IOException e) {
LOG.warning("Failed to delete icon file: " + file, e);
}
new File(root, "icon." + extension).delete();
}
}
@@ -305,10 +297,10 @@ public final class HMCLGameRepository extends DefaultGameRepository {
if (iconType == VersionIconType.DEFAULT) {
Version version = getVersion(id).resolve(this);
Optional<Path> iconFile = getVersionIconFile(id);
Optional<File> iconFile = getVersionIconFile(id);
if (iconFile.isPresent()) {
try {
return FXUtils.loadImage(iconFile.get());
return FXUtils.loadImage(iconFile.get().toPath());
} catch (Exception e) {
LOG.warning("Failed to load version icon of " + id, e);
}
@@ -320,8 +312,6 @@ public final class HMCLGameRepository extends DefaultGameRepository {
return VersionIconType.FABRIC.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE))
return VersionIconType.FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM))
return VersionIconType.CLEANROOM.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE))
return VersionIconType.NEO_FORGE.getIcon();
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT))
@@ -343,7 +333,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
public void saveVersionSetting(String id) {
if (!localVersionSettings.containsKey(id))
return;
Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize();
Path file = getLocalVersionSettingFile(id).toPath().toAbsolutePath().normalize();
try {
Files.createDirectories(file.getParent());
} catch (IOException e) {
@@ -377,7 +367,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
vs.setUsesGlobal(true);
}
public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
public LaunchOptions getLaunchOptions(String version, JavaRuntime javaVersion, File gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
VersionSetting vs = getVersionSetting(version);
LaunchOptions.Builder builder = new LaunchOptions.Builder()
@@ -388,7 +378,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs()))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs()))
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory(
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int)(getAllocatedMemory(
vs.getMaxMemory() * 1024L * 1024L,
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
vs.isAutoMemory()
@@ -413,7 +403,6 @@ public final class HMCLGameRepository extends DefaultGameRepository {
.setPreLaunchCommand(vs.getPreLaunchCommand())
.setPostExitCommand(vs.getPostExitCommand())
.setNoGeneratedJVMArgs(vs.isNoJVMArgs())
.setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs())
.setNativesDirType(vs.getNativesDirType())
.setNativesDir(vs.getNativesDir())
.setProcessPriority(vs.getProcessPriority())
@@ -435,15 +424,15 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
}
Path json = getModpackConfiguration(version);
if (Files.exists(json)) {
File json = getModpackConfiguration(version);
if (json.exists()) {
try {
String jsonText = Files.readString(json);
String jsonText = FileUtils.readText(json);
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to parse modpack configuration file " + json, e);
e.printStackTrace();
}
}
@@ -454,8 +443,8 @@ public final class HMCLGameRepository extends DefaultGameRepository {
}
@Override
public Path getModpackConfiguration(String version) {
return getVersionRoot(version).resolve("modpack.cfg");
public File getModpackConfiguration(String version) {
return new File(getVersionRoot(version), "modpack.cfg");
}
public void markVersionAsModpack(String id) {
@@ -468,24 +457,16 @@ public final class HMCLGameRepository extends DefaultGameRepository {
public void markVersionLaunchedAbnormally(String id) {
try {
Files.createFile(getVersionRoot(id).resolve(".abnormal"));
Files.createFile(getVersionRoot(id).toPath().resolve(".abnormal"));
} catch (IOException ignored) {
}
}
public boolean unmarkVersionLaunchedAbnormally(String id) {
Path file = getVersionRoot(id).resolve(".abnormal");
if (Files.isRegularFile(file)) {
try {
Files.delete(file);
} catch (IOException e) {
LOG.warning("Failed to delete abnormal mark file: " + file, e);
}
return true;
} else {
return false;
}
File file = new File(getVersionRoot(id), ".abnormal");
boolean result = file.isFile();
file.delete();
return result;
}
private static final Gson GSON = new GsonBuilder()
@@ -507,7 +488,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
return false;
return FileUtils.isNameValid(id);
return OperatingSystem.isNameValid(id);
}
/**
@@ -28,16 +28,16 @@ import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class HMCLModpackInstallTask extends Task<Void> {
private final Path zipFile;
private final File zipFile;
private final String name;
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependency;
@@ -45,16 +45,16 @@ public final class HMCLModpackInstallTask extends Task<Void> {
private final List<Task<?>> dependencies = new ArrayList<>(1);
private final List<Task<?>> dependents = new ArrayList<>(4);
public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) {
public HMCLModpackInstallTask(Profile profile, File zipFile, Modpack modpack, String name) {
dependency = profile.getDependency();
repository = profile.getRepository();
this.zipFile = zipFile;
this.name = name;
this.modpack = modpack;
Path run = repository.getRunDirectory(name);
Path json = repository.getModpackConfiguration(name);
if (repository.hasVersion(name) && Files.notExists(json))
File run = repository.getRunDirectory(name);
File json = repository.getModpackConfiguration(name);
if (repository.hasVersion(name) && !json.exists())
throw new IllegalArgumentException("Version " + name + " already exists");
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
@@ -65,8 +65,8 @@ public final class HMCLModpackInstallTask extends Task<Void> {
ModpackConfiguration<Modpack> config = null;
try {
if (Files.exists(json)) {
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
if (json.exists()) {
config = JsonUtils.GSON.fromJson(FileUtils.readText(json), ModpackConfiguration.typeOf(Modpack.class));
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Path;
@@ -48,14 +49,15 @@ public final class HMCLModpackProvider implements ModpackProvider {
}
@Override
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, File zipFile, Modpack modpack) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
if (!(dependencyManager.getGameRepository() instanceof HMCLGameRepository repository)) {
if (!(dependencyManager.getGameRepository() instanceof HMCLGameRepository)) {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
HMCLGameRepository repository = (HMCLGameRepository) dependencyManager.getGameRepository();
Profile profile = repository.getProfile();
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
@@ -77,9 +79,9 @@ public final class HMCLModpackProvider implements ModpackProvider {
return manifest;
}
private final static class HMCLModpack extends Modpack {
private static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) {
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, File zipFile, String name) {
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
}
}
@@ -49,12 +49,12 @@ import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URL;
import java.nio.file.AccessDeniedException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
@@ -76,7 +76,7 @@ public final class LauncherHelper {
private final Profile profile;
private Account account;
private final String selectedVersion;
private Path scriptFile;
private File scriptFile;
private final VersionSetting setting;
private LauncherVisibility launcherVisibility;
private boolean showLogs;
@@ -119,8 +119,9 @@ public final class LauncherHelper {
launch0();
}
public void makeLaunchScript(Path scriptFile) {
public void makeLaunchScript(File scriptFile) {
this.scriptFile = Objects.requireNonNull(scriptFile);
launch();
}
@@ -159,17 +160,19 @@ public final class LauncherHelper {
}),
Task.composeAsync(() -> {
Renderer renderer = setting.getRenderer();
if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
if (renderer != Renderer.DEFAULT
&& OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
&& OperatingSystem.WINDOWS_VERSION != null) {
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.WINDOWS_VERSION);
if (lib == null)
return null;
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
File file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
if (file.getAbsolutePath().indexOf('=') >= 0) {
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
return null;
}
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.name().toLowerCase(Locale.ROOT);
String agent = file.getAbsolutePath() + "=" + renderer.name().toLowerCase(Locale.ROOT);
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
return new LibraryDownloadTask(dependencyManager, file, lib)
@@ -223,7 +226,7 @@ public final class LauncherHelper {
} else {
runLater(() -> {
launchingStepsPane.fireEvent(new DialogCloseEvent());
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
Controllers.dialog(i18n("version.launch_script.success", scriptFile.getAbsolutePath()));
});
}
}).withFakeProgress(
@@ -266,27 +269,27 @@ public final class LauncherHelper {
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
URL url = rce.getUrl();
if (responseCode == 404)
message += i18n("download.code.404", uri);
message += i18n("download.code.404", url);
else
message += i18n("download.failed", uri, responseCode);
message += i18n("download.failed", url, responseCode);
} else {
message += StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof DownloadException) {
URI uri = ((DownloadException) ex).getUri();
URL url = ((DownloadException) ex).getUrl();
if (ex.getCause() instanceof SocketTimeoutException) {
message = i18n("install.failed.downloading.timeout", uri);
message = i18n("install.failed.downloading.timeout", url);
} else if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
message = i18n("assets.index.malformed");
@@ -297,11 +300,11 @@ public final class LauncherHelper {
} else if (ex instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex;
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
URL url = rce.getUrl();
if (responseCode == 404)
message = i18n("download.code.404", uri);
message = i18n("download.code.404", url);
else
message = i18n("download.failed", uri, responseCode);
message = i18n("download.failed", url, responseCode);
} else if (ex instanceof CommandTooLongException) {
message = i18n("launch.failed.command_too_long");
} else if (ex instanceof ExecutionPolicyLimitException) {
@@ -449,9 +452,7 @@ public final class LauncherHelper {
return result;
} else {
GameJavaVersion gameJavaVersion;
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM_JAVA_21))
gameJavaVersion = GameJavaVersion.JAVA_21;
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = version.getJavaVersion();
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
@@ -541,9 +542,6 @@ public final class LauncherHelper {
case MODDED_JAVA_21:
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
break;
case CLEANROOM_JAVA_21:
suggestions.add(i18n("launch.advice.cleanroom"));
break;
case VANILLA_JAVA_8_51:
suggestions.add(i18n("launch.advice.java8_51_1_13"));
break;
@@ -17,7 +17,6 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
@@ -42,9 +41,9 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
public SearchResult search(String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (!StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
@@ -66,7 +65,7 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
SearchResult searchResult = getBackedRemoteModRepository().search(gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
@@ -44,8 +44,8 @@ public final class LogExporter {
}
public static CompletableFuture<Void> exportLogs(Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) {
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
Path runDirectory = gameRepository.getRunDirectory(versionId).toPath();
Path baseDirectory = gameRepository.getBaseDirectory().toPath();
List<String> versions = new ArrayList<>();
String currentVersionId = versionId;
@@ -19,13 +19,11 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.mod.*;
import org.jackhuang.hmcl.mod.curse.CurseModpackProvider;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider;
import org.jackhuang.hmcl.mod.multimc.MultiMCComponents;
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
@@ -44,6 +42,7 @@ import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
@@ -51,7 +50,10 @@ import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.Lang.mapOf;
@@ -59,8 +61,7 @@ import static org.jackhuang.hmcl.util.Lang.toIterable;
import static org.jackhuang.hmcl.util.Pair.pair;
public final class ModpackHelper {
private ModpackHelper() {
}
private ModpackHelper() {}
private static final Map<String, ModpackProvider> providers = mapOf(
pair(CurseModpackProvider.INSTANCE.getName(), CurseModpackProvider.INSTANCE),
@@ -71,16 +72,12 @@ public final class ModpackHelper {
pair(HMCLModpackProvider.INSTANCE.getName(), HMCLModpackProvider.INSTANCE)
);
static {
MultiMCComponents.setImplementation(Metadata.FULL_TITLE);
}
@Nullable
public static ModpackProvider getProviderByType(String type) {
return providers.get(type);
}
public static boolean isFileModpackByExtension(Path file) {
public static boolean isFileModpackByExtension(File file) {
String ext = FileUtils.getExtension(file);
return "zip".equals(ext) || "mrpack".equals(ext);
}
@@ -138,12 +135,15 @@ public final class ModpackHelper {
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
}
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
try {
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
} catch (JsonParseException e) {
throw new IOException("Malformed modpack configuration");
}
public static ModpackConfiguration<?> readModpackConfiguration(File file) throws IOException {
if (!file.exists())
throw new FileNotFoundException(file.getPath());
else
try {
return JsonUtils.GSON.fromJson(FileUtils.readText(file), ModpackConfiguration.class);
} catch (JsonParseException e) {
throw new IOException("Malformed modpack configuration");
}
}
public static Task<?> getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) {
@@ -174,21 +174,21 @@ public final class ModpackHelper {
return Files.exists(Paths.get("externalgames").resolve(name));
}
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) {
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, File zipFile, String name, Charset charset) {
if (isExternalGameNameConflicts(name)) {
throw new IllegalArgumentException("name existing");
}
return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name)
return new ManuallyCreatedModpackInstallTask(profile, zipFile.toPath(), charset, name)
.thenAcceptAsync(Schedulers.javafx(), location -> {
Profile newProfile = new Profile(name, location);
Profile newProfile = new Profile(name, location.toFile());
newProfile.setUseRelativePath(true);
Profiles.getProfiles().add(newProfile);
Profiles.setSelectedProfile(newProfile);
});
}
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack) {
public static Task<?> getInstallTask(Profile profile, File zipFile, String name, Modpack modpack) {
profile.getRepository().markVersionAsModpack(name);
ExceptionalRunnable<?> success = () -> {
@@ -210,17 +210,14 @@ public final class ModpackHelper {
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name));
else
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.javafx(), success, failure)
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
.whenComplete(Schedulers.javafx(), success, failure);
}
public static Task<Void> getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
@@ -233,17 +230,13 @@ public final class ModpackHelper {
}
}
public static Task<?> getUpdateTask(Profile profile, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
public static Task<?> getUpdateTask(Profile profile, File zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile.toPath(), charset);
ModpackProvider provider = getProviderByType(configuration.getType());
if (provider == null) {
throw new UnsupportedModpackException();
}
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
else
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack);
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack);
}
public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) {
@@ -283,24 +276,6 @@ public final class ModpackHelper {
}
}
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) {
if (c.isOverrideCommands()) {
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
}
if (c.isOverrideJavaArgs()) {
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
}
}
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
});
}
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
@@ -160,13 +160,13 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
@Override
public String getClientId() {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.getAttribute("hmcl.microsoft.auth.id", ""));
JarUtils.getManifestAttribute("Microsoft-Auth-Id", ""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.getAttribute("hmcl.microsoft.auth.secret", ""));
JarUtils.getManifestAttribute("Microsoft-Auth-Secret", ""));
}
@Override
@@ -26,6 +26,8 @@ import javafx.beans.value.ObservableValue;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
@@ -43,6 +45,7 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -107,7 +110,7 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(texture.getUrl(), file).run();
new FileDownloadTask(new URL(texture.getUrl()), file.toFile()).run();
LOG.info("Texture downloaded: " + texture.getUrl());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
@@ -262,8 +265,15 @@ public final class TexturesLoader {
int scale = (int) skin.getWidth() / 64;
int faceOffset = (int) Math.round(size / 18.0);
g.setImageSmoothing(false);
drawAvatar(g, skin, size, scale, faceOffset);
try {
g.setImageSmoothing(false);
drawAvatar(g, skin, size, scale, faceOffset);
} catch (NoSuchMethodError ignored) {
// Earlier JavaFX did not support GraphicsContext::setImageSmoothing
// In order to prevent the blurring caused by bilinear interpolation,
// we use the self-implemented nearest neighbor interpolation to scale
drawAvatarSlow(g, skin, size, scale, faceOffset);
}
}
private static void drawAvatar(GraphicsContext g, Image skin, int size, int scale, int faceOffset) {
@@ -275,6 +285,37 @@ public final class TexturesLoader {
0, 0, size, size);
}
private static void drawAvatarSlow(GraphicsContext g, Image skin, int size, int scale, int faceOffset) {
PixelReader reader = skin.getPixelReader();
PixelWriter writer = g.getPixelWriter();
drawImage(writer, reader,
8 * scale, 8 * scale, 8 * scale, 8 * scale,
faceOffset, faceOffset, size - 2 * faceOffset, size - 2 * faceOffset);
drawImage(writer, reader,
40 * scale, 8 * scale, 8 * scale, 8 * scale,
0, 0, size, size);
}
/*
* Scale and draw image using nearest-neighbor interpolation
*/
private static void drawImage(PixelWriter writer, PixelReader reader,
int sx, int sy, int sw, int sh,
int dx, int dy, int dw, int dh) {
double xScale = ((double) sw) / dw;
double yScale = ((double) sh) / dh;
for (int xOffset = 0; xOffset < dw; xOffset++) {
for (int yOffset = 0; yOffset < dh; yOffset++) {
int color = reader.getArgb(sx + (int) (xOffset * xScale), sy + (int) (yOffset * yScale));
// Draw only non-transparent pixels
if ((color >>> 24) != 0)
writer.setArgb(dx + xOffset, dy + yOffset, color);
}
}
}
private static final class SkinBindingChangeListener implements ChangeListener<LoadedTexture> {
static final WeakHashMap<Canvas, SkinBindingChangeListener> hole = new WeakHashMap<>();
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.util.platform.Platform;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
@@ -115,7 +116,11 @@ public final class HMCLJavaRepository implements JavaRepository {
}
if (Files.isDirectory(javaDir)) {
JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class);
JavaManifest manifest;
try (InputStream input = Files.newInputStream(file)) {
manifest = JsonUtils.fromJsonFully(input, JavaManifest.class);
}
list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged));
}
}
@@ -186,7 +191,7 @@ public final class HMCLJavaRepository implements JavaRepository {
});
JavaManifest manifest = new JavaManifest(info, update, files);
JsonUtils.writeToJsonFile(getManifestFile(platform, gameJavaVersion), manifest);
FileUtils.writeText(getManifestFile(platform, gameJavaVersion), JsonUtils.GSON.toJson(manifest));
return JavaRuntime.of(executable, info, true);
});
}
@@ -198,7 +203,7 @@ public final class HMCLJavaRepository implements JavaRepository {
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
FileUtils.writeText(getManifestFile(platform, name), JsonUtils.GSON.toJson(result));
return JavaRuntime.of(executable, result.getInfo(), true);
});
}
@@ -207,7 +212,7 @@ public final class HMCLJavaRepository implements JavaRepository {
public Task<Void> getUninstallJavaTask(Platform platform, String name) {
return Task.runAsync(() -> {
Files.deleteIfExists(getManifestFile(platform, name));
FileUtils.deleteDirectory(getJavaDir(platform, name));
FileUtils.deleteDirectory(getJavaDir(platform, name).toFile());
});
}
@@ -220,7 +225,7 @@ public final class HMCLJavaRepository implements JavaRepository {
if (relativized.getNameCount() > 1) {
String name = relativized.getName(0).toString();
Files.deleteIfExists(getManifestFile(java.getPlatform(), name));
FileUtils.deleteDirectory(getJavaDir(java.getPlatform(), name));
FileUtils.deleteDirectory(getJavaDir(java.getPlatform(), name).toFile());
}
});
}
@@ -20,20 +20,17 @@ package org.jackhuang.hmcl.java;
import kala.compress.archivers.ArchiveEntry;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.Hex;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -101,9 +98,10 @@ public final class JavaInstallTask extends Task<JavaManifest> {
}
if (tree.isExecutable(entry))
FileUtils.setExecutable(path);
//noinspection ResultOfMethodCallIgnored
path.toFile().setExecutable(true);
files.put(String.join("/", nameStack), new JavaLocalFiles.LocalFile(HexFormat.of().formatHex(messageDigest.digest()), size));
files.put(String.join("/", nameStack), new JavaLocalFiles.LocalFile(Hex.encodeHex(messageDigest.digest()), size));
}
nameStack.remove(nameStack.size() - 1);
}
@@ -209,18 +209,9 @@ public final class JavaManager {
public static Task<Void> getUninstallJavaTask(JavaRuntime java) {
assert java.isManaged();
Path root = REPOSITORY.getPlatformRoot(java.getPlatform());
Path relativized = root.relativize(java.getBinary());
Path platformRoot;
try {
platformRoot = REPOSITORY.getPlatformRoot(java.getPlatform()).toRealPath();
} catch (Throwable ignored) {
return Task.completed(null);
}
if (!java.getBinary().startsWith(platformRoot))
return Task.completed(null);
Path relativized = platformRoot.relativize(java.getBinary());
if (relativized.getNameCount() > 1) {
FXUtils.runInFX(() -> {
try {
@@ -412,15 +403,8 @@ public final class JavaManager {
if (System.getenv("PATH") != null) {
String[] paths = System.getenv("PATH").split(File.pathSeparator);
for (String path : paths) {
// https://github.com/HMCL-dev/HMCL/issues/4079
// https://github.com/Meloong-Git/PCL/issues/4261
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && path.toLowerCase(Locale.ROOT)
.contains("\\common files\\oracle\\java\\")) {
continue;
}
try {
tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
tryAddJavaExecutable(javaRuntimes, Paths.get(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
} catch (InvalidPathException ignored) {
}
}
@@ -488,15 +472,12 @@ public final class JavaManager {
try {
info = JavaInfo.fromReleaseFile(releaseFile);
} catch (IOException e) {
LOG.warning("Failed to read release file " + releaseFile, e);
}
}
if (info == null) {
try {
info = JavaInfoUtils.fromExecutable(executable, false);
} catch (IOException e) {
LOG.warning("Failed to lookup Java executable at " + executable, e);
try {
info = JavaInfoUtils.fromExecutable(executable, false);
} catch (IOException e2) {
e2.addSuppressed(e);
LOG.warning("Failed to lookup Java executable at " + executable, e2);
}
}
}
@@ -36,7 +36,6 @@ import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import javax.net.ssl.SSLException;
@@ -69,6 +68,18 @@ public final class Accounts {
private static final AuthlibInjectorArtifactProvider AUTHLIB_INJECTOR_DOWNLOADER = createAuthlibInjectorArtifactProvider();
private static void triggerAuthlibInjectorUpdateCheck() {
if (AUTHLIB_INJECTOR_DOWNLOADER instanceof AuthlibInjectorDownloader) {
Schedulers.io().execute(() -> {
try {
((AuthlibInjectorDownloader) AUTHLIB_INJECTOR_DOWNLOADER).checkUpdate();
} catch (IOException e) {
LOG.warning("Failed to check update for authlib-injector", e);
}
});
}
}
public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory();
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
@@ -326,6 +337,8 @@ public final class Accounts {
});
}
triggerAuthlibInjectorUpdateCheck();
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
continue;
@@ -333,7 +346,7 @@ public final class Accounts {
try {
server.fetchMetadataResponse();
} catch (IOException e) {
LOG.warning("Failed to fetch authlib-injector server metadata: " + server, e);
LOG.warning("Failed to fetch authlib-injector server metdata: " + server, e);
}
});
}
@@ -358,18 +371,24 @@ public final class Accounts {
// ==== authlib-injector ====
private static AuthlibInjectorArtifactProvider createAuthlibInjectorArtifactProvider() {
String authlibinjectorLocation = System.getProperty("hmcl.authlibinjector.location");
if (authlibinjectorLocation != null) {
if (authlibinjectorLocation == null) {
return new AuthlibInjectorDownloader(
Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve("authlib-injector.jar"),
DownloadProviders::getDownloadProvider) {
@Override
public Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately() {
Optional<AuthlibInjectorArtifactInfo> local = super.getArtifactInfoImmediately();
if (local.isPresent()) {
return local;
}
// search authlib-injector.jar in current directory, it's used as a fallback
return parseArtifact(Metadata.CURRENT_DIRECTORY.resolve("authlib-injector.jar"));
}
};
} else {
LOG.info("Using specified authlib-injector: " + authlibinjectorLocation);
return new SimpleAuthlibInjectorArtifactProvider(Paths.get(authlibinjectorLocation));
}
String authlibInjectorVersion = JarUtils.getAttribute("hmcl.authlib-injector.version", null);
if (authlibInjectorVersion == null)
throw new AssertionError("Missing hmcl.authlib-injector.version");
String authlibInjectorFileName = "authlib-injector-" + authlibInjectorVersion + ".jar";
return new AuthlibInjectorExtractor(Accounts.class.getResource("/assets/" + authlibInjectorFileName),
Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve(authlibInjectorFileName));
}
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
@@ -21,10 +21,10 @@ import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import java.io.IOException;
@@ -38,7 +38,6 @@ import java.util.concurrent.CopyOnWriteArraySet;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@JsonSerializable
public final class AuthlibInjectorServers implements Validation {
public static final String CONFIG_FILENAME = "authlib-injectors.json";
@@ -74,7 +73,8 @@ public final class AuthlibInjectorServers implements Validation {
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
AuthlibInjectorServers configInstance;
try {
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
String content = FileUtils.readText(configLocation);
configInstance = JsonUtils.GSON.fromJson(content, AuthlibInjectorServers.class);
} catch (IOException | JsonParseException e) {
LOG.warning("Malformed authlib-injectors.json", e);
return;
File diff suppressed because it is too large. Load diff
@@ -107,7 +107,6 @@ public final class ConfigHolder {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
&& configLocation.getFileSystem() == FileSystems.getDefault()
&& configLocation.toFile().canWrite()) {
LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug");
// There are some serious problems with the implementation of Samba or OpenJDK
throw new SambaException();
} else {
@@ -164,7 +163,7 @@ public final class ConfigHolder {
LOG.warning("Failed to get owner");
}
try {
String content = Files.readString(configLocation);
String content = FileUtils.readText(configLocation);
Config deserialized = Config.fromJson(content);
if (deserialized == null) {
LOG.info("Config is empty");
@@ -193,7 +192,7 @@ public final class ConfigHolder {
private static GlobalConfig loadGlobalConfig() throws IOException {
if (Files.exists(GLOBAL_CONFIG_PATH)) {
try {
String content = Files.readString(GLOBAL_CONFIG_PATH);
String content = FileUtils.readText(GLOBAL_CONFIG_PATH);
GlobalConfig deserialized = GlobalConfig.fromJson(content);
if (deserialized == null) {
LOG.info("Config is empty");
@@ -29,23 +29,25 @@ import org.jackhuang.hmcl.util.io.ResponseCodeException;
import javax.net.ssl.SSLHandshakeException;
import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URL;
import java.nio.file.AccessDeniedException;
import java.util.ArrayList;
import java.util.List;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
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 final DownloadProviderWrapper provider;
private static DownloadProvider currentDownloadProvider;
public static final Map<String, DownloadProvider> providersById;
public static final Map<String, DownloadProvider> rawProviders;
@@ -57,7 +59,6 @@ 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 {
@@ -67,92 +68,95 @@ public final class DownloadProviders {
MOJANG = new MojangDownloadProvider();
BMCLAPI = new BMCLAPIDownloadProvider(bmclapiRoot);
rawProviders = Map.of(
"mojang", MOJANG,
"bmclapi", BMCLAPI
rawProviders = mapOf(
pair("mojang", MOJANG),
pair("bmclapi", BMCLAPI)
);
AdaptedDownloadProvider fileProvider = new AdaptedDownloadProvider();
fileProvider.setDownloadProviderCandidates(List.of(BMCLAPI, MOJANG));
fileProvider.setDownloadProviderCandidates(Arrays.asList(BMCLAPI, MOJANG));
BalancedDownloadProvider balanced = new BalancedDownloadProvider(MOJANG, BMCLAPI);
providersById = Map.of(
"official", new AutoDownloadProvider(MOJANG, fileProvider),
"balanced", new AutoDownloadProvider(balanced, fileProvider),
"mirror", new AutoDownloadProvider(BMCLAPI, fileProvider));
providersById = mapOf(
pair("official", new AutoDownloadProvider(MOJANG, fileProvider)),
pair("balanced", new AutoDownloadProvider(balanced, fileProvider)),
pair("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() {
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);
FXUtils.onChangeAndOperate(config().versionListSourceProperty(), versionListSource -> {
if (!providersById.containsKey(versionListSource)) {
config().setVersionListSource(DEFAULT_PROVIDER_ID);
return;
}
};
config().versionListSourceProperty().addListener(onChangeDownloadSource);
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
onChangeDownloadSource.invalidated(null);
currentDownloadProvider = Optional.ofNullable(providersById.get(versionListSource))
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
});
if (!rawProviders.containsKey(config().getDownloadType())) {
config().setDownloadType(DEFAULT_RAW_PROVIDER_ID);
}
FXUtils.onChangeAndOperate(config().downloadTypeProperty(), downloadType -> {
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);
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())
);
});
}
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 provider;
return config().isAutoChooseDownloadType() ? currentDownloadProvider : fileDownloadProvider;
}
public static String localizeErrorMessage(Throwable exception) {
if (exception instanceof DownloadException) {
URI uri = ((DownloadException) exception).getUri();
URL url = ((DownloadException) exception).getUrl();
if (exception.getCause() instanceof SocketTimeoutException) {
return i18n("install.failed.downloading.timeout", uri);
return i18n("install.failed.downloading.timeout", url);
} else if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
return i18n("download.code." + responseCodeException.getResponseCode(), url);
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception.getCause() instanceof FileNotFoundException) {
return i18n("download.code.404", uri);
return i18n("download.code.404", url);
} else if (exception.getCause() instanceof AccessDeniedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception instanceof ArtifactMalformedException) {
return i18n("exception.artifact_malformed");
@@ -22,6 +22,5 @@ public enum EnumBackgroundImage {
CUSTOM,
CLASSIC,
NETWORK,
TRANSLUCENT, // Deprecated
PAINT
TRANSLUCENT
}
@@ -21,20 +21,23 @@ import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.text.Font;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.Locale;
import java.util.Objects;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -51,21 +54,7 @@ public final class FontManager {
public static final double DEFAULT_FONT_SIZE = 12.0f;
private static final Lazy<Font> DEFAULT_FONT = new Lazy<>(() -> {
Font font;
// Recommended
font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font"));
if (font != null)
return font;
font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font"));
if (font != null)
return font;
// Legacy
font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
Font font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
if (font != null)
return font;
@@ -84,12 +73,10 @@ public final class FontManager {
return font;
}
// Default
String fcMatchPattern;
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()
&& !(fcMatchPattern = I18n.getLocale().getFcMatchPattern()).isEmpty())
return findByFcMatch(fcMatchPattern);
&& Locale.getDefault() != Locale.ROOT
&& !"en".equals(Locale.getDefault().getLanguage()))
return findByFcMatch();
else
return null;
});
@@ -125,7 +112,6 @@ public final class FontManager {
for (String extension : FONT_EXTENSIONS) {
Path path = dir.resolve("font." + extension);
if (Files.isRegularFile(path)) {
LOG.info("Load font file: " + path);
try {
Font font = Font.loadFont(path.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (font != null) {
@@ -141,47 +127,16 @@ public final class FontManager {
return null;
}
private static Font tryLoadLocalizedFont(Path dir) {
Map<String, Map<String, Path>> fontFiles = LocaleUtils.findAllLocalizedFiles(dir, "font", Set.of(FONT_EXTENSIONS));
if (fontFiles.isEmpty())
return null;
List<Locale> candidateLocales = I18n.getLocale().getCandidateLocales();
for (Locale locale : candidateLocales) {
Map<String, Path> extToFiles = fontFiles.get(LocaleUtils.toLanguageKey(locale));
if (extToFiles != null) {
for (String ext : FONT_EXTENSIONS) {
Path fontFile = extToFiles.get(ext);
if (fontFile != null) {
LOG.info("Load font file: " + fontFile);
try {
Font font = Font.loadFont(
fontFile.toAbsolutePath().normalize().toUri().toURL().toExternalForm(),
DEFAULT_FONT_SIZE);
if (font != null)
return font;
} catch (MalformedURLException ignored) {
}
LOG.warning("Failed to load font " + fontFile);
}
}
}
}
return null;
}
public static Font findByFcMatch(String pattern) {
public static Font findByFcMatch() {
Path fcMatch = SystemUtils.which("fc-match");
if (fcMatch == null)
return null;
try {
String result = SystemUtils.run(fcMatch.toString(),
pattern,
":lang=" + Locale.getDefault().toLanguageTag(),
"--format", "%{family}\\n%{file}").trim();
String[] results = result.split("\\n");
if (results.length != 2 || results[0].isEmpty() || results[1].isEmpty()) {
LOG.warning("Unexpected output from fc-match: " + result);
@@ -197,34 +152,46 @@ public final class FontManager {
return null;
}
LOG.info("Load font file: " + path);
Font[] fonts = Font.loadFonts(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (fonts == null) {
LOG.warning("Failed to load font from " + path);
return null;
} else if (fonts.length == 0) {
LOG.warning("No fonts loaded from " + path);
return null;
}
if (JavaRuntime.CURRENT_VERSION >= 9) {
try {
MethodHandle methodHandle = MethodHandles.publicLookup().findStatic(Font.class, "loadFonts",
MethodType.methodType(Font[].class, String.class, double.class));
for (Font font : fonts) {
if (font.getFamily().equalsIgnoreCase(family)) {
return font;
}
}
Font[] fonts = (Font[]) methodHandle.invokeExact(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (fonts == null) {
LOG.warning("Failed to load font from " + path);
return null;
} else if (fonts.length == 0) {
LOG.warning("No fonts loaded from " + path);
return null;
}
if (family.indexOf(',') >= 0) {
for (String candidateFamily : family.split(",")) {
for (Font font : fonts) {
if (font.getFamily().equalsIgnoreCase(candidateFamily)) {
if (font.getFamily().equalsIgnoreCase(family)) {
return font;
}
}
if (family.indexOf(',') >= 0) {
for (String candidateFamily : family.split(",")) {
for (Font font : fonts) {
if (font.getFamily().equalsIgnoreCase(candidateFamily)) {
return font;
}
}
}
}
LOG.warning(String.format("Family '%s' not found in font file '%s'", family, path));
return fonts[0];
} catch (NoSuchMethodException | IllegalAccessException ignored) {
}
}
LOG.warning(String.format("Family '%s' not found in font file '%s'", family, path));
return fonts[0];
Font font = Font.loadFont(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (font == null)
LOG.warning("Failed to load font from " + path);
return font;
} catch (Throwable e) {
LOG.warning("Failed to get default font with fc-match", e);
return null;
@@ -21,7 +21,10 @@ import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
@@ -54,8 +57,6 @@ public final class GlobalConfig implements Observable {
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
@@ -130,18 +131,6 @@ public final class GlobalConfig implements Observable {
enableOfflineAccount.set(value);
}
public StringProperty fontAntiAliasingProperty() {
return fontAntiAliasing;
}
public String getFontAntiAliasing() {
return fontAntiAliasing.get();
}
public void setFontAntiAliasing(String value) {
this.fontAntiAliasing.set(value);
}
public ObservableSet<String> getUserJava() {
return userJava;
}
@@ -157,8 +146,7 @@ public final class GlobalConfig implements Observable {
"logRetention",
"userJava",
"disabledJava",
"enableOfflineAccount",
"fontAntiAliasing"
"enableOfflineAccount"
));
@Override
@@ -171,7 +159,6 @@ public final class GlobalConfig implements Observable {
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
jsonObject.add("logRetention", context.serialize(src.getLogRetention()));
jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing()));
if (src.enableOfflineAccount.get())
jsonObject.addProperty("enableOfflineAccount", true);
@@ -199,7 +186,6 @@ public final class GlobalConfig implements Observable {
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20));
config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false));
config.setFontAntiAliasing(Optional.ofNullable(obj.get("fontAntiAliasing")).map(JsonElement::getAsString).orElse(null));
JsonElement userJava = obj.get("userJava");
if (userJava != null && userJava.isJsonArray()) {
@@ -35,8 +35,8 @@ import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import java.io.File;
import java.lang.reflect.Type;
import java.nio.file.Path;
import java.util.Optional;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
@@ -65,17 +65,17 @@ public final class Profile implements Observable {
this.selectedVersion.set(selectedVersion);
}
private final ObjectProperty<Path> gameDir;
private final ObjectProperty<File> gameDir;
public ObjectProperty<Path> gameDirProperty() {
public ObjectProperty<File> gameDirProperty() {
return gameDir;
}
public Path getGameDir() {
public File getGameDir() {
return gameDir.get();
}
public void setGameDir(Path gameDir) {
public void setGameDir(File gameDir) {
this.gameDir.set(gameDir);
}
@@ -103,7 +103,7 @@ public final class Profile implements Observable {
this.name.set(name);
}
private final BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false);
private BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false);
public BooleanProperty useRelativePathProperty() {
return useRelativePath;
@@ -118,18 +118,18 @@ public final class Profile implements Observable {
}
public Profile(String name) {
this(name, Path.of(".minecraft"));
this(name, new File(".minecraft"));
}
public Profile(String name, Path initialGameDir) {
public Profile(String name, File initialGameDir) {
this(name, initialGameDir, new VersionSetting());
}
public Profile(String name, Path initialGameDir, VersionSetting global) {
public Profile(String name, File initialGameDir, VersionSetting global) {
this(name, initialGameDir, global, null, false);
}
public Profile(String name, Path initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) {
public Profile(String name, File initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) {
this.name = new SimpleStringProperty(this, "name", name);
gameDir = new SimpleObjectProperty<>(this, "gameDir", initialGameDir);
repository = new HMCLGameRepository(this, initialGameDir);
@@ -234,7 +234,7 @@ public final class Profile implements Observable {
JsonObject jsonObject = new JsonObject();
jsonObject.add("global", context.serialize(src.getGlobal()));
jsonObject.addProperty("gameDir", src.getGameDir().toString());
jsonObject.addProperty("gameDir", src.getGameDir().getPath());
jsonObject.addProperty("useRelativePath", src.isUseRelativePath());
jsonObject.addProperty("selectedMinecraftVersion", src.getSelectedVersion());
@@ -243,11 +243,12 @@ public final class Profile implements Observable {
@Override
public Profile deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject obj)) return null;
if (json == JsonNull.INSTANCE || !(json instanceof JsonObject)) return null;
JsonObject obj = (JsonObject) json;
String gameDir = Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse("");
return new Profile("Default",
Path.of(gameDir),
new File(gameDir),
context.deserialize(obj.get("global"), VersionSetting.class),
Optional.ofNullable(obj.get("selectedMinecraftVersion")).map(JsonElement::getAsString).orElse(""),
Optional.ofNullable(obj.get("useRelativePath")).map(JsonElement::getAsBoolean).orElse(false));
@@ -26,7 +26,7 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import java.nio.file.Path;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -48,17 +48,20 @@ public final class Profiles {
}
public static String getProfileDisplayName(Profile profile) {
return switch (profile.getName()) {
case Profiles.DEFAULT_PROFILE -> i18n("profile.default");
case Profiles.HOME_PROFILE -> i18n("profile.home");
default -> profile.getName();
};
switch (profile.getName()) {
case Profiles.DEFAULT_PROFILE:
return i18n("profile.default");
case Profiles.HOME_PROFILE:
return i18n("profile.home");
default:
return profile.getName();
}
}
private static final ObservableList<Profile> profiles = observableArrayList(profile -> new Observable[] { profile });
private static final ReadOnlyListWrapper<Profile> profilesWrapper = new ReadOnlyListWrapper<>(profiles);
private static final ObjectProperty<Profile> selectedProfile = new SimpleObjectProperty<Profile>() {
private static ObjectProperty<Profile> selectedProfile = new SimpleObjectProperty<Profile>() {
{
profiles.addListener(onInvalidating(this::invalidated));
}
@@ -101,8 +104,8 @@ public final class Profiles {
private static void checkProfiles() {
if (profiles.isEmpty()) {
Profile current = new Profile(Profiles.DEFAULT_PROFILE, Path.of(".minecraft"), new VersionSetting(), null, true);
Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY);
Profile current = new Profile(Profiles.DEFAULT_PROFILE, new File(".minecraft"), new VersionSetting(), null, true);
Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY.toFile());
Platform.runLater(() -> profiles.addAll(current, home));
}
}
@@ -18,33 +18,21 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.*;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ProxyManager {
private static final SimpleProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY);
private static final ProxySelector SYSTEM_DEFAULT;
static {
ProxySelector systemProxySelector = ProxySelector.getDefault();
SYSTEM_DEFAULT = systemProxySelector != null
? new ProxySelectorWrapper(systemProxySelector)
: NO_PROXY;
}
private static volatile @NotNull ProxySelector defaultProxySelector = SYSTEM_DEFAULT;
private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null;
private static final ProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY);
private static final ProxySelector SYSTEM_DEFAULT = Lang.requireNonNullElse(ProxySelector.getDefault(), NO_PROXY);
private static ProxySelector getProxySelector() {
if (config().hasProxy()) {
@@ -58,22 +46,22 @@ public final class ProxyManager {
LOG.warning("Illegal proxy port: " + port);
return NO_PROXY;
} else {
return new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port))));
return new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port)));
}
} else {
return ProxyManager.SYSTEM_DEFAULT;
}
}
private static SimpleAuthenticator getAuthenticator() {
private static Authenticator getAuthenticator() {
if (config().hasProxy() && config().hasProxyAuth()) {
String username = config().getProxyUser();
String password = config().getProxyPass();
if (username != null || password != null)
return new SimpleAuthenticator(
Objects.requireNonNullElse(username, ""),
Objects.requireNonNullElse(password, "").toCharArray()
Lang.requireNonNullElse(username, ""),
Lang.requireNonNullElse(password, "").toCharArray()
);
else
return null;
@@ -82,62 +70,46 @@ public final class ProxyManager {
}
static void init() {
ProxySelector.setDefault(new ProxySelector() {
@Override
public List<Proxy> select(URI uri) {
return defaultProxySelector.select(uri);
}
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
defaultProxySelector.connectFailed(uri, sa, ioe);
}
});
Authenticator.setDefault(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
var defaultAuthenticator = ProxyManager.defaultAuthenticator;
return defaultAuthenticator != null ? defaultAuthenticator.getPasswordAuthentication() : null;
}
});
defaultProxySelector = getProxySelector();
InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector();
ProxySelector.setDefault(getProxySelector());
InvalidationListener updateProxySelector = observable -> ProxySelector.setDefault(getProxySelector());
config().proxyTypeProperty().addListener(updateProxySelector);
config().proxyHostProperty().addListener(updateProxySelector);
config().proxyPortProperty().addListener(updateProxySelector);
config().hasProxyProperty().addListener(updateProxySelector);
defaultAuthenticator = getAuthenticator();
InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator();
Authenticator.setDefault(getAuthenticator());
InvalidationListener updateAuthenticator = observable -> Authenticator.setDefault(getAuthenticator());
config().hasProxyProperty().addListener(updateAuthenticator);
config().hasProxyAuthProperty().addListener(updateAuthenticator);
config().proxyUserProperty().addListener(updateAuthenticator);
config().proxyPassProperty().addListener(updateAuthenticator);
}
private static abstract class AbstractProxySelector extends ProxySelector {
private static final class SimpleProxySelector extends ProxySelector {
private final List<Proxy> proxies;
SimpleProxySelector(Proxy proxy) {
this(Collections.singletonList(proxy));
}
SimpleProxySelector(List<Proxy> proxies) {
this.proxies = proxies;
}
@Override
public List<Proxy> select(URI uri) {
if (uri == null)
throw new IllegalArgumentException("URI can't be null.");
return proxies;
}
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
if (uri == null || sa == null || ioe == null) {
throw new IllegalArgumentException("Arguments can't be null.");
}
}
}
private static final class SimpleProxySelector extends AbstractProxySelector {
private final List<Proxy> proxies;
SimpleProxySelector(Proxy proxy) {
this.proxies = List.of(proxy);
}
@Override
public List<Proxy> select(URI uri) {
if (uri == null)
throw new IllegalArgumentException("URI can't be null.");
return proxies;
}
@Override
public String toString() {
@@ -145,26 +117,6 @@ public final class ProxyManager {
}
}
/// Wraps another ProxySelector to avoid using proxy for loopback addresses.
private static final class ProxySelectorWrapper extends AbstractProxySelector {
private final ProxySelector source;
ProxySelectorWrapper(ProxySelector source) {
this.source = source;
}
@Override
public List<Proxy> select(URI uri) {
if (uri == null)
throw new IllegalArgumentException("URI can't be null.");
if (NetworkUtils.isLoopbackAddress(uri))
return NO_PROXY.proxies;
return source.select(uri);
}
}
private static final class SimpleAuthenticator extends Authenticator {
private final String username;
private final char[] password;
@@ -175,7 +127,7 @@ public final class ProxyManager {
}
@Override
public PasswordAuthentication getPasswordAuthentication() {
protected PasswordAuthentication getPasswordAuthentication() {
return getRequestorType() == RequestorType.PROXY ? new PasswordAuthentication(username, password) : null;
}
}
@@ -22,13 +22,20 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
@@ -51,8 +58,23 @@ public final class StyleSheets {
config().themeProperty().addListener(o -> stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet()));
}
private static String toStyleSheetUri(String styleSheet) {
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
private static String toStyleSheetUri(String styleSheet, String fallback) {
if (FXUtils.JAVAFX_MAJOR_VERSION >= 17)
// JavaFX 17+ support loading stylesheets from data URIs
// https://bugs.openjdk.org/browse/JDK-8267554
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
else
try {
Path temp = Files.createTempFile("hmcl", ".css");
// For JavaFX 17 or earlier, CssParser uses the default charset
// https://bugs.openjdk.org/browse/JDK-8279328
FileUtils.writeText(temp, styleSheet, Charset.defaultCharset());
temp.toFile().deleteOnExit();
return temp.toUri().toString();
} catch (IOException | NullPointerException e) {
LOG.error("Unable to create stylesheet, fallback to " + fallback, e);
return fallback;
}
}
private static String getFontStyleSheet() {
@@ -102,7 +124,7 @@ public final class StyleSheets {
builder.append('}');
return toStyleSheetUri(builder.toString());
return toStyleSheetUri(builder.toString(), fontFamily);
}
private static String rgba(Color color, double opacity) {
@@ -129,7 +151,7 @@ public final class StyleSheets {
"-fx-base-disabled-text-fill:" + rgba(theme.getForegroundColor(), 0.7) + ";" +
"-fx-base-text-fill:" + Theme.getColorDisplayName(theme.getForegroundColor()) + ";" +
"-theme-thumb:" + rgba(theme.getPaint(), 0.7) + ";" +
'}');
'}', blueCss);
}
public static void init(Scene scene) {
@@ -33,9 +33,7 @@ public enum VersionIconType {
FORGE("/assets/img/forge.png"),
NEO_FORGE("/assets/img/neoforge.png"),
FURNACE("/assets/img/furnace.png"),
QUILT("/assets/img/quilt.png"),
APRIL_FOOLS("/assets/img/april_fools.png"),
CLEANROOM("/assets/img/cleanroom.png");
QUILT("/assets/img/quilt.png");
// Please append new items at last
@@ -380,20 +380,6 @@ public final class VersionSetting implements Cloneable, Observable {
noJVMArgsProperty.set(noJVMArgs);
}
private final BooleanProperty noOptimizingJVMArgsProperty = new SimpleBooleanProperty(this, "noOptimizingJVMArgs", false);
public BooleanProperty noOptimizingJVMArgsProperty() {
return noOptimizingJVMArgsProperty;
}
public boolean isNoOptimizingJVMArgs() {
return noOptimizingJVMArgsProperty.get();
}
public void setNoOptimizingJVMArgs(boolean noOptimizingJVMArgs) {
noOptimizingJVMArgsProperty.set(noOptimizingJVMArgs);
}
private final BooleanProperty notCheckJVMProperty = new SimpleBooleanProperty(this, "notCheckJVM", false);
public BooleanProperty notCheckJVMProperty() {
@@ -811,6 +797,14 @@ public final class VersionSetting implements Cloneable, Observable {
return obj;
}
private static <T> T getOrDefault(T[] values, JsonElement index, T defaultValue) {
if (index == null)
return defaultValue;
int idx = index.getAsInt();
return idx >= 0 && idx < values.length ? values[idx] : defaultValue;
}
@Override
public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject))
@@ -845,14 +839,14 @@ public final class VersionSetting implements Cloneable, Observable {
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE));
vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL));
vs.setLauncherVisibility(getOrDefault(LauncherVisibility.values(), obj.get("launcherVisibility"), LauncherVisibility.HIDE));
vs.setProcessPriority(getOrDefault(ProcessPriority.values(), obj.get("processPriority"), ProcessPriority.NORMAL));
vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false));
vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false));
vs.setGameDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("gameDirType"), GameDirectoryType.class, GameDirectoryType.ROOT_FOLDER));
vs.setGameDirType(getOrDefault(GameDirectoryType.values(), obj.get("gameDirType"), GameDirectoryType.ROOT_FOLDER));
vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null));
vs.setNativesDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("nativesDirType"), NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER));
vs.setVersionIcon(parseJsonPrimitive(obj.getAsJsonPrimitive("versionIcon"), VersionIconType.class, VersionIconType.DEFAULT));
vs.setNativesDirType(getOrDefault(NativesDirectoryType.values(), obj.get("nativesDirType"), NativesDirectoryType.VERSION_FOLDER));
vs.setVersionIcon(getOrDefault(VersionIconType.values(), obj.get("versionIcon"), VersionIconType.DEFAULT));
if (obj.get("javaVersionType") != null) {
JavaVersionType javaVersionType = parseJsonPrimitive(obj.getAsJsonPrimitive("javaVersionType"), JavaVersionType.class, JavaVersionType.AUTO);
@@ -0,0 +1,20 @@
package org.jackhuang.hmcl.ui;
import java.awt.*;
import java.lang.reflect.Method;
public final class AwtUtils {
private AwtUtils() {
}
public static void setAppleIcon(Image image) {
try {
Class<?> taskbarClass = Class.forName("java.awt.Taskbar");
Method getTaskBarMethod = taskbarClass.getDeclaredMethod("getTaskbar");
Object taskBar = getTaskBarMethod.invoke(null);
Method setIconImageMethod = taskbarClass.getDeclaredMethod("setIconImage", Image.class);
setIconImageMethod.invoke(taskBar, image);
} catch (Throwable ignore) {
}
}
}
@@ -62,7 +62,7 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.nio.file.Path;
import java.io.File;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -72,8 +72,6 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class Controllers {
public static final String JAVA_VERSION_TIP = "javaVersion";
public static final String JAVA_INTERPRETED_MODE_TIP = "javaInterpretedMode";
public static final String SOFTWARE_RENDERING = "softwareRendering";
public static final int MIN_WIDTH = 800 + 2 + 16; // bg width + border width*2 + shadow width*2
public static final int MIN_HEIGHT = 450 + 2 + 40 + 16; // bg height + border width*2 + toolbar height + shadow width*2
@@ -92,7 +90,7 @@ public final class Controllers {
gameListPage.selectedProfileProperty().bindBidirectional(Profiles.selectedProfileProperty());
gameListPage.profilesProperty().bindContent(Profiles.profilesProperty());
FXUtils.applyDragListener(gameListPage, ModpackHelper::isFileModpackByExtension, modpacks -> {
Path modpack = modpacks.get(0);
File modpack = modpacks.get(0);
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(Profiles.getSelectedProfile(), modpack), i18n("install.modpack"));
});
return gameListPage;
@@ -178,18 +176,6 @@ public final class Controllers {
public static void initialize(Stage stage) {
LOG.info("Start initializing application");
if (System.getProperty("prism.lcdtext") == null) {
String fontAntiAliasing = globalConfig().getFontAntiAliasing();
if ("lcd".equalsIgnoreCase(fontAntiAliasing)) {
LOG.info("Enable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "true");
} else if ("gray".equalsIgnoreCase(fontAntiAliasing)
|| OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && SCREEN.getOutputScaleX() > 1) {
LOG.info("Disable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "false");
}
}
Controllers.stage = stage;
stageSizeChangeListener = o -> {
@@ -219,11 +205,7 @@ public final class Controllers {
if (targetProperty != null
&& Controllers.stage != null
&& !Controllers.stage.isIconified()
// https://github.com/HMCL-dev/HMCL/issues/4290
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ||
!Controllers.stage.isFullScreen() && !Controllers.stage.isMaximized())
) {
&& !Controllers.stage.isIconified()) {
targetProperty.set(sourceProperty.get());
}
};
@@ -330,43 +312,24 @@ public final class Controllers {
if (JavaRuntime.CURRENT_VERSION < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) {
Number shownTipVersion = null;
try {
shownTipVersion = (Number) config().getShownTips().get(JAVA_VERSION_TIP);
} catch (ClassCastException e) {
LOG.warning("Invalid type for shown tips key: " + JAVA_VERSION_TIP, e);
}
if (shownTipVersion == null || shownTipVersion.intValue() < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) {
MessageDialogPane.Builder builder = new MessageDialogPane.Builder(i18n("fatal.deprecated_java_version"), null, MessageType.WARNING);
String downloadLink = Metadata.getSuggestedJavaDownloadLink();
if (downloadLink != null)
builder.addHyperLink(
i18n("fatal.deprecated_java_version.download_link", Metadata.RECOMMENDED_JAVA_VERSION),
downloadLink
);
builder.addHyperLink(i18n("fatal.deprecated_java_version.download_link", 21), downloadLink);
Controllers.dialog(builder
.ok(() -> config().getShownTips().put(JAVA_VERSION_TIP, Metadata.MINIMUM_SUPPORTED_JAVA_VERSION))
.build());
}
}
// Check whether JIT is enabled in the current environment
if (!JavaRuntime.CURRENT_JIT_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(JAVA_INTERPRETED_MODE_TIP))) {
Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.java_interpreted_mode"), i18n("message.warning"), MessageType.WARNING)
.ok(null)
.addCancel(i18n("button.do_not_show_again"), () ->
config().getShownTips().put(JAVA_INTERPRETED_MODE_TIP, true))
.build());
}
// Check whether hardware acceleration is enabled
if (!FXUtils.GPU_ACCELERATION_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(SOFTWARE_RENDERING))) {
Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.software_rendering"), i18n("message.warning"), MessageType.WARNING)
.ok(null)
.addCancel(i18n("button.do_not_show_again"), () ->
config().getShownTips().put(SOFTWARE_RENDERING, true))
.build());
}
if (globalConfig().getAgreementVersion() < 1) {
JFXDialogLayout agreementPane = new JFXDialogLayout();
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
@@ -18,22 +18,21 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.*;
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.WeakListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.*;
import javafx.collections.ObservableMap;
import javafx.event.Event;
import javafx.event.EventDispatcher;
import javafx.event.EventType;
import javafx.geometry.Bounds;
import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.Scene;
@@ -43,23 +42,23 @@ import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.*;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.image.ImageLoader;
import org.jackhuang.hmcl.ui.image.ImageUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
@@ -67,35 +66,38 @@ import org.jackhuang.hmcl.util.javafx.ExtendedProperties;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.Nullable;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.awt.image.BufferedImage;
import java.io.*;
import java.lang.ref.WeakReference;
import java.net.*;
import java.nio.file.FileSystems;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.*;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.Lang.tryCast;
@@ -108,32 +110,6 @@ public final class FXUtils {
public static final int JAVAFX_MAJOR_VERSION;
public static final String GRAPHICS_PIPELINE;
public static final boolean GPU_ACCELERATION_ENABLED;
static {
String pipelineName = "";
try {
Object pipeline = Class.forName("com.sun.prism.GraphicsPipeline").getMethod("getPipeline").invoke(null);
if (pipeline != null) {
pipelineName = pipeline.getClass().getName();
}
} catch (Throwable e) {
LOG.warning("Failed to get prism pipeline", e);
}
GRAPHICS_PIPELINE = pipelineName;
GPU_ACCELERATION_ENABLED = !pipelineName.endsWith(".SWPipeline");
}
/// @see Platform.Preferences
public static final @Nullable ObservableMap<String, Object> PREFERENCES;
public static final @Nullable ObservableBooleanValue DARK_MODE;
public static final @Nullable Boolean REDUCED_MOTION;
public static final @Nullable MethodHandle TEXT_TRUNCATED_PROPERTY;
static {
String jfxVersion = System.getProperty("javafx.version");
int majorVersion = -1;
@@ -144,65 +120,27 @@ public final class FXUtils {
}
}
JAVAFX_MAJOR_VERSION = majorVersion;
ObservableMap<String, Object> preferences = null;
ObservableBooleanValue darkMode = null;
Boolean reducedMotion = null;
if (JAVAFX_MAJOR_VERSION >= 22) {
try {
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
Class<?> preferencesClass = Class.forName("javafx.application.Platform$Preferences");
@SuppressWarnings("unchecked")
var preferences0 = (ObservableMap<String, Object>) lookup.findStatic(Platform.class, "getPreferences", MethodType.methodType(preferencesClass))
.invoke();
preferences = preferences0;
@SuppressWarnings("unchecked")
var colorSchemeProperty =
(ReadOnlyObjectProperty<? extends Enum<?>>)
lookup.findVirtual(preferencesClass, "colorSchemeProperty", MethodType.methodType(ReadOnlyObjectProperty.class))
.invoke(preferences);
darkMode = Bindings.createBooleanBinding(() ->
"DARK".equals(colorSchemeProperty.get().name()), colorSchemeProperty);
if (JAVAFX_MAJOR_VERSION >= 24) {
reducedMotion = (boolean)
lookup.findVirtual(preferencesClass, "isReducedMotion", MethodType.methodType(boolean.class))
.invoke(preferences);
}
} catch (Throwable e) {
LOG.warning("Failed to get preferences", e);
}
}
PREFERENCES = preferences;
DARK_MODE = darkMode;
REDUCED_MOTION = reducedMotion;
MethodHandle textTruncatedProperty = null;
if (JAVAFX_MAJOR_VERSION >= 23) {
try {
textTruncatedProperty = MethodHandles.publicLookup().findVirtual(
Labeled.class,
"textTruncatedProperty",
MethodType.methodType(ReadOnlyBooleanProperty.class)
);
} catch (Throwable e) {
LOG.warning("Failed to lookup textTruncatedProperty", e);
}
}
TEXT_TRUNCATED_PROPERTY = textTruncatedProperty;
}
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
public static final List<String> IMAGE_EXTENSIONS = Lang.immutableListOf(
"png", "jpg", "jpeg", "bmp", "gif", "webp", "apng"
"png", "jpg", "jpeg", "bmp", "gif", "webp"
);
private static final Map<String, Image> builtinImageCache = new ConcurrentHashMap<>();
private static final Map<String, Path> remoteImageCache = new ConcurrentHashMap<>();
public static void shutdown() {
for (Map.Entry<String, Path> entry : remoteImageCache.entrySet()) {
try {
Files.deleteIfExists(entry.getValue());
} catch (IOException e) {
LOG.warning(String.format("Failed to delete cache file %s.", entry.getValue()), e);
}
remoteImageCache.remove(entry.getKey());
}
builtinImageCache.clear();
}
@@ -391,35 +329,12 @@ public final class FXUtils {
ScrollUtils.addSmoothScrolling(scrollPane);
}
/// If the current environment is JavaFX 23 or higher, this method returns [Labeled#textTruncatedProperty()];
/// Otherwise, it returns `null`.
public static @Nullable ReadOnlyBooleanProperty textTruncatedProperty(Labeled labeled) {
if (TEXT_TRUNCATED_PROPERTY != null) {
try {
return (ReadOnlyBooleanProperty) TEXT_TRUNCATED_PROPERTY.invokeExact(labeled);
} catch (RuntimeException | Error e) {
throw e;
} catch (Throwable e) {
throw new RuntimeException(e);
}
} else {
return null;
}
}
private static final Duration TOOLTIP_FAST_SHOW_DELAY = Duration.millis(50);
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
public static void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
tooltip.setShowDelay(showDelay);
tooltip.setShowDuration(showDuration);
tooltip.setHideDelay(hideDelay);
Tooltip.install(node, tooltip);
}
public static void installFastTooltip(Node node, Tooltip tooltip) {
runInFX(() -> installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
runInFX(() -> TooltipInstaller.INSTALLER.installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
}
public static void installFastTooltip(Node node, String tooltip) {
@@ -427,7 +342,7 @@ public final class FXUtils {
}
public static void installSlowTooltip(Node node, Tooltip tooltip) {
runInFX(() -> installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
runInFX(() -> TooltipInstaller.INSTALLER.installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
}
public static void installSlowTooltip(Node node, String tooltip) {
@@ -458,27 +373,20 @@ public final class FXUtils {
}
}
public static void openFolder(Path file) {
if (file.getFileSystem() != FileSystems.getDefault()) {
LOG.warning("Cannot open folder as the file system is not supported: " + file);
public static void openFolder(File file) {
if (!FileUtils.makeDirectory(file)) {
LOG.error("Unable to make directory " + file);
return;
}
try {
Files.createDirectories(file);
} catch (IOException e) {
LOG.warning("Failed to create directory " + file);
return;
}
String path = FileUtils.getAbsolutePath(file);
String path = file.getAbsolutePath();
String openCommand;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
openCommand = "explorer.exe";
else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
openCommand = "/usr/bin/open";
else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && Files.exists(Path.of("/usr/bin/xdg-open")))
else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && new File("/usr/bin/xdg-open").exists())
openCommand = "/usr/bin/xdg-open";
else
openCommand = null;
@@ -500,7 +408,7 @@ public final class FXUtils {
// Fallback to java.awt.Desktop::open
try {
java.awt.Desktop.getDesktop().open(file.toFile());
java.awt.Desktop.getDesktop().open(file);
} catch (Throwable e) {
LOG.error("Unable to open " + path + " by java.awt.Desktop.getDesktop()::open", e);
}
@@ -543,11 +451,11 @@ public final class FXUtils {
}
// Fallback to open folder
openFolder(file.getParent());
openFolder(file.getParent().toFile());
});
} else {
// We do not have a universal method to show file in file manager.
openFolder(file.getParent());
openFolder(file.getParent().toFile());
}
}
@@ -570,35 +478,37 @@ public final class FXUtils {
if (link == null)
return;
String uri = NetworkUtils.encodeLocation(link);
thread(() -> {
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", uri});
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
try {
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", link});
return;
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
Runtime.getRuntime().exec(new String[]{"open", uri});
return;
} else {
for (String browser : linuxBrowsers) {
Path path = SystemUtils.which(browser);
if (path != null) {
try {
Runtime.getRuntime().exec(new String[]{path.toString(), uri});
return;
} catch (Throwable ignored) {
}
} catch (Throwable e) {
LOG.warning("An exception occurred while calling rundll32", e);
}
}
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
for (String browser : linuxBrowsers) {
Path path = SystemUtils.which(browser);
if (path != null) {
try {
Runtime.getRuntime().exec(new String[]{path.toString(), link});
return;
} catch (Throwable ignored) {
}
}
LOG.warning("No known browser found");
}
} catch (Throwable e) {
LOG.warning("Failed to open link: " + link + ", fallback to java.awt.Desktop", e);
}
try {
java.awt.Desktop.getDesktop().browse(new URI(uri));
java.awt.Desktop.getDesktop().browse(new URI(link));
} catch (Throwable e) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
try {
Runtime.getRuntime().exec(new String[]{"/usr/bin/open", link});
} catch (IOException ex) {
LOG.warning("Unable to open link: " + link, ex);
}
LOG.warning("Failed to open link: " + link, e);
}
});
@@ -795,240 +705,6 @@ public final class FXUtils {
property.removeListener(binding);
}
private static final class PaintBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<ColorPicker> colorPickerRef;
private final WeakReference<Property<Paint>> propertyRef;
private final int hashCode;
private boolean updating = false;
private PaintBidirectionalBinding(ColorPicker colorPicker, Property<Paint> property) {
this.colorPickerRef = new WeakReference<>(colorPicker);
this.propertyRef = new WeakReference<>(property);
this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property);
}
@Override
public void invalidated(Observable sourceProperty) {
if (!updating) {
final ColorPicker colorPicker = colorPickerRef.get();
final Property<Paint> property = propertyRef.get();
if (colorPicker == null || property == null) {
if (colorPicker != null) {
colorPicker.valueProperty().removeListener(this);
}
if (property != null) {
property.removeListener(this);
}
} else {
updating = true;
try {
if (property == sourceProperty) {
Paint newValue = property.getValue();
if (newValue instanceof Color)
colorPicker.setValue((Color) newValue);
else
colorPicker.setValue(null);
} else {
Paint newValue = colorPicker.getValue();
property.setValue(newValue);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return colorPickerRef.get() == null || propertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof FXUtils.PaintBidirectionalBinding))
return false;
var that = (FXUtils.PaintBidirectionalBinding) o;
final ColorPicker colorPicker = this.colorPickerRef.get();
final Property<Paint> property = this.propertyRef.get();
final ColorPicker thatColorPicker = that.colorPickerRef.get();
final Property<?> thatProperty = that.propertyRef.get();
if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null)
return false;
return colorPicker == thatColorPicker && property == thatProperty;
}
}
public static void bindPaint(ColorPicker colorPicker, Property<Paint> property) {
PaintBidirectionalBinding binding = new PaintBidirectionalBinding(colorPicker, property);
colorPicker.valueProperty().removeListener(binding);
property.removeListener(binding);
if (property.getValue() instanceof Color)
colorPicker.setValue((Color) property.getValue());
else
colorPicker.setValue(null);
colorPicker.valueProperty().addListener(binding);
property.addListener(binding);
}
private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<JFXComboBox<String>> comboBoxRef;
private final WeakReference<IntegerProperty> widthPropertyRef;
private final WeakReference<IntegerProperty> heightPropertyRef;
private final int hashCode;
private boolean updating = false;
private WindowsSizeBidirectionalBinding(JFXComboBox<String> comboBox,
IntegerProperty widthProperty,
IntegerProperty heightProperty) {
this.comboBoxRef = new WeakReference<>(comboBox);
this.widthPropertyRef = new WeakReference<>(widthProperty);
this.heightPropertyRef = new WeakReference<>(heightProperty);
this.hashCode = System.identityHashCode(comboBox)
^ System.identityHashCode(widthProperty)
^ System.identityHashCode(heightProperty);
}
@Override
public void invalidated(Observable observable) {
if (!updating) {
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null) {
if (comboBox != null) {
comboBox.focusedProperty().removeListener(this);
comboBox.sceneProperty().removeListener(this);
}
if (widthProperty != null)
widthProperty.removeListener(this);
if (heightProperty != null)
heightProperty.removeListener(this);
} else {
updating = true;
try {
int width = widthProperty.get();
int height = heightProperty.get();
if (observable instanceof ReadOnlyProperty<?>
&& ((ReadOnlyProperty<?>) observable).getBean() == comboBox) {
String value = comboBox.valueProperty().get();
if (value == null)
value = "";
int idx = value.indexOf('x');
if (idx < 0)
idx = value.indexOf('*');
if (idx < 0) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
String widthStr = value.substring(0, idx).trim();
String heightStr = value.substring(idx + 1).trim();
int newWidth;
int newHeight;
try {
newWidth = Integer.parseInt(widthStr);
newHeight = Integer.parseInt(heightStr);
} catch (NumberFormatException e) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
widthProperty.set(newWidth);
heightProperty.set(newHeight);
} else {
comboBox.setValue(width + "x" + height);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return this.comboBoxRef.get() == null
|| this.widthPropertyRef.get() == null
|| this.heightPropertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof WindowsSizeBidirectionalBinding))
return false;
var that = (WindowsSizeBidirectionalBinding) obj;
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
var thatComboBox = that.comboBoxRef.get();
var thatWidthProperty = that.widthPropertyRef.get();
var thatHeightProperty = that.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null
|| thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) {
return false;
}
return comboBox == thatComboBox
&& widthProperty == thatWidthProperty
&& heightProperty == thatHeightProperty;
}
}
public static void bindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
comboBox.setValue(widthProperty.get() + "x" + heightProperty.get());
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().addListener(binding);
comboBox.sceneProperty().addListener(binding);
widthProperty.addListener(binding);
heightProperty.addListener(binding);
}
public static void unbindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().removeListener(binding);
comboBox.sceneProperty().removeListener(binding);
widthProperty.removeListener(binding);
heightProperty.removeListener(binding);
}
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
int itemCount = children.length;
int childSelectedCount = 0;
@@ -1096,53 +772,98 @@ public final class FXUtils {
stage.getIcons().add(newBuiltinImage(icon));
}
private static Image loadWebPImage(InputStream input) throws IOException {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
reader.setInput(imageInput, true, true);
return SwingFXUtils.toFXImage(reader.read(0, reader.getDefaultReadParam()), null);
} finally {
reader.dispose();
}
}
public static Image loadWebPImage(InputStream input,
int requestedWidth, int requestedHeight,
boolean preserveRatio, boolean smooth) throws IOException {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
BufferedImage bufferedImage;
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
reader.setInput(imageInput, true, true);
bufferedImage = reader.read(0, reader.getDefaultReadParam());
} finally {
reader.dispose();
}
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
}
public static Image loadImage(Path path) throws Exception {
return loadImage(path, 0, 0, false, false);
try (InputStream input = Files.newInputStream(path)) {
if ("webp".equalsIgnoreCase(FileUtils.getExtension(path)))
return loadWebPImage(input);
else {
Image image = new Image(input);
if (image.isError())
throw image.getException();
return image;
}
}
}
public static Image loadImage(Path path,
int requestedWidth, int requestedHeight,
boolean preserveRatio, boolean smooth) throws Exception {
try (var input = new BufferedInputStream(Files.newInputStream(path))) {
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
String ext = FileUtils.getExtension(path).toLowerCase(Locale.ROOT);
ImageLoader loader = ImageUtils.EXT_TO_LOADER.get(ext);
if (loader == null && !ImageUtils.DEFAULT_EXTS.contains(ext)) {
input.mark(ImageUtils.HEADER_BUFFER_SIZE);
byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
input.reset();
loader = ImageUtils.guessLoader(headerBuffer);
if ("webp".equalsIgnoreCase(ext))
return loadWebPImage(Channels.newInputStream(channel),
requestedWidth, requestedHeight, preserveRatio, smooth);
if (!IMAGE_EXTENSIONS.contains(ext)) {
byte[] header = new byte[12];
ByteBuffer buffer = ByteBuffer.wrap(header);
//noinspection StatementWithEmptyBody
while (buffer.hasRemaining() && channel.read(buffer) > 0) {
}
channel.position(0L);
if (!buffer.hasRemaining()) {
// WebP File
if (header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F' &&
header[8] == 'W' && header[9] == 'E' && header[10] == 'B' && header[11] == 'P') {
return loadWebPImage(Channels.newInputStream(channel),
requestedWidth, requestedHeight, preserveRatio, smooth);
}
}
}
if (loader == null)
loader = ImageUtils.DEFAULT;
return loader.load(input, requestedWidth, requestedHeight, preserveRatio, smooth);
Image image = new Image(Channels.newInputStream(channel),
requestedWidth, requestedHeight,
preserveRatio, smooth);
if (image.isError())
throw image.getException();
return image;
}
}
public static Image loadImage(String url) throws Exception {
URI uri = NetworkUtils.toURI(url);
URLConnection connection = NetworkUtils.createConnection(uri);
if (connection instanceof HttpURLConnection)
public static Image loadImage(URL url) throws Exception {
URLConnection connection = NetworkUtils.createConnection(url);
if (connection instanceof HttpURLConnection) {
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
}
try (BufferedInputStream input = new BufferedInputStream(connection.getInputStream())) {
String contentType = Objects.requireNonNull(connection.getContentType(), "");
Matcher matcher = ImageUtils.CONTENT_TYPE_PATTERN.matcher(contentType);
if (matcher.find())
contentType = matcher.group("type");
ImageLoader loader = ImageUtils.CONTENT_TYPE_TO_LOADER.get(contentType);
if (loader == null && !ImageUtils.DEFAULT_CONTENT_TYPES.contains(contentType)) {
input.mark(ImageUtils.HEADER_BUFFER_SIZE);
byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
input.reset();
loader = ImageUtils.guessLoader(headerBuffer);
try (InputStream input = connection.getInputStream()) {
String path = url.getPath();
if (path != null && "webp".equalsIgnoreCase(StringUtils.substringAfterLast(path, '.')))
return loadWebPImage(input);
else {
Image image = new Image(input);
if (image.isError())
throw image.getException();
return image;
}
if (loader == null)
loader = ImageUtils.DEFAULT;
return loader.load(input, 0, 0, false, false);
}
}
@@ -1187,12 +908,55 @@ public final class FXUtils {
}
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(url)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth));
return Task.composeAsync(() -> {
Path currentPath = remoteImageCache.get(url);
if (currentPath != null) {
if (Files.isReadable(currentPath))
return Task.completed(currentPath);
// The file is unavailable or unreadable.
remoteImageCache.remove(url);
try {
Files.deleteIfExists(currentPath);
} catch (IOException e) {
LOG.warning("An exception encountered while deleting broken cached image file.", e);
}
}
Path newPath = Files.createTempFile("hmcl-net-resource-cache-", ".cache");
return new FileDownloadTask(NetworkUtils.toURL(url), newPath.toFile())
.thenSupplyAsync(() -> {
Path otherPath = remoteImageCache.putIfAbsent(url, newPath);
if (otherPath == null)
return newPath;
else {
// The image has been loaded in another task. Delete the image here in order not to pollute the tmp folder.
Files.delete(newPath);
return otherPath;
}
});
}).thenApplyAsync(path -> loadImage(path, requestedWidth, requestedHeight, preserveRatio, smooth));
}
/**
* Load image from the internet. It will cache the data of images for the further usage.
* The cached data will be deleted when HMCL is closed or hidden.
*
* @param url the url of image. The image resource should be a file on the internet.
* @param requestedWidth the image's bounding box width
* @param requestedHeight the image's bounding box height
* @param preserveRatio indicates whether to preserve the aspect ratio of
* the original image when scaling to fit the image within the
* specified bounding box
* @param smooth indicates whether to use a better quality filtering
* algorithm or a faster one when scaling this image to fit within
* the specified bounding box
* @return the image resource within the jar.
*/
public static ObservableValue<Image> newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
var image = new SimpleObjectProperty<Image>();
SimpleObjectProperty<Image> image = new SimpleObjectProperty<>();
getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
@@ -1215,57 +979,29 @@ 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;
}
public static JFXButton newToggleButton4(SVG icon) {
JFXButton button = new JFXButton();
button.getStyleClass().add("toggle-icon4");
button.setGraphic(icon.createIcon(Theme.blackFill(), -1));
return button;
}
public static Label newSafeTruncatedLabel(String text) {
Label label = new Label(text);
label.setTextOverrun(OverrunStyle.CENTER_WORD_ELLIPSIS);
showTooltipWhenTruncated(label);
public static Label truncatedLabel(String text, int limit) {
Label label = new Label();
if (text.length() <= limit) {
label.setText(text);
} else {
label.setText(StringUtils.truncate(text, limit));
installFastTooltip(label, text);
}
return label;
}
private static final String LABEL_FULL_TEXT_PROP_KEY = FXUtils.class.getName() + ".LABEL_FULL_TEXT";
public static void showTooltipWhenTruncated(Labeled labeled) {
ReadOnlyBooleanProperty textTruncatedProperty = textTruncatedProperty(labeled);
if (textTruncatedProperty != null) {
ChangeListener<Boolean> listener = (observable, oldValue, newValue) -> {
var label = (Labeled) ((ReadOnlyProperty<?>) observable).getBean();
var tooltip = (Tooltip) label.getProperties().get(LABEL_FULL_TEXT_PROP_KEY);
if (newValue) {
if (tooltip == null) {
tooltip = new Tooltip();
tooltip.textProperty().bind(label.textProperty());
label.getProperties().put(LABEL_FULL_TEXT_PROP_KEY, tooltip);
}
FXUtils.installFastTooltip(label, tooltip);
} else if (tooltip != null) {
Tooltip.uninstall(label, tooltip);
}
};
listener.changed(textTruncatedProperty, false, textTruncatedProperty.get());
textTruncatedProperty.addListener(listener);
}
}
public static void applyDragListener(Node node, PathMatcher filter, Consumer<List<Path>> callback) {
public static void applyDragListener(Node node, FileFilter filter, Consumer<List<File>> callback) {
applyDragListener(node, filter, callback, null);
}
public static void applyDragListener(Node node, PathMatcher filter, Consumer<List<Path>> callback, Runnable dragDropped) {
public static void applyDragListener(Node node, FileFilter filter, Consumer<List<File>> callback, Runnable dragDropped) {
node.setOnDragOver(event -> {
if (event.getGestureSource() != node && event.getDragboard().hasFiles()) {
if (event.getDragboard().getFiles().stream().map(File::toPath).anyMatch(filter::matches))
if (event.getDragboard().getFiles().stream().anyMatch(filter::accept))
event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
}
event.consume();
@@ -1274,7 +1010,7 @@ public final class FXUtils {
node.setOnDragDropped(event -> {
List<File> files = event.getDragboard().getFiles();
if (files != null) {
List<Path> acceptFiles = files.stream().map(File::toPath).filter(filter::matches).toList();
List<File> acceptFiles = files.stream().filter(filter::accept).collect(Collectors.toList());
if (!acceptFiles.isEmpty()) {
callback.accept(acceptFiles);
event.setDropCompleted(true);
@@ -1366,27 +1102,6 @@ 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) {
@@ -1479,65 +1194,4 @@ public final class FXUtils {
return new FileChooser.ExtensionFilter(i18n("extension.png"),
IMAGE_EXTENSIONS.stream().map(ext -> "*." + ext).toArray(String[]::new));
}
/**
* Intelligently determines the popup position to prevent the menu from exceeding screen boundaries.
* Supports multi-monitor setups by detecting the current screen where the component is located.
* Now handles first-time popup display by forcing layout measurement.
*
* @param root the root node to calculate position relative to
* @param popupInstance the popup instance to position
* @return the optimal vertical position for the popup menu
*/
public static JFXPopup.PopupVPosition determineOptimalPopupPosition(Node root, JFXPopup popupInstance) {
// Get the screen bounds in screen coordinates
Bounds screenBounds = root.localToScreen(root.getBoundsInLocal());
// Convert Bounds to Rectangle2D for getScreensForRectangle method
Rectangle2D boundsRect = new Rectangle2D(
screenBounds.getMinX(), screenBounds.getMinY(),
screenBounds.getWidth(), screenBounds.getHeight()
);
// Find the screen that contains this component (supports multi-monitor)
List<Screen> screens = Screen.getScreensForRectangle(boundsRect);
Screen currentScreen = screens.isEmpty() ? Screen.getPrimary() : screens.get(0);
Rectangle2D visualBounds = currentScreen.getVisualBounds();
double screenHeight = visualBounds.getHeight();
double screenMinY = visualBounds.getMinY();
double itemScreenY = screenBounds.getMinY();
// Calculate available space relative to the current screen
double availableSpaceAbove = itemScreenY - screenMinY;
double availableSpaceBelow = screenMinY + screenHeight - itemScreenY - root.getBoundsInLocal().getHeight();
// Get popup content and ensure it's properly measured
Region popupContent = popupInstance.getPopupContent();
double menuHeight;
if (popupContent.getHeight() <= 0) {
// Force layout measurement if height is not yet available
popupContent.autosize();
popupContent.applyCss();
popupContent.layout();
// Get the measured height, or use a reasonable fallback
menuHeight = popupContent.getHeight();
if (menuHeight <= 0) {
// Fallback: estimate based on number of menu items
// Each menu item is roughly 36px height + separators + padding
menuHeight = 300; // Conservative estimate for the current menu structure
}
} else {
menuHeight = popupContent.getHeight();
}
// Add some margin for safety
menuHeight += 20;
return (availableSpaceAbove > menuHeight && availableSpaceBelow < menuHeight)
? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward
: JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward
}
}
@@ -129,7 +129,7 @@ public class GameCrashWindow extends Stage {
return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>());
}), Task.supplyAsync(() -> {
Path latestLog = repository.getRunDirectory(version.getId()).resolve("logs/latest.log");
Path latestLog = repository.getRunDirectory(version.getId()).toPath().resolve("logs/latest.log");
if (!Files.isReadable(latestLog)) {
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
}
@@ -379,7 +379,7 @@ public class GameCrashWindow extends Stage {
TwoLineListItem gameDir = new TwoLineListItem();
gameDir.getStyleClass().setAll("two-line-item-second-large");
gameDir.setTitle(i18n("game.directory"));
gameDir.setSubtitle(launchOptions.getGameDir().toAbsolutePath().toString());
gameDir.setSubtitle(launchOptions.getGameDir().getAbsolutePath());
FXUtils.installFastTooltip(gameDir, i18n("game.directory"));
TwoLineListItem javaDir = new TwoLineListItem();
@@ -139,9 +139,6 @@ public class InstallerItem extends Control {
case "forge":
iconType = VersionIconType.FORGE;
break;
case "cleanroom":
iconType = VersionIconType.CLEANROOM;
break;
case "liteloader":
iconType = VersionIconType.CHICKEN;
break;
@@ -236,7 +233,6 @@ public class InstallerItem extends Control {
InstallerItem fabric = new InstallerItem(FABRIC, style);
InstallerItem fabricApi = new InstallerItem(FABRIC_API, style);
InstallerItem forge = new InstallerItem(FORGE, style);
InstallerItem cleanroom = new InstallerItem(CLEANROOM, style);
InstallerItem neoForge = new InstallerItem(NEO_FORGE, style);
InstallerItem liteLoader = new InstallerItem(LITELOADER, style);
InstallerItem optiFine = new InstallerItem(OPTIFINE, style);
@@ -244,11 +240,11 @@ public class InstallerItem extends Control {
InstallerItem quiltApi = new InstallerItem(QUILT_API, style);
Map<InstallerItem, Set<InstallerItem>> incompatibleMap = new HashMap<>();
mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom);
addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge, cleanroom);
addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge, cleanroom);
addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine, cleanroom);
addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom);
mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge);
addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge);
addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge);
addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine);
addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine);
for (Map.Entry<InstallerItem, Set<InstallerItem>> entry : incompatibleMap.entrySet()) {
InstallerItem item = entry.getKey();
@@ -282,7 +278,7 @@ public class InstallerItem extends Control {
game.versionProperty.set(new InstalledState(gameVersion, false, false));
}
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, cleanroom};
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi};
for (InstallerItem item : all) {
if (!item.resolvedStateProperty.isBound()) {
@@ -298,8 +294,6 @@ public class InstallerItem extends Control {
if (gameVersion == null) {
this.libraries = all;
} else if (gameVersion.equals("1.12.2")) {
this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, optiFine};
} else if (GameVersionNumber.compare(gameVersion, "1.13") < 0) {
this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine};
} else {
@@ -329,7 +323,11 @@ public class InstallerItem extends Control {
Pane pane;
if (control.style == Style.CARD) {
pane = new VBox();
holder = FXUtils.onWeakChangeAndOperate(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7));
if (FXUtils.JAVAFX_MAJOR_VERSION == 8)
holder = FXUtils.onWeakChange(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7));
else
holder = FXUtils.onWeakChangeAndOperate(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7));
} else {
pane = new HBox();
holder = null;
@@ -64,9 +64,11 @@ public class ListPageSkin extends SkinBase<ListPage<?>> {
vBox.setAlignment(Pos.BOTTOM_RIGHT);
vBox.setPickOnBounds(false);
JFXButton btnAdd = FXUtils.newRaisedButton("");
JFXButton btnAdd = new JFXButton();
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());
@@ -38,9 +38,9 @@ import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.LauncherHelper;
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;
@@ -186,7 +186,7 @@ public final class LogWindow extends Stage {
}
private void onTerminateGame() {
LogWindow.this.gameProcess.stop();
LauncherHelper.stopManagedProcesses();
}
private void onClear() {
@@ -224,7 +224,7 @@ public final class LogWindow extends Stage {
try {
if (gameProcess.isRunning()) {
GameDumpGenerator.writeDumpTo(gameProcess.getProcess().pid(), dumpFile);
GameDumpGenerator.writeDumpTo(gameProcess.getPID(), dumpFile);
FXUtils.showFileInExplorer(dumpFile);
}
} catch (Throwable e) {
@@ -312,7 +312,6 @@ 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) {
@@ -1,27 +1,7 @@
/*
* 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;
package org.jackhuang.hmcl.ui;
import javax.swing.*;
/**
* @author Glavo
*/
public final class SwingUtils {
static {
if (System.getProperty("swing.defaultlaf") == null) {
@@ -0,0 +1,123 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2024 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;
import javafx.scene.Node;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public class TooltipInstaller {
public static final TooltipInstaller INSTALLER;
static {
TooltipInstaller installer = null;
try {
installer = new NewInstaller();
} catch (Throwable e) {
try {
installer = new OldInstaller();
} catch (Throwable e2) {
e2.addSuppressed(e);
LOG.warning("Failed to initialize TooltipInstaller", e2);
}
}
INSTALLER = installer != null ? installer : new TooltipInstaller();
}
TooltipInstaller() {
}
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
Tooltip.install(node, tooltip);
}
// For Java 8
private static final class OldInstaller extends TooltipInstaller {
private static final Constructor<?> createTooltipBehavior;
private static final Method installTooltipBehavior;
static {
try {
Class<?> behaviorClass = Class.forName("javafx.scene.control.Tooltip$TooltipBehavior");
createTooltipBehavior = behaviorClass.getDeclaredConstructor(Duration.class, Duration.class, Duration.class, boolean.class);
createTooltipBehavior.setAccessible(true);
installTooltipBehavior = behaviorClass.getDeclaredMethod("install", Node.class, Tooltip.class);
installTooltipBehavior.setAccessible(true);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
@Override
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
try {
Object behavior = createTooltipBehavior.newInstance(showDelay, showDuration, hideDelay, false);
installTooltipBehavior.invoke(behavior, node, tooltip);
} catch (ReflectiveOperationException e) {
LOG.warning("Failed to set tooltip show delay", e);
Tooltip.install(node, tooltip);
}
}
}
// For Java 9+
private static final class NewInstaller extends TooltipInstaller {
private static final MethodHandle setTooltipShowDelay;
private static final MethodHandle setTooltipShowDuration;
private static final MethodHandle setTooltipHideDelay;
static {
try {
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
MethodType methodType = MethodType.methodType(void.class, Duration.class);
setTooltipShowDelay = lookup.findVirtual(Tooltip.class, "setShowDelay", methodType);
setTooltipShowDuration = lookup.findVirtual(Tooltip.class, "setShowDuration", methodType);
setTooltipHideDelay = lookup.findVirtual(Tooltip.class, "setHideDelay", methodType);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
@Override
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
try {
setTooltipShowDelay.invokeExact(tooltip, showDelay);
setTooltipShowDuration.invokeExact(tooltip, showDuration);
setTooltipHideDelay.invokeExact(tooltip, hideDelay);
} catch (Throwable e) {
LOG.warning("Failed to set tooltip show delay", e);
}
Tooltip.install(node, tooltip);
}
}
}
@@ -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,6 +21,7 @@ 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;
@@ -76,9 +77,25 @@ public class AccountAdvancedListItem extends AdvancedListItem {
setActionButtonVisible(false);
FXUtils.onScroll(this, Accounts.getAccounts(),
accounts -> accounts.indexOf(account.get()),
Accounts::setSelectedAccount);
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()));
});
}
public ObjectProperty<Account> accountProperty() {
@@ -41,14 +41,13 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogController;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import org.jackhuang.hmcl.util.skin.NormalizedSkin;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CancellationException;
@@ -117,7 +116,8 @@ public class AccountListItem extends RadioButton {
}
public ObservableBooleanValue canUploadSkin() {
if (account instanceof AuthlibInjectorAccount aiAccount) {
if (account instanceof AuthlibInjectorAccount) {
AuthlibInjectorAccount aiAccount = (AuthlibInjectorAccount) account;
ObjectBinding<Optional<CompleteGameProfile>> profile = aiAccount.getYggdrasilService().getProfileRepository().binding(aiAccount.getUUID());
return createBooleanBinding(() -> {
Set<TextureType> uploadableTextures = profile.get()
@@ -148,7 +148,7 @@ public class AccountListItem extends RadioButton {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("account.skin.upload"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png"));
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
File selectedFile = chooser.showOpenDialog(Controllers.getStage());
if (selectedFile == null) {
return null;
}
@@ -156,7 +156,7 @@ public class AccountListItem extends RadioButton {
return refreshAsync()
.thenRunAsync(() -> {
Image skinImg;
try (var input = Files.newInputStream(selectedFile)) {
try (FileInputStream input = new FileInputStream(selectedFile)) {
skinImg = new Image(input);
} catch (IOException e) {
throw new InvalidSkinException("Failed to read skin image", e);
@@ -167,7 +167,7 @@ public class AccountListItem extends RadioButton {
NormalizedSkin skin = new NormalizedSkin(skinImg);
String model = skin.isSlim() ? "slim" : "";
LOG.info("Uploading skin [" + selectedFile + "], model [" + model + "]");
account.uploadSkin(skin.isSlim(), selectedFile);
account.uploadSkin(skin.isSlim(), selectedFile.toPath());
})
.thenComposeAsync(refreshAsync())
.whenComplete(Schedulers.javafx(), e -> {
@@ -33,7 +33,6 @@ import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.Theme;
@@ -129,10 +128,6 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
JFXButton btnRefresh = new JFXButton();
SpinnerPane spinnerRefresh = new SpinnerPane();
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");
if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
btnRefresh.setDisable(true);
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot"));
}
btnRefresh.setOnAction(e -> {
spinnerRefresh.showSpinner();
skinnable.refreshAsync()
@@ -41,8 +41,6 @@ import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.ClassTitle;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jackhuang.hmcl.util.platform.NativeUtils;
@@ -50,9 +48,9 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
import java.time.Duration;
import java.net.URI;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Locale;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
@@ -65,22 +63,27 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true);
private static boolean isExemptedRegion() {
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
String zoneId = ZoneId.systemDefault().getId();
if (Arrays.asList(
"Asia/Shanghai",
// Although Asia/Beijing is not a legal name, Deepin uses it
"Asia/Beijing",
"Asia/Chongqing",
"Asia/Chungking",
"Asia/Harbin"
).contains(zoneId))
return true;
// Check if the time zone is UTC+8
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry()))
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
Kernel32 kernel32 = Kernel32.INSTANCE;
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
return true;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
Kernel32 kernel32 = Kernel32.INSTANCE;
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
return true;
}
}
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && "GMT+08:00".equals(zoneId))
// Some Linux distributions may use invalid time zone ids (e.g., Asia/Beijing)
// Java may not be able to resolve this name and use GMT+08:00 instead.
return true;
return false;
}
@@ -187,7 +190,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
item.titleProperty().bind(title);
String host = "";
try {
host = NetworkUtils.toURI(server.getUrl()).getHost();
host = URI.create(server.getUrl()).getHost();
} catch (IllegalArgumentException e) {
LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e);
}
@@ -31,6 +31,7 @@ import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import javax.net.ssl.SSLException;
import java.io.IOException;
@@ -195,8 +196,7 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
lblServerName.setText(serverBeingAdded.getName());
lblServerUrl.setText(serverBeingAdded.getUrl());
//noinspection HttpUrlsUsage
lblServerWarning.setVisible(serverBeingAdded.getUrl().startsWith("http://"));
lblServerWarning.setVisible("http".equals(NetworkUtils.toURL(serverBeingAdded.getUrl()).getProtocol()));
this.setContent(confirmServerPane, ContainerAnimations.SWIPE_LEFT);
} else {
@@ -39,9 +39,8 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
import java.io.File;
import java.util.Arrays;
import java.util.UUID;
@@ -82,16 +81,16 @@ public class OfflineAccountSkinPane extends StackPane {
canvas.addEventHandler(DragEvent.DRAG_OVER, e -> {
if (e.getDragboard().hasFiles()) {
Path file = e.getDragboard().getFiles().get(0).toPath();
if (FileUtils.getName(file).endsWith(".png"))
File file = e.getDragboard().getFiles().get(0);
if (file.getAbsolutePath().endsWith(".png"))
e.acceptTransferModes(TransferMode.COPY);
}
});
canvas.addEventHandler(DragEvent.DRAG_DROPPED, e -> {
if (e.isAccepted()) {
Path skin = e.getDragboard().getFiles().get(0).toPath();
File skin = e.getDragboard().getFiles().get(0);
Platform.runLater(() -> {
skinSelector.setValue(FileUtils.getAbsolutePath(skin));
skinSelector.setValue(skin.getAbsolutePath());
skinItem.setSelectedData(Skin.Type.LOCAL_FILE);
});
}
@@ -20,7 +20,6 @@ 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;
@@ -41,16 +40,8 @@ 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) {
@@ -27,19 +27,17 @@ import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.stage.DirectoryChooser;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class FileItem extends BorderPane {
private final Label lblPath = new Label();
@@ -79,14 +77,14 @@ public class FileItem extends BorderPane {
private String processPath(String path) {
Path given;
try {
given = Path.of(path).toAbsolutePath().normalize();
given = Paths.get(path).toAbsolutePath();
} catch (IllegalArgumentException e) {
return path;
}
if (isConvertToRelativePath()) {
try {
return Metadata.CURRENT_DIRECTORY.relativize(given).normalize().toString();
return Paths.get(".").normalize().toAbsolutePath().relativize(given).normalize().toString();
} catch (IllegalArgumentException e) {
// the given path can't be relativized against current path
}
@@ -97,22 +95,17 @@ public class FileItem extends BorderPane {
public void onExplore() {
DirectoryChooser chooser = new DirectoryChooser();
if (path.get() != null) {
Path file;
try {
file = Path.of(path.get());
if (Files.exists(file)) {
if (Files.isRegularFile(file))
file = file.toAbsolutePath().normalize().getParent();
else if (Files.isDirectory(file))
file = file.toAbsolutePath().normalize();
chooser.setInitialDirectory(file.toFile());
}
} catch (InvalidPathException e) {
LOG.warning("Failed to resolve path: " + path.get());
File file = new File(path.get());
if (file.exists()) {
if (file.isFile())
file = file.getAbsoluteFile().getParentFile();
else if (file.isDirectory())
file = file.getAbsoluteFile();
chooser.setInitialDirectory(file);
}
}
chooser.titleProperty().bind(titleProperty());
var selectedDir = chooser.showDialog(Controllers.getStage());
File selectedDir = chooser.showDialog(Controllers.getStage());
if (selectedDir != null) {
path.set(processPath(selectedDir.toString()));
}
@@ -31,9 +31,8 @@ import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
import java.io.File;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -87,9 +86,9 @@ public class FileSelector extends HBox {
if (directory) {
DirectoryChooser chooser = new DirectoryChooser();
chooser.setTitle(chooserTitle);
Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage()));
File dir = chooser.showDialog(Controllers.getStage());
if (dir != null) {
String path = FileUtils.getAbsolutePath(dir);
String path = dir.getAbsolutePath();
customField.setText(path);
value.setValue(path);
}
@@ -97,9 +96,9 @@ public class FileSelector extends HBox {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().addAll(getExtensionFilters());
chooser.setTitle(chooserTitle);
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
File file = chooser.showOpenDialog(Controllers.getStage());
if (file != null) {
String path = FileUtils.getAbsolutePath(file);
String path = file.getAbsolutePath();
customField.setText(path);
value.setValue(path);
}
@@ -10,7 +10,6 @@ import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
@@ -22,7 +21,7 @@ import org.jackhuang.hmcl.util.StringUtils;
public class IconedTwoLineListItem extends HBox {
private final StringProperty title = new SimpleStringProperty(this, "title");
private final ObservableList<Label> tags = FXCollections.observableArrayList();
private final ObservableList<String> tags = FXCollections.observableArrayList();
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle");
private final StringProperty externalLink = new SimpleStringProperty(this, "externalLink");
private final ObjectProperty<Image> image = new SimpleObjectProperty<>(this, "image");
@@ -63,7 +62,7 @@ public class IconedTwoLineListItem extends HBox {
this.title.set(title);
}
public ObservableList<Label> getTags() {
public ObservableList<String> getTags() {
return tags;
}
@@ -98,7 +98,7 @@ public final class MessageDialogPane extends HBox {
{
StackPane titlePane = new StackPane();
titlePane.getStyleClass().addAll("jfx-layout-heading", "title");
titlePane.getChildren().setAll(new Label(title != null ? title : i18n("message.info")));
titlePane.getChildren().setAll(new Label(title != null ? title : type.getDisplayName()));
StackPane content = new StackPane();
content.getStyleClass().add("jfx-layout-body");
@@ -108,7 +108,6 @@ public final class MessageDialogPane extends HBox {
content.getChildren().setAll(textFlow);
else {
ScrollPane scrollPane = new ScrollPane(textFlow);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setPrefHeight(350);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
scrollPane.setFitToWidth(true);
@@ -24,13 +24,11 @@ import javafx.beans.property.*;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.Label;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Paint;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
@@ -299,35 +297,4 @@ public final class MultiFileItem<T> extends VBox {
return pane;
}
}
public static final class PaintOption<T> extends Option<T> {
private final ColorPicker colorPicker = new ColorPicker();
public PaintOption(String title, T data) {
super(title, data);
}
public PaintOption<T> bindBidirectional(Property<Paint> property) {
FXUtils.bindPaint(colorPicker, property);
return this;
}
@Override
protected Node createItem(ToggleGroup group) {
BorderPane pane = new BorderPane();
pane.setPadding(new Insets(3));
FXUtils.setLimitHeight(pane, 30);
left.setText(title);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
left.setToggleGroup(group);
left.setUserData(data);
pane.setLeft(left);
colorPicker.disableProperty().bind(left.selectedProperty().not());
BorderPane.setAlignment(colorPicker, Pos.CENTER_RIGHT);
pane.setRight(colorPicker);
return pane;
}
}
}
@@ -191,7 +191,7 @@ public class RipplerContainer extends StackPane {
return StyleableProperties.FACTORY.getCssMetaData();
}
private final static class StyleableProperties {
private 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 final class HeadersRegion extends StackPane {
private 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 final class Top extends Horizontal {
private class Top extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -404,7 +404,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private final class Bottom extends Horizontal {
private class Bottom extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -519,7 +519,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private final class Left extends Vertical {
private class Left extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -543,7 +543,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private final class Right extends Vertical {
private class Right extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -22,10 +22,13 @@ import javafx.application.Platform;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jetbrains.annotations.NotNull;
@@ -40,7 +43,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class TaskExecutorDialogPane extends BorderPane {
private TaskExecutor executor;
private TaskCancellationAction onCancel;
private final Consumer<FetchTask.SpeedEvent> speedEventHandler;
private final Consumer<FileDownloadTask.SpeedEvent> speedEventHandler;
private final Label lblTitle;
private final Label lblProgress;
@@ -58,10 +61,16 @@ public class TaskExecutorDialogPane extends BorderPane {
lblTitle = new Label();
lblTitle.setStyle("-fx-font-size: 14px; -fx-font-weight: BOLD;");
taskListPane = new TaskListPane();
VBox.setVgrow(taskListPane, Priority.ALWAYS);
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
scrollPane.setFitToWidth(true);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
{
taskListPane = new TaskListPane();
scrollPane.setContent(taskListPane);
}
center.getChildren().setAll(lblTitle, taskListPane);
center.getChildren().setAll(lblTitle, scrollPane);
}
BorderPane bottom = new BorderPane();
@@ -99,7 +108,7 @@ public class TaskExecutorDialogPane extends BorderPane {
String finalUnit = unit;
Platform.runLater(() -> lblProgress.setText(String.format("%.1f %s", finalSpeed, finalUnit)));
};
FileDownloadTask.speedEvent.channel(FetchTask.SpeedEvent.class).registerWeak(speedEventHandler);
FileDownloadTask.speedEvent.channel(FileDownloadTask.SpeedEvent.class).registerWeak(speedEventHandler);
onEscPressed(this, btnCancel::fire);
}
@@ -17,23 +17,17 @@
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXProgressBar;
import javafx.application.Platform;
import javafx.beans.WeakListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
@@ -58,7 +52,6 @@ import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackCompletionTask;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthCompletionTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthInstallTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackInstallTask;
import org.jackhuang.hmcl.mod.server.ServerModpackCompletionTask;
@@ -70,86 +63,54 @@ import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.FXThread;
import org.jetbrains.annotations.NotNull;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import java.lang.ref.WeakReference;
import java.util.Collection;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class TaskListPane extends StackPane {
private static final Insets DEFAULT_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 0);
private static final Insets STAGED_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 26);
private TaskExecutor executor;
private final JFXListView<Node> listView = new JFXListView<>();
private final AdvancedListBox listBox = new AdvancedListBox();
private final Map<Task<?>, ProgressListNode> nodes = new HashMap<>();
private final Map<String, StageNode> stageNodes = new HashMap<>();
private final List<StageNode> stageNodes = new ArrayList<>();
private final ObjectProperty<Insets> progressNodePadding = new SimpleObjectProperty<>(Insets.EMPTY);
private final DoubleProperty cellWidth = new SimpleDoubleProperty();
private Cell lastCell;
public TaskListPane() {
listView.setPadding(new Insets(12, 0, 0, 0));
listView.setCellFactory(l -> new Cell());
listView.setSelectionModel(null);
FXUtils.onChangeAndOperate(listView.widthProperty(), width -> {
double w = width.doubleValue();
cellWidth.set(w <= 12.0 ? w : w - 12.0);
});
listBox.setSpacing(0);
getChildren().setAll(listView);
}
@FXThread
private void addStages(@NotNull Collection<String> stages) {
for (String stage : stages) {
stageNodes.computeIfAbsent(stage, s -> {
StageNode node = new StageNode(stage);
listView.getItems().add(node);
return node;
});
}
}
@FXThread
private void updateProgressNodePadding() {
progressNodePadding.set(stageNodes.isEmpty() ? DEFAULT_PROGRESS_NODE_PADDING : STAGED_PROGRESS_NODE_PADDING);
getChildren().setAll(listBox);
}
public void setExecutor(TaskExecutor executor) {
List<String> stages = Lang.removingDuplicates(executor.getStages());
this.executor = executor;
executor.addTaskListener(new TaskListener() {
@Override
public void onStart() {
Platform.runLater(() -> {
stageNodes.clear();
listView.getItems().clear();
addStages(executor.getStages());
updateProgressNodePadding();
listBox.clear();
stageNodes.addAll(stages.stream().map(StageNode::new).collect(Collectors.toList()));
stageNodes.forEach(listBox::add);
if (stages.isEmpty()) progressNodePadding.setValue(new Insets(0, 0, 8, 0));
else progressNodePadding.setValue(new Insets(0, 0, 8, 26));
});
}
@Override
public void onReady(Task<?> task) {
if (task instanceof Task.StagesHintTask) {
Platform.runLater(() -> {
addStages(((Task<?>.StagesHintTask) task).getStages());
updateProgressNodePadding();
});
}
if (task.getStage() != null) {
Platform.runLater(() -> {
StageNode node = stageNodes.get(task.getStage());
if (node != null)
node.begin();
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::begin);
});
}
}
@@ -162,11 +123,7 @@ public final class TaskListPane extends StackPane {
if (task instanceof GameAssetDownloadTask) {
task.setName(i18n("assets.download_all"));
} else if (task instanceof GameInstallTask) {
if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game"))
return;
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
} else if (task instanceof CleanroomInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom")));
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
@@ -199,7 +156,7 @@ public final class TaskListPane extends StackPane {
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
} else if (task instanceof HMCLModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask) {
task.setName(i18n("modpack.export"));
} else if (task instanceof MinecraftInstanceTask) {
task.setName(i18n("modpack.scan"));
@@ -212,25 +169,25 @@ public final class TaskListPane extends StackPane {
Platform.runLater(() -> {
ProgressListNode node = new ProgressListNode(task);
nodes.put(task, node);
StageNode stageNode = stageNodes.get(task.getInheritedStage());
listView.getItems().add(listView.getItems().indexOf(stageNode) + 1, node);
StageNode stageNode = stageNodes.stream().filter(x -> x.stage.equals(task.getInheritedStage())).findAny().orElse(null);
listBox.add(listBox.indexOf(stageNode) + 1, node);
});
}
@Override
public void onFinished(Task<?> task) {
Platform.runLater(() -> {
if (task.getStage() != null) {
StageNode stageNode = stageNodes.get(task.getStage());
if (stageNode != null)
stageNode.succeed();
}
if (task.getStage() != null) {
Platform.runLater(() -> {
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::succeed);
});
}
Platform.runLater(() -> {
ProgressListNode node = nodes.remove(task);
if (node != null) {
node.unbind();
listView.getItems().remove(node);
}
if (node == null)
return;
node.unbind();
listBox.remove(node);
});
}
@@ -238,23 +195,25 @@ public final class TaskListPane extends StackPane {
public void onFailed(Task<?> task, Throwable throwable) {
if (task.getStage() != null) {
Platform.runLater(() -> {
StageNode stageNode = stageNodes.get(task.getStage());
if (stageNode != null)
stageNode.fail();
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::fail);
});
}
ProgressListNode node = nodes.remove(task);
if (node != null)
Platform.runLater(() -> node.setThrowable(throwable));
if (node == null)
return;
Platform.runLater(() -> {
node.setThrowable(throwable);
});
}
@Override
public void onPropertiesUpdate(Task<?> task) {
if (task instanceof Task.CountTask) {
runInFX(() -> {
StageNode stageNode = stageNodes.get(((Task<?>.CountTask) task).getCountStage());
if (stageNode != null)
stageNode.count();
stageNodes.stream()
.filter(x -> x.stage.equals(((Task<?>.CountTask) task).getCountStage()))
.findAny()
.ifPresent(StageNode::count);
});
return;
@@ -263,252 +222,122 @@ public final class TaskListPane extends StackPane {
if (task.getStage() != null) {
int total = tryCast(task.getProperties().get("total"), Integer.class).orElse(0);
runInFX(() -> {
StageNode stageNode = stageNodes.get(task.getStage());
if (stageNode != null)
stageNode.addTotal(total);
stageNodes.stream()
.filter(x -> x.stage.equals(task.getStage()))
.findAny()
.ifPresent(stageNode -> {
stageNode.setTotal(total);
});
});
}
}
});
}
private final class Cell extends ListCell<Node> {
private static final double STATUS_ICON_SIZE = 14;
private final BorderPane pane = new BorderPane();
private final StackPane left = new StackPane();
private static class StageNode extends BorderPane {
private final String stage;
private final Label title = new Label();
private final Label message = new Label();
private final JFXProgressBar bar = new JFXProgressBar();
private WeakReference<StageNode> prevStageNodeRef;
private StatusChangeListener statusChangeListener;
private Cell() {
setPadding(new Insets(0, 0, 4, 0));
prefWidthProperty().bind(cellWidth);
FXUtils.setLimitHeight(left, STATUS_ICON_SIZE);
FXUtils.setLimitWidth(left, STATUS_ICON_SIZE);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
BorderPane.setMargin(left, new Insets(0, 12, 0, 0));
BorderPane.setAlignment(title, Pos.CENTER_LEFT);
pane.setCenter(title);
DoubleBinding barWidth = Bindings.createDoubleBinding(() -> {
Insets padding = pane.getPadding();
Insets insets = pane.getInsets();
return pane.getWidth() - padding.getLeft() - padding.getRight() - insets.getLeft() - insets.getRight();
}, pane.paddingProperty(), pane.widthProperty());
bar.minWidthProperty().bind(barWidth);
bar.prefWidthProperty().bind(barWidth);
bar.maxWidthProperty().bind(barWidth);
setGraphic(pane);
}
private void updateLeftIcon(StageNode.Status status) {
left.getChildren().setAll(status.svg.createIcon(Theme.blackFill(), STATUS_ICON_SIZE));
}
@Override
protected void updateItem(Node item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell && !isVisible())
return;
lastCell = this;
pane.paddingProperty().unbind();
title.textProperty().unbind();
message.textProperty().unbind();
bar.progressProperty().unbind();
StageNode prevStageNode;
if (prevStageNodeRef != null && (prevStageNode = prevStageNodeRef.get()) != null)
prevStageNode.status.removeListener(statusChangeListener);
if (item instanceof ProgressListNode) {
var progressListNode = (ProgressListNode) item;
title.setText(progressListNode.title);
message.textProperty().bind(progressListNode.message);
bar.progressProperty().bind(progressListNode.progress);
pane.paddingProperty().bind(progressNodePadding);
pane.setLeft(null);
pane.setRight(message);
pane.setBottom(bar);
} else if (item instanceof StageNode) {
var stageNode = (StageNode) item;
title.textProperty().bind(stageNode.title);
message.setText("");
bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
pane.setPadding(Insets.EMPTY);
pane.setLeft(left);
pane.setRight(message);
pane.setBottom(null);
updateLeftIcon(stageNode.status.get());
if (statusChangeListener == null)
statusChangeListener = new StatusChangeListener(this);
stageNode.status.addListener(statusChangeListener);
prevStageNodeRef = new WeakReference<>(stageNode);
} else { // item == null
title.setText("");
message.setText("");
bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
pane.setPadding(Insets.EMPTY);
pane.setLeft(null);
pane.setRight(null);
pane.setBottom(null);
}
}
}
private static final class StatusChangeListener implements ChangeListener<StageNode.Status>, WeakListener {
private final WeakReference<Cell> cellRef;
private StatusChangeListener(Cell cell) {
this.cellRef = new WeakReference<>(cell);
}
@Override
public boolean wasGarbageCollected() {
return cellRef.get() == null;
}
@Override
public void changed(ObservableValue<? extends StageNode.Status> observable,
StageNode.Status oldValue,
StageNode.Status newValue) {
Cell cell = cellRef.get();
if (cell == null) {
if (observable != null)
observable.removeListener(this);
return;
}
cell.updateLeftIcon(newValue);
}
}
private static abstract class Node {
}
private static final class StageNode extends Node {
private enum Status {
WAITING(SVG.MORE_HORIZ),
RUNNING(SVG.ARROW_FORWARD),
SUCCESS(SVG.CHECK),
FAILED(SVG.CLOSE);
private final SVG svg;
Status(SVG svg) {
this.svg = svg;
}
}
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.WAITING);
private final StringProperty title = new SimpleStringProperty();
private final String message;
private int count = 0;
private int total = 0;
private boolean started = false;
private StageNode(String stage) {
String stageKey;
String stageValue;
public StageNode(String stage) {
this.stage = stage;
int idx = stage.indexOf(':');
if (idx >= 0) {
stageKey = stage.substring(0, idx);
stageValue = stage.substring(idx + 1);
} else {
stageKey = stage;
stageValue = null;
}
String stageKey = StringUtils.substringBefore(stage, ':');
String stageValue = StringUtils.substringAfter(stage, ':');
// CHECKSTYLE:OFF
// @formatter:off
switch (stageKey) {
case "hmcl.modpack": message = i18n("install.modpack"); break;
case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break;
case "hmcl.install.assets": message = i18n("assets.download"); break;
case "hmcl.install.libraries": message = i18n("libraries.download"); break;
case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break;
case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break;
case "hmcl.install.cleanroom": message = i18n("install.installer.install", i18n("install.installer.cleanroom") + " " + stageValue); break;
case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break;
case "hmcl.modpack": message = i18n("install.modpack"); break;
case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break;
case "hmcl.install.assets": message = i18n("assets.download"); break;
case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break;
case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break;
case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break;
case "hmcl.install.liteloader": message = i18n("install.installer.install", i18n("install.installer.liteloader") + " " + stageValue); break;
case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break;
case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break;
case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break;
case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break;
case "hmcl.install.fabric-api": message = i18n("install.installer.install", i18n("install.installer.fabric-api") + " " + stageValue); break;
case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break;
case "hmcl.install.quilt-api": message = i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue); break;
default: message = i18n(stageKey); break;
case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break;
case "hmcl.install.quilt-api": message = i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue); break;
default: message = i18n(stageKey); break;
}
// @formatter:on
// CHECKSTYLE:ON
title.set(message);
title.setText(message);
BorderPane.setAlignment(title, Pos.CENTER_LEFT);
BorderPane.setMargin(title, new Insets(0, 0, 0, 8));
setPadding(new Insets(0, 0, 8, 4));
setCenter(title);
setLeft(FXUtils.limitingSize(SVG.MORE_HORIZ.createIcon(Theme.blackFill(), 14), 14, 14));
}
private void begin() {
if (status.get() == Status.WAITING) {
status.set(Status.RUNNING);
}
public void begin() {
if (started) return;
started = true;
setLeft(FXUtils.limitingSize(SVG.ARROW_FORWARD.createIcon(Theme.blackFill(), 14), 14, 14));
}
public void fail() {
status.set(Status.FAILED);
setLeft(FXUtils.limitingSize(SVG.CLOSE.createIcon(Theme.blackFill(), 14), 14, 14));
}
public void succeed() {
status.set(Status.SUCCESS);
setLeft(FXUtils.limitingSize(SVG.CHECK.createIcon(Theme.blackFill(), 14), 14, 14));
}
public void count() {
updateCounter(++count, total);
}
public void addTotal(int n) {
this.total += n;
public void setTotal(int total) {
this.total = total;
updateCounter(count, total);
}
public void updateCounter(int count, int total) {
title.setValue(total > 0
? message + " - " + count + "/" + total
: message
);
if (total > 0)
title.setText(String.format("%s - %d/%d", message, count, total));
else
title.setText(message);
}
}
private static final class ProgressListNode extends Node {
private final String title;
private final StringProperty message = new SimpleStringProperty("");
private final DoubleProperty progress = new SimpleDoubleProperty(0.0);
private class ProgressListNode extends BorderPane {
private final JFXProgressBar bar = new JFXProgressBar();
private final Label title = new Label();
private final Label state = new Label();
private final DoubleBinding binding = Bindings.createDoubleBinding(() ->
getWidth() - getPadding().getLeft() - getPadding().getRight() - getInsets().getLeft() - getInsets().getRight(),
paddingProperty(), widthProperty());
private ProgressListNode(Task<?> task) {
this.title = task.getName();
message.bind(task.messageProperty());
progress.bind(task.progressProperty());
public ProgressListNode(Task<?> task) {
bar.progressProperty().bind(task.progressProperty());
title.setText(task.getName());
state.textProperty().bind(task.messageProperty());
setLeft(title);
setRight(state);
setBottom(bar);
bar.minWidthProperty().bind(binding);
bar.prefWidthProperty().bind(binding);
bar.maxWidthProperty().bind(binding);
paddingProperty().bind(progressNodePadding);
}
public void unbind() {
message.unbind();
progress.unbind();
bar.progressProperty().unbind();
state.textProperty().unbind();
}
public void setThrowable(Throwable throwable) {
unbind();
message.set(throwable.getLocalizedMessage());
progress.set(0.);
state.setText(throwable.getLocalizedMessage());
bar.setProgress(0);
}
}
}
@@ -29,22 +29,16 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.AggregatedObservableList;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
public class TwoLineListItem extends VBox {
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
public static Label createTagLabel(String tag) {
Label tagLabel = new Label();
tagLabel.getStyleClass().add("tag");
tagLabel.setText(tag);
HBox.setMargin(tagLabel, new Insets(0, 8, 0, 0));
return tagLabel;
}
private final StringProperty title = new SimpleStringProperty(this, "title");
private final ObservableList<Label> tags = FXCollections.observableArrayList();
private final ObservableList<String> tags = FXCollections.observableArrayList();
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle");
private final ObservableList<Node> tagLabels;
private final AggregatedObservableList<Node> firstLineChildren;
public TwoLineListItem(String titleString, String subtitleString) {
@@ -64,9 +58,16 @@ public class TwoLineListItem extends VBox {
lblTitle.getStyleClass().add("title");
lblTitle.textProperty().bind(title);
tagLabels = MappedObservableList.create(tags, tag -> {
Label tagLabel = new Label();
tagLabel.getStyleClass().add("tag");
tagLabel.setText(tag);
HBox.setMargin(tagLabel, new Insets(0, 8, 0, 0));
return tagLabel;
});
firstLineChildren = new AggregatedObservableList<>();
firstLineChildren.appendList(FXCollections.singletonObservableList(lblTitle));
firstLineChildren.appendList(tags);
firstLineChildren.appendList(tagLabels);
Bindings.bindContent(firstLine.getChildren(), firstLineChildren.getAggregatedList());
Label lblSubtitle = new Label();
@@ -110,11 +111,7 @@ public class TwoLineListItem extends VBox {
this.subtitle.set(subtitle);
}
public void addTag(String tag) {
getTags().add(createTagLabel(tag));
}
public ObservableList<Label> getTags() {
public ObservableList<String> getTags() {
return tags;
}
@@ -21,7 +21,10 @@ import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -55,9 +58,9 @@ public class URLValidator extends ValidatorBase {
hasErrors.set(!nullable);
else {
try {
NetworkUtils.toURI(textField.getText());
new URL(textField.getText()).toURI();
hasErrors.set(false);
} catch (IllegalArgumentException e) {
} catch (IOException | URISyntaxException e) {
hasErrors.set(true);
}
}
@@ -31,13 +31,12 @@ import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.image.Image;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.*;
import javafx.scene.input.DragEvent;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
@@ -45,7 +44,6 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.setting.EnumBackgroundImage;
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.account.AddAuthlibInjectorServerPane;
@@ -57,23 +55,26 @@ import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import static java.util.stream.Collectors.toList;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.newBuiltinImage;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.io.FileUtils.getExtension;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.io.FileUtils.getExtension;
public class DecoratorController {
private static final String PROPERTY_DIALOG_CLOSE_HANDLER = DecoratorController.class.getName() + ".dialog.closeListener";
@@ -123,13 +124,18 @@ public class DecoratorController {
// Setup background
decorator.setContentBackground(getBackground());
changeBackgroundListener = o -> updateBackground();
changeBackgroundListener = o -> {
final int currentCount = ++this.changeBackgroundCount;
CompletableFuture.supplyAsync(this::getBackground, Schedulers.io())
.thenAcceptAsync(background -> {
if (this.changeBackgroundCount == currentCount)
decorator.setContentBackground(background);
}, Schedulers.javafx());
};
WeakInvalidationListener weakListener = new WeakInvalidationListener(changeBackgroundListener);
config().backgroundImageTypeProperty().addListener(weakListener);
config().backgroundImageProperty().addListener(weakListener);
config().backgroundImageUrlProperty().addListener(weakListener);
config().backgroundPaintProperty().addListener(weakListener);
config().backgroundImageOpacityProperty().addListener(weakListener);
// pass key events to current dialog / current page
decorator.addEventFilter(KeyEvent.ANY, e -> {
@@ -165,23 +171,17 @@ public class DecoratorController {
// press ESC to go back
onEscPressed(navigator, this::back);
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
// press F11 to toggle full screen
navigator.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.F11) {
stage.setFullScreen(!stage.isFullScreen());
try {
// For JavaFX 12+
MouseButton button = MouseButton.valueOf("BACK");
navigator.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == button) {
back();
e.consume();
}
});
} catch (IllegalArgumentException ignored) {
}
navigator.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.BACK) {
back();
e.consume();
}
});
}
public Decorator getDecorator() {
@@ -196,20 +196,6 @@ public class DecoratorController {
@SuppressWarnings("FieldCanBeLocal") // Strong reference
private final InvalidationListener changeBackgroundListener;
private void updateBackground() {
final int currentCount = ++this.changeBackgroundCount;
Task.supplyAsync(Schedulers.io(), this::getBackground)
.setName("Update background")
.whenComplete(Schedulers.javafx(), (background, exception) -> {
if (exception == null) {
if (this.changeBackgroundCount == currentCount)
decorator.setContentBackground(background);
} else {
LOG.warning("Failed to update background", exception);
}
}).start();
}
private Background getBackground() {
EnumBackgroundImage imageType = config().getBackgroundImageType();
if (imageType == null)
@@ -230,7 +216,7 @@ public class DecoratorController {
String backgroundImageUrl = config().getBackgroundImageUrl();
if (backgroundImageUrl != null) {
try {
image = FXUtils.loadImage(backgroundImageUrl);
image = FXUtils.loadImage(new URL(backgroundImageUrl));
} catch (Exception e) {
LOG.warning("Couldn't load background image", e);
}
@@ -239,60 +225,13 @@ public class DecoratorController {
case CLASSIC:
image = newBuiltinImage("/assets/img/background-classic.jpg");
break;
case TRANSLUCENT: // Deprecated
case TRANSLUCENT:
return new Background(new BackgroundFill(new Color(1, 1, 1, 0.5), CornerRadii.EMPTY, Insets.EMPTY));
case PAINT:
Paint paint = config().getBackgroundPaint();
double opacity = Lang.clamp(0, config().getBackgroundImageOpacity(), 100) / 100.;
if (paint instanceof Color || paint == null) {
Color color = (Color) paint;
if (color == null)
color = Color.WHITE; // Default to white if no color is set
if (opacity < 1.)
color = new Color(color.getRed(), color.getGreen(), color.getBlue(), opacity);
return new Background(new BackgroundFill(color, CornerRadii.EMPTY, Insets.EMPTY));
} else {
// TODO: Support opacity for non-color paints
return new Background(new BackgroundFill(paint, CornerRadii.EMPTY, Insets.EMPTY));
}
}
if (image == null) {
image = loadDefaultBackgroundImage();
}
return createBackgroundWithOpacity(image, config().getBackgroundImageOpacity());
}
private Background createBackgroundWithOpacity(Image image, int opacity) {
if (opacity <= 0) {
return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY));
} else if (opacity >= 100) {
return new Background(new BackgroundImage(
image,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
} else {
WritableImage tempImage = new WritableImage((int) image.getWidth(), (int) image.getHeight());
PixelReader pixelReader = image.getPixelReader();
PixelWriter pixelWriter = tempImage.getPixelWriter();
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
Color color = pixelReader.getColor(x, y);
Color newColor = new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * opacity / 100);
pixelWriter.setColor(x, y, newColor);
}
}
return new Background(new BackgroundImage(
tempImage,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
}
return new Background(new BackgroundImage(image, BackgroundRepeat.NO_REPEAT, BackgroundRepeat.NO_REPEAT, BackgroundPosition.DEFAULT, new BackgroundSize(800, 480, false, false, true, true)));
}
/**
@@ -18,11 +18,8 @@
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -32,7 +29,6 @@ import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
@@ -47,7 +43,6 @@ import org.jackhuang.hmcl.ui.animation.AnimationProducer;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
public class DecoratorSkin extends SkinBase<Decorator> {
private final StackPane root, parent;
@@ -55,10 +50,6 @@ public class DecoratorSkin extends SkinBase<Decorator> {
private final Stage primaryStage;
private final TransitionPane navBarPane;
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener onWindowsStatusChange;
private final EventHandler<MouseEvent> onTitleBarDoubleClick;
private double mouseInitX, mouseInitY, stageInitX, stageInitY, stageInitWidth, stageInitHeight;
/**
@@ -89,41 +80,9 @@ public class DecoratorSkin extends SkinBase<Decorator> {
skinnable.getSnackbar().registerSnackbarContainer(parent);
EventHandler<MouseEvent> onMouseReleased = this::onMouseReleased;
EventHandler<MouseEvent> onMouseDragged = this::onMouseDragged;
EventHandler<MouseEvent> onMouseMoved = this::onMouseMoved;
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
onWindowsStatusChange = observable -> {
if (primaryStage.isIconified() || primaryStage.isFullScreen() || primaryStage.isMaximized()) {
root.removeEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.removeEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.removeEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
} else {
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
};
onTitleBarDoubleClick = event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
primaryStage.setMaximized(!primaryStage.isMaximized());
event.consume();
}
};
WeakInvalidationListener weakOnWindowsStatusChange = new WeakInvalidationListener(onWindowsStatusChange);
primaryStage.iconifiedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.maximizedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.fullScreenProperty().addListener(weakOnWindowsStatusChange);
onWindowsStatusChange.invalidated(null);
} else {
onWindowsStatusChange = null;
onTitleBarDoubleClick = null;
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
root.addEventFilter(MouseEvent.MOUSE_RELEASED, this::onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, this::onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
shadowContainer.getChildren().setAll(parent);
root.getChildren().setAll(shadowContainer);
@@ -323,8 +282,6 @@ public class DecoratorSkin extends SkinBase<Decorator> {
BorderPane.setAlignment(titleNode, Pos.CENTER_LEFT);
BorderPane.setMargin(titleNode, new Insets(0, 0, 0, 8));
}
if (onTitleBarDoubleClick != null)
center.setOnMouseClicked(onTitleBarDoubleClick);
navBar.setCenter(center);
if (canRefresh) {
@@ -23,8 +23,8 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.wizard.*;
import org.jackhuang.hmcl.util.SettingsMap;
import java.util.Map;
import java.util.concurrent.ConcurrentLinkedQueue;
public class DecoratorWizardDisplayer extends DecoratorTransitionPage implements WizardDisplayer {
@@ -94,7 +94,7 @@ public class DecoratorWizardDisplayer extends DecoratorTransitionPage implements
}
@Override
public void handleTask(SettingsMap settings, Task<?> task) {
public void handleTask(Map<String, Object> settings, Task<?> task) {
displayer.handleTask(settings, task);
}
@@ -35,7 +35,8 @@ import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import java.util.Map;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -79,12 +80,12 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
protected abstract void reload();
@Override
public void onNavigate(SettingsMap settings) {
public void onNavigate(Map<String, Object> settings) {
reload();
}
@Override
public abstract void cleanup(SettingsMap settings);
public abstract void cleanup(Map<String, Object> settings);
protected abstract void onInstall();
@@ -119,15 +120,23 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
{
InstallerItem[] libraries = control.group.getLibraries();
FlowPane libraryPane = new FlowPane(16, 16, libraries);
ScrollPane scrollPane = new ScrollPane(libraryPane);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0));
root.setCenter(scrollPane);
FlowPane libraryPane = new FlowPane(libraries);
libraryPane.setVgap(16);
libraryPane.setHgap(16);
if (libraries.length <= 8)
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
if (FXUtils.JAVAFX_MAJOR_VERSION < 25 && libraries.length <= 8) {
BorderPane.setMargin(libraryPane, new Insets(16, 0, 16, 0));
root.setCenter(libraryPane);
} else {
ScrollPane scrollPane = new ScrollPane(libraryPane);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0));
root.setCenter(scrollPane);
if (libraries.length <= 8)
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
}
}
{
@@ -29,8 +29,8 @@ import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.ui.InstallerItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.SettingsMap;
import java.util.Map;
import java.util.Optional;
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
@@ -109,6 +109,6 @@ class AdditionalInstallersPage extends AbstractInstallersPage {
}
@Override
public void cleanup(SettingsMap settings) {
public void cleanup(Map<String, Object> settings) {
}
}
@@ -51,13 +51,15 @@ import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.function.Supplier;
@@ -144,17 +146,17 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
public static void download(Profile profile, @Nullable String version, RemoteMod.Version file, String subdirectoryName) {
if (version == null) version = profile.getSelectedVersion();
Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version) : profile.getRepository().getBaseDirectory();
Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version).toPath() : profile.getRepository().getBaseDirectory().toPath();
Controllers.prompt(i18n("archive.file.name"), (result, resolve, reject) -> {
if (!FileUtils.isNameValid(result)) {
if (!OperatingSystem.isNameValid(result)) {
reject.accept(i18n("install.new_game.malformed"));
return;
}
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
var task = new FileDownloadTask(file.getFile().getUrl(), dest);
FileDownloadTask task = new FileDownloadTask(NetworkUtils.toURL(file.getFile().getUrl()), dest.toFile());
task.setName(file.getName());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
@@ -222,7 +224,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
private static final class DownloadNavigator implements Navigation {
private final SettingsMap settings = new SettingsMap();
private final Map<String, Object> settings = new HashMap<>();
@Override
public void onStart() {
@@ -259,7 +261,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
@Override
public SettingsMap getSettings() {
public Map<String, Object> getSettings() {
return settings;
}
@@ -286,39 +288,37 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
}
@Override
public void start(SettingsMap settings) {
settings.put(ModpackPage.PROFILE, profile);
public void start(Map<String, Object> settings) {
settings.put(PROFILE, profile);
settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), gameVersion);
}
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
private Task<Void> finishVersionDownloadingAsync(Map<String, Object> settings) {
GameBuilder builder = dependencyManager.gameBuilder();
String name = (String) settings.get("name");
builder.name(name);
builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion());
settings.asStringMap().forEach((key, value) -> {
if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(key)
&& value instanceof RemoteVersion remoteVersion)
builder.version(remoteVersion);
});
for (Map.Entry<String, Object> entry : settings.entrySet())
if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(entry.getKey()) && entry.getValue() instanceof RemoteVersion)
builder.version((RemoteVersion) entry.getValue());
return builder.buildAsync().whenComplete(any -> profile.getRepository().refreshVersions())
.thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name));
}
@Override
public Object finish(SettingsMap settings) {
public Object finish(Map<String, Object> settings) {
settings.put("title", i18n("install.new_game.installation"));
settings.put("success_message", i18n("install.success"));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
settings.put("failure_callback", (FailureCallback) (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
return finishVersionDownloadingAsync(settings);
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
switch (step) {
case 0:
return new InstallersPage(controller, profile.getRepository(), ((RemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider);
@@ -331,5 +331,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
public boolean cancel() {
return true;
}
public static final String PROFILE = "PROFILE";
}
}
@@ -27,7 +27,9 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.Map;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -72,27 +74,14 @@ public class InstallersPage extends AbstractInstallersPage {
}
@Override
public void cleanup(SettingsMap settings) {
}
private static boolean checkName(String name) {
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (!(c >= '0' && c <= '9')
&& !(c >= 'a' && c <= 'z')
&& !(c >= 'A' && c <= 'Z')
&& c != '-' && c != '_' && c != '.'
)
return false;
}
return true;
public void cleanup(Map<String, Object> settings) {
}
protected void onInstall() {
String name = txtName.getText();
if (!checkName(name)) {
// Check for non-ASCII characters.
if (!StringUtils.isASCII(name)) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.name.invalid"),
i18n("message.warning"),
@@ -129,9 +118,6 @@ public class InstallersPage extends AbstractInstallersPage {
case NEO_FORGE:
loaderName = i18n("install.installer.neoforge");
break;
case CLEANROOM:
loaderName = i18n("install.installer.cleanroom");
break;
case FABRIC:
loaderName = i18n("install.installer.fabric");
break;
@@ -36,14 +36,16 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -56,11 +58,11 @@ public final class LocalModpackPage extends ModpackPage {
public LocalModpackPage(WizardController controller) {
super(controller);
Profile profile = controller.getSettings().get(ModpackPage.PROFILE);
Profile profile = (Profile) controller.getSettings().get("PROFILE");
String name = controller.getSettings().get(MODPACK_NAME);
if (name != null) {
txtModpackName.setText(name);
Optional<String> name = tryCast(controller.getSettings().get(MODPACK_NAME), String.class);
if (name.isPresent()) {
txtModpackName.setText(name.get());
txtModpackName.setDisable(true);
} else {
FXUtils.onChangeAndOperate(installAsVersion, installAsVersion -> {
@@ -80,15 +82,15 @@ public final class LocalModpackPage extends ModpackPage {
btnDescription.setVisible(false);
Path selectedFile;
Path filePath = controller.getSettings().get(MODPACK_FILE);
if (filePath != null) {
selectedFile = filePath;
File selectedFile;
Optional<File> filePath = tryCast(controller.getSettings().get(MODPACK_FILE), File.class);
if (filePath.isPresent()) {
selectedFile = filePath.get();
} else {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("modpack.choose"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
selectedFile = chooser.showOpenDialog(Controllers.getStage());
if (selectedFile == null) {
controller.onEnd();
return;
@@ -98,19 +100,19 @@ public final class LocalModpackPage extends ModpackPage {
}
showSpinner();
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile))
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile.toPath()))
.thenApplyAsync(encoding -> {
charset = encoding;
manifest = ModpackHelper.readModpackManifest(selectedFile, encoding);
manifest = ModpackHelper.readModpackManifest(selectedFile.toPath(), encoding);
return manifest;
})
.whenComplete(Schedulers.javafx(), (manifest, exception) -> {
if (exception instanceof ManuallyCreatedModpackException) {
hideSpinner();
lblName.setText(FileUtils.getName(selectedFile));
lblName.setText(selectedFile.getName());
installAsVersion.set(false);
if (name == null) {
if (!name.isPresent()) {
// trim: https://github.com/HMCL-dev/HMCL/issues/962
txtModpackName.setText(FileUtils.getNameWithoutExtension(selectedFile));
}
@@ -131,7 +133,7 @@ public final class LocalModpackPage extends ModpackPage {
lblVersion.setText(manifest.getVersion());
lblAuthor.setText(manifest.getAuthor());
if (name == null) {
if (!name.isPresent()) {
// trim: https://github.com/HMCL-dev/HMCL/issues/962
txtModpackName.setText(manifest.getName().trim());
}
@@ -142,7 +144,7 @@ public final class LocalModpackPage extends ModpackPage {
}
@Override
public void cleanup(SettingsMap settings) {
public void cleanup(Map<String, Object> settings) {
settings.remove(MODPACK_FILE);
}
@@ -175,9 +177,9 @@ public final class LocalModpackPage extends ModpackPage {
Controllers.navigate(new WebPage(i18n("modpack.description"), manifest.getDescription()));
}
public static final SettingsMap.Key<Path> MODPACK_FILE = new SettingsMap.Key<>("MODPACK_FILE");
public static final SettingsMap.Key<String> MODPACK_NAME = new SettingsMap.Key<>("MODPACK_NAME");
public static final SettingsMap.Key<Modpack> MODPACK_MANIFEST = new SettingsMap.Key<>("MODPACK_MANIFEST");
public static final SettingsMap.Key<Charset> MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET");
public static final SettingsMap.Key<Boolean> MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED");
public static final String MODPACK_FILE = "MODPACK_FILE";
public static final String MODPACK_NAME = "MODPACK_NAME";
public static final String MODPACK_MANIFEST = "MODPACK_MANIFEST";
public static final String MODPACK_CHARSET = "MODPACK_CHARSET";
public static final String MODPACK_MANUALLY_CREATED = "MODPACK_MANUALLY_CREATED";
}
@@ -32,25 +32,26 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.Map;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackInstallWizardProvider implements WizardProvider {
public class ModpackInstallWizardProvider implements WizardProvider {
private final Profile profile;
private final Path file;
private final File file;
private final String updateVersion;
public ModpackInstallWizardProvider(Profile profile) {
this(profile, null, null);
}
public ModpackInstallWizardProvider(Profile profile, Path modpackFile) {
public ModpackInstallWizardProvider(Profile profile, File modpackFile) {
this(profile, modpackFile, null);
}
@@ -58,28 +59,28 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
this(profile, null, updateVersion);
}
public ModpackInstallWizardProvider(Profile profile, Path modpackFile, String updateVersion) {
public ModpackInstallWizardProvider(Profile profile, File modpackFile, String updateVersion) {
this.profile = profile;
this.file = modpackFile;
this.updateVersion = updateVersion;
}
@Override
public void start(SettingsMap settings) {
public void start(Map<String, Object> settings) {
if (file != null)
settings.put(LocalModpackPage.MODPACK_FILE, file);
if (updateVersion != null)
settings.put(LocalModpackPage.MODPACK_NAME, updateVersion);
settings.put(ModpackPage.PROFILE, profile);
settings.put(PROFILE, profile);
}
private Task<?> finishModpackInstallingAsync(SettingsMap settings) {
Path selected = settings.get(LocalModpackPage.MODPACK_FILE);
ServerModpackManifest serverModpackManifest = settings.get(RemoteModpackPage.MODPACK_SERVER_MANIFEST);
Modpack modpack = settings.get(LocalModpackPage.MODPACK_MANIFEST);
String name = settings.get(LocalModpackPage.MODPACK_NAME);
Charset charset = settings.get(LocalModpackPage.MODPACK_CHARSET);
boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false);
private Task<?> finishModpackInstallingAsync(Map<String, Object> settings) {
File selected = tryCast(settings.get(LocalModpackPage.MODPACK_FILE), File.class).orElse(null);
ServerModpackManifest serverModpackManifest = tryCast(settings.get(RemoteModpackPage.MODPACK_SERVER_MANIFEST), ServerModpackManifest.class).orElse(null);
Modpack modpack = tryCast(settings.get(LocalModpackPage.MODPACK_MANIFEST), Modpack.class).orElse(null);
String name = tryCast(settings.get(LocalModpackPage.MODPACK_NAME), String.class).orElse(null);
Charset charset = tryCast(settings.get(LocalModpackPage.MODPACK_CHARSET), Charset.class).orElse(null);
boolean isManuallyCreated = tryCast(settings.get(LocalModpackPage.MODPACK_MANUALLY_CREATED), Boolean.class).orElse(false);
if (isManuallyCreated) {
return ModpackHelper.getInstallManuallyCreatedModpackTask(profile, selected, name, charset);
@@ -118,18 +119,21 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
}
@Override
public Object finish(SettingsMap settings) {
public Object finish(Map<String, Object> settings) {
settings.put("title", i18n("install.modpack.installation"));
settings.put("success_message", i18n("install.success"));
settings.put(FailureCallback.KEY, (ignored, exception, next) -> {
if (exception instanceof ModpackCompletionException) {
if (exception.getCause() instanceof FileNotFoundException) {
Controllers.dialog(i18n("modpack.type.curse.not_found"), i18n("install.failed"), MessageType.ERROR, next);
settings.put("failure_callback", new FailureCallback() {
@Override
public void onFail(Map<String, Object> settings, Exception exception, Runnable next) {
if (exception instanceof ModpackCompletionException) {
if (exception.getCause() instanceof FileNotFoundException) {
Controllers.dialog(i18n("modpack.type.curse.not_found"), i18n("install.failed"), MessageType.ERROR, next);
} else {
Controllers.dialog(i18n("install.success"), i18n("install.success"), MessageType.SUCCESS, next);
}
} else {
Controllers.dialog(i18n("install.success"), i18n("install.success"), MessageType.SUCCESS, next);
UpdateInstallerWizardProvider.alertFailureMessage(exception, next);
}
} else {
UpdateInstallerWizardProvider.alertFailureMessage(exception, next);
}
});
@@ -137,7 +141,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
switch (step) {
case 0:
return new ModpackSelectionPage(controller);
@@ -157,4 +161,6 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
public boolean cancel() {
return true;
}
public static final String PROFILE = "PROFILE";
}
@@ -7,20 +7,16 @@ import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import static javafx.beans.binding.Bindings.createBooleanBinding;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public abstract class ModpackPage extends SpinnerPane implements WizardPage {
public static final SettingsMap.Key<Profile> PROFILE = new SettingsMap.Key<>("PROFILE");
protected final WizardController controller;
protected final Label lblName;
@@ -82,7 +78,8 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
BorderPane descriptionPane = new BorderPane();
{
btnDescription = FXUtils.newBorderButton(i18n("modpack.description"));
btnDescription = new JFXButton(i18n("modpack.description"));
btnDescription.getStyleClass().add("jfx-button-border");
btnDescription.setOnAction(e -> onDescribe());
descriptionPane.setLeft(btnDescription);
@@ -29,25 +29,30 @@ import javafx.scene.shape.SVGPath;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import static org.jackhuang.hmcl.ui.download.LocalModpackPage.MODPACK_FILE;
import static org.jackhuang.hmcl.ui.download.LocalModpackPage.MODPACK_NAME;
import static org.jackhuang.hmcl.ui.download.RemoteModpackPage.MODPACK_SERVER_MANIFEST;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackSelectionPage extends VBox implements WizardPage {
@@ -69,14 +74,14 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
createButton("repository", this::onChooseRepository)
);
Path filePath = controller.getSettings().get(MODPACK_FILE);
if (filePath != null) {
controller.getSettings().put(MODPACK_FILE, filePath);
Optional<File> filePath = tryCast(controller.getSettings().get(MODPACK_FILE), File.class);
if (filePath.isPresent()) {
controller.getSettings().put(MODPACK_FILE, filePath.get());
Platform.runLater(controller::onNext);
}
FXUtils.applyDragListener(this, ModpackHelper::isFileModpackByExtension, modpacks -> {
Path modpack = modpacks.get(0);
File modpack = modpacks.get(0);
controller.getSettings().put(MODPACK_FILE, modpack);
controller.onNext();
});
@@ -110,7 +115,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("modpack.choose"));
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip", "*.mrpack"));
Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
File selectedFile = chooser.showOpenDialog(Controllers.getStage());
if (selectedFile == null) {
Platform.runLater(controller::onEnd);
return;
@@ -121,9 +126,10 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
}
private void onChooseRemoteFile() {
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (url, resolve, reject) -> {
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (urlString, resolve, reject) -> {
try {
if (url.endsWith("server-manifest.json")) {
URL url = new URL(urlString);
if (urlString.endsWith("server-manifest.json")) {
// if urlString ends with .json, we assume that the url is server-manifest.json
Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
@@ -144,11 +150,11 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
resolve.run();
Controllers.taskDialog(
new FileDownloadTask(url, modpack)
new FileDownloadTask(url, modpack.toFile(), null)
.whenComplete(Schedulers.javafx(), e -> {
if (e == null) {
resolve.run();
controller.getSettings().put(MODPACK_FILE, modpack);
controller.getSettings().put(MODPACK_FILE, modpack.toFile());
controller.onNext();
} else {
reject.accept(e.getMessage());
@@ -165,14 +171,14 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
}
public void onChooseRepository() {
String modPackName = controller.getSettings().get(MODPACK_NAME);
String modPackName = (String) controller.getSettings().get(MODPACK_NAME);
DownloadPage downloadPage = new DownloadPage(modPackName);
downloadPage.showModpackDownloads();
Controllers.navigate(downloadPage);
}
@Override
public void cleanup(SettingsMap settings) {
public void cleanup(Map<String, Object> settings) {
}
@Override
@@ -19,7 +19,6 @@ package org.jackhuang.hmcl.ui.download;
import javafx.application.Platform;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.ui.Controllers;
@@ -28,11 +27,13 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class RemoteModpackPage extends ModpackPage {
@@ -41,9 +42,8 @@ public final class RemoteModpackPage extends ModpackPage {
public RemoteModpackPage(WizardController controller) {
super(controller);
manifest = controller.getSettings().get(MODPACK_SERVER_MANIFEST);
if (manifest == null)
throw new IllegalStateException("MODPACK_SERVER_MANIFEST should exist");
manifest = tryCast(controller.getSettings().get(MODPACK_SERVER_MANIFEST), ServerModpackManifest.class)
.orElseThrow(() -> new IllegalStateException("MODPACK_SERVER_MANIFEST should exist"));
try {
controller.getSettings().put(MODPACK_MANIFEST, manifest.toModpack(null));
@@ -57,10 +57,10 @@ public final class RemoteModpackPage extends ModpackPage {
lblVersion.setText(manifest.getVersion());
lblAuthor.setText(manifest.getAuthor());
Profile profile = controller.getSettings().get(ModpackPage.PROFILE);
String name = controller.getSettings().get(MODPACK_NAME);
if (name != null) {
txtModpackName.setText(name);
Profile profile = (Profile) controller.getSettings().get("PROFILE");
Optional<String> name = tryCast(controller.getSettings().get(MODPACK_NAME), String.class);
if (name.isPresent()) {
txtModpackName.setText(name.get());
txtModpackName.setDisable(true);
} else {
// trim: https://github.com/HMCL-dev/HMCL/issues/962
@@ -75,7 +75,7 @@ public final class RemoteModpackPage extends ModpackPage {
}
@Override
public void cleanup(SettingsMap settings) {
public void cleanup(Map<String, Object> settings) {
settings.remove(MODPACK_SERVER_MANIFEST);
}
@@ -89,7 +89,7 @@ public final class RemoteModpackPage extends ModpackPage {
Controllers.navigate(new WebPage(i18n("modpack.description"), manifest.getDescription()));
}
public static final SettingsMap.Key<ServerModpackManifest> MODPACK_SERVER_MANIFEST = new SettingsMap.Key<>("MODPACK_SERVER_MANIFEST");
public static final SettingsMap.Key<String> MODPACK_NAME = new SettingsMap.Key<>("MODPACK_NAME");
public static final SettingsMap.Key<Modpack> MODPACK_MANIFEST = new SettingsMap.Key<>("MODPACK_MANIFEST");
public static final String MODPACK_SERVER_MANIFEST = "MODPACK_SERVER_MANIFEST";
public static final String MODPACK_NAME = "MODPACK_NAME";
public static final String MODPACK_MANIFEST = "MODPACK_MANIFEST";
}
@@ -30,7 +30,6 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
@@ -38,9 +37,10 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.zip.ZipException;
@@ -66,29 +66,29 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
}
@Override
public void start(SettingsMap settings) {
public void start(Map<String, Object> settings) {
}
@Override
public Object finish(SettingsMap settings) {
public Object finish(Map<String, Object> settings) {
settings.put("title", i18n("install.change_version.process"));
settings.put("success_message", i18n("install.success"));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> alertFailureMessage(exception, next));
settings.put("failure_callback", (FailureCallback) (settings1, exception, next) -> alertFailureMessage(exception, next));
// We remove library but not save it,
// so if installation failed will not break down current version.
Task<Version> ret = Task.supplyAsync(() -> version);
List<String> stages = new ArrayList<>();
for (Object value : settings.asStringMap().values()) {
if (value instanceof RemoteVersion remoteVersion) {
for (Object value : settings.values()) {
if (value instanceof RemoteVersion) {
RemoteVersion remoteVersion = (RemoteVersion) value;
ret = ret.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion));
stages.add(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()));
if ("game".equals(remoteVersion.getLibraryId())) {
stages.add("hmcl.install.libraries");
stages.add("hmcl.install.assets");
}
} else if (value instanceof RemoveVersionAction removeVersionAction) {
ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.libraryId));
} else if (value instanceof RemoveVersionAction) {
ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, ((RemoveVersionAction) value).libraryId));
}
}
@@ -96,7 +96,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
switch (step) {
case 0:
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + libraryId)), gameVersion, downloadProvider, libraryId, () -> {
@@ -133,28 +133,28 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) exception.getCause();
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
URL url = rce.getUrl();
if (responseCode == 404)
message += i18n("download.code.404", uri);
message += i18n("download.code.404", url);
else
message += i18n("download.failed", uri, responseCode);
message += i18n("download.failed", url, responseCode);
} else {
message += StringUtils.getStackTrace(exception.getCause());
}
Controllers.dialog(message, i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception instanceof DownloadException) {
URI uri = ((DownloadException) exception).getUri();
URL url = ((DownloadException) exception).getUrl();
if (exception.getCause() instanceof SocketTimeoutException) {
Controllers.dialog(i18n("install.failed.downloading.timeout", uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.timeout", url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else {
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
}
} else {
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
}
} else if (exception instanceof UnsupportedInstallationException) {
switch (((UnsupportedInstallationException) exception).getReason()) {
@@ -28,7 +28,8 @@ import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.SettingsMap;
import java.util.Map;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -44,37 +45,36 @@ public final class VanillaInstallWizardProvider implements WizardProvider {
}
@Override
public void start(SettingsMap settings) {
settings.put(ModpackPage.PROFILE, profile);
public void start(Map<String, Object> settings) {
settings.put(PROFILE, profile);
}
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
private Task<Void> finishVersionDownloadingAsync(Map<String, Object> settings) {
GameBuilder builder = dependencyManager.gameBuilder();
String name = (String) settings.get("name");
builder.name(name);
builder.gameVersion(((RemoteVersion) settings.get("game")).getGameVersion());
settings.asStringMap().forEach((key, value) -> {
if (!"game".equals(key) && value instanceof RemoteVersion remoteVersion)
builder.version(remoteVersion);
});
for (Map.Entry<String, Object> entry : settings.entrySet())
if (!"game".equals(entry.getKey()) && entry.getValue() instanceof RemoteVersion)
builder.version((RemoteVersion) entry.getValue());
return builder.buildAsync().whenComplete(any -> profile.getRepository().refreshVersions())
.thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name));
}
@Override
public Object finish(SettingsMap settings) {
public Object finish(Map<String, Object> settings) {
settings.put("title", i18n("install.new_game.installation"));
settings.put("success_message", i18n("install.success"));
settings.put(FailureCallback.KEY, (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
settings.put("failure_callback", (FailureCallback) (settings1, exception, next) -> UpdateInstallerWizardProvider.alertFailureMessage(exception, next));
return finishVersionDownloadingAsync(settings);
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
switch (step) {
case 0:
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer.game")), "", downloadProvider, "game",
@@ -88,4 +88,6 @@ public final class VanillaInstallWizardProvider implements WizardProvider {
public boolean cancel() {
return true;
}
public static final String PROFILE = "PROFILE";
}
@@ -17,22 +17,27 @@
*/
package org.jackhuang.hmcl.ui.download;
import com.jfoenix.controls.*;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXSpinner;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.VersionList;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
@@ -42,80 +47,297 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
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.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.*;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.HintPane;
import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.wrap;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class VersionsPage extends Control implements WizardPage, Refreshable {
public final class VersionsPage extends BorderPane implements WizardPage, Refreshable {
private final String gameVersion;
private final String libraryId;
private final String title;
private final Navigation navigation;
private final DownloadProvider downloadProvider;
private final JFXListView<RemoteVersion> list;
private final JFXSpinner spinner;
private final StackPane failedPane;
private final StackPane emptyPane;
private final TransitionPane root;
private final JFXCheckBox chkRelease;
private final JFXCheckBox chkSnapshot;
private final JFXCheckBox chkOld;
private final ComponentList centrePane;
private final StackPane center;
private final VersionList<?> versionList;
private final Runnable callback;
private CompletableFuture<?> executor;
private final ObservableList<RemoteVersion> versions = FXCollections.observableArrayList();
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
private final HBox searchBar;
private final StringProperty queryString = new SimpleStringProperty();
public VersionsPage(Navigation navigation,
String title, String gameVersion,
DownloadProvider downloadProvider,
String libraryId,
Runnable callback) {
public VersionsPage(Navigation navigation, String title, String gameVersion, DownloadProvider downloadProvider, String libraryId, Runnable callback) {
this.title = title;
this.gameVersion = gameVersion;
this.libraryId = libraryId;
this.navigation = navigation;
this.downloadProvider = downloadProvider;
this.versionList = downloadProvider.getVersionListById(libraryId);
this.callback = callback;
HintPane hintPane = new HintPane();
hintPane.setText(i18n("sponsor.bmclapi"));
hintPane.getStyleClass().add("sponsor-pane");
FXUtils.onClicked(hintPane, this::onSponsor);
BorderPane.setMargin(hintPane, new Insets(10, 10, 0, 10));
this.setTop(hintPane);
root = new TransitionPane();
BorderPane toolbarPane = new BorderPane();
JFXButton btnRefresh;
{
spinner = new JFXSpinner();
center = new StackPane();
center.setStyle("-fx-padding: 10;");
{
centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
{
HBox checkPane = new HBox();
checkPane.setSpacing(10);
{
chkRelease = new JFXCheckBox(i18n("version.game.releases"));
chkRelease.setSelected(true);
HBox.setMargin(chkRelease, new Insets(10, 0, 10, 0));
chkSnapshot = new JFXCheckBox(i18n("version.game.snapshots"));
HBox.setMargin(chkSnapshot, new Insets(10, 0, 10, 0));
chkOld = new JFXCheckBox(i18n("version.game.old"));
HBox.setMargin(chkOld, new Insets(10, 0, 10, 0));
checkPane.getChildren().setAll(chkRelease, chkSnapshot, chkOld);
}
list = new JFXListView<>();
list.getStyleClass().add("jfx-list-view-float");
VBox.setVgrow(list, Priority.ALWAYS);
TransitionPane rightToolbarPane = new TransitionPane();
{
HBox refreshPane = new HBox();
refreshPane.setAlignment(Pos.CENTER_RIGHT);
btnRefresh = new JFXButton(i18n("button.refresh"));
btnRefresh.getStyleClass().add("jfx-tool-bar-button");
btnRefresh.setOnAction(e -> onRefresh());
JFXButton btnSearch = new JFXButton(i18n("search"));
btnSearch.getStyleClass().add("jfx-tool-bar-button");
btnSearch.setGraphic(wrap(SVG.SEARCH.createIcon(Theme.blackFill(), -1)));
searchBar = new HBox();
{
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 0));
JFXTextField searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
JFXButton closeSearchBar = new JFXButton();
closeSearchBar.getStyleClass().add("jfx-tool-bar-button");
closeSearchBar.setGraphic(wrap(SVG.CLOSE.createIcon(Theme.blackFill(), -1)));
closeSearchBar.setOnAction(e -> {
searchField.clear();
rightToolbarPane.setContent(refreshPane, ContainerAnimations.FADE);
});
onEscPressed(searchField, closeSearchBar::fire);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> queryString.set(searchField.getText()));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
pause.setRate(1);
pause.playFromStart();
});
searchBar.getChildren().setAll(searchField, closeSearchBar);
btnSearch.setOnAction(e -> {
rightToolbarPane.setContent(searchBar, ContainerAnimations.FADE);
searchField.requestFocus();
});
}
refreshPane.getChildren().setAll(new HBox(btnSearch, btnRefresh));
rightToolbarPane.setContent(refreshPane, ContainerAnimations.NONE);
}
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
toolbarPane.setLeft(checkPane);
toolbarPane.setRight(rightToolbarPane);
centrePane.getContent().setAll(toolbarPane, list);
}
center.getChildren().setAll(centrePane);
}
failedPane = new StackPane();
failedPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.refresh"));
FXUtils.onClicked(label, this::onRefresh);
failedPane.getChildren().setAll(label);
}
emptyPane = new StackPane();
emptyPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.empty"));
FXUtils.onClicked(label, this::onBack);
emptyPane.getChildren().setAll(label);
}
}
this.setCenter(root);
versionList = downloadProvider.getVersionListById(libraryId);
boolean hasType = versionList.hasType();
chkRelease.setManaged(hasType);
chkRelease.setVisible(hasType);
chkSnapshot.setManaged(hasType);
chkSnapshot.setVisible(hasType);
chkOld.setManaged(hasType);
chkOld.setVisible(hasType);
if (hasType) {
centrePane.getContent().setAll(toolbarPane, list);
} else {
centrePane.getContent().setAll(list);
}
ComponentList.setVgrow(list, Priority.ALWAYS);
InvalidationListener listener = o -> {
List<RemoteVersion> versions = loadVersions();
String query = queryString.get();
if (!StringUtils.isBlank(query)) {
Predicate<RemoteVersion> predicate;
if (query.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(query.substring("regex:".length()));
predicate = it -> pattern.matcher(it.getSelfVersion()).find();
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
} else {
String lowerQueryString = query.toLowerCase(Locale.ROOT);
predicate = it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString);
}
versions = versions.stream().filter(predicate).collect(Collectors.toList());
}
list.getItems().setAll(versions);
};
chkRelease.selectedProperty().addListener(listener);
chkSnapshot.selectedProperty().addListener(listener);
chkOld.selectedProperty().addListener(listener);
queryString.addListener(listener);
btnRefresh.setGraphic(wrap(SVG.REFRESH.createIcon(Theme.blackFill(), -1)));
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, libraryId));
FXUtils.onClicked(list, () -> {
if (list.getSelectionModel().getSelectedIndex() < 0)
return;
navigation.getSettings().put(libraryId, list.getSelectionModel().getSelectedItem());
callback.run();
});
refresh();
}
@Override
protected Skin<?> createDefaultSkin() {
return new VersionsPageSkin(this);
private List<RemoteVersion> loadVersions() {
return versionList.getVersions(gameVersion).stream()
.filter(it -> {
switch (it.getVersionType()) {
case RELEASE:
return chkRelease.isSelected();
case PENDING:
case SNAPSHOT:
return chkSnapshot.isSelected();
case OLD:
return chkOld.isSelected();
default:
return true;
}
})
.sorted().collect(Collectors.toList());
}
@Override
public void refresh() {
status.set(Status.LOADING);
Task<?> task = versionList.refreshAsync(gameVersion)
.thenSupplyAsync(() -> versionList.getVersions(gameVersion).stream().sorted().collect(Collectors.toList()))
.whenComplete(Schedulers.javafx(), (items, exception) -> {
if (exception == null) {
versions.setAll(items);
status.set(Status.SUCCESS);
VersionList<?> currentVersionList = versionList;
root.setContent(spinner, ContainerAnimations.FADE);
executor = currentVersionList.refreshAsync(gameVersion).whenComplete((result, exception) -> {
if (exception == null) {
List<RemoteVersion> items = loadVersions();
Platform.runLater(() -> {
if (versionList != currentVersionList) return;
if (currentVersionList.getVersions(gameVersion).isEmpty()) {
root.setContent(emptyPane, ContainerAnimations.FADE);
} else {
LOG.warning("Failed to fetch versions list", exception);
status.set(Status.FAILED);
if (items.isEmpty()) {
chkRelease.setSelected(true);
chkSnapshot.setSelected(true);
chkOld.setSelected(true);
} else {
list.getItems().setAll(items);
}
root.setContent(center, ContainerAnimations.FADE);
}
});
task.start();
} else {
LOG.warning("Failed to fetch versions list", exception);
Platform.runLater(() -> {
if (versionList != currentVersionList) return;
root.setContent(failedPane, ContainerAnimations.FADE);
});
}
// https://github.com/HMCL-dev/HMCL/issues/938
System.gc();
});
}
@Override
@@ -124,8 +346,10 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
@Override
public void cleanup(SettingsMap settings) {
public void cleanup(Map<String, Object> settings) {
settings.remove(libraryId);
if (executor != null)
executor.cancel(true);
}
private void onRefresh() {
@@ -136,76 +360,27 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
navigation.onPrev(true);
}
private enum Status {
LOADING,
FAILED,
SUCCESS,
}
private enum VersionTypeFilter {
ALL,
RELEASE,
SNAPSHOTS,
APRIL_FOOLS,
OLD
private void onSponsor() {
FXUtils.openLink("https://bmclapidoc.bangbang93.com");
}
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
private final VersionsPage control;
private final TwoLineListItem twoLineListItem = new TwoLineListItem();
private final ImageView imageView = new ImageView();
private final StackPane pane = new StackPane();
final IconedTwoLineListItem content = new IconedTwoLineListItem();
final RipplerContainer ripplerContainer = new RipplerContainer(content);
final StackPane pane = new StackPane();
private final Holder<RemoteVersionListCell> lastCell;
RemoteVersionListCell(Holder<RemoteVersionListCell> lastCell, VersionsPage control) {
RemoteVersionListCell(Holder<RemoteVersionListCell> lastCell, String libraryId) {
this.lastCell = lastCell;
this.control = control;
HBox hbox = new HBox(16);
HBox.setHgrow(twoLineListItem, Priority.ALWAYS);
hbox.setAlignment(Pos.CENTER);
HBox actions = new HBox(8);
actions.setAlignment(Pos.CENTER);
{
if ("game".equals(control.libraryId)) {
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
wikiButton.setOnAction(event -> onOpenWiki());
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
actions.getChildren().add(wikiButton);
}
JFXButton actionButton = newToggleButton4(SVG.ARROW_FORWARD);
actionButton.setOnAction(e -> onAction());
actions.getChildren().add(actionButton);
if ("game".equals(libraryId)) {
content.getExternalLinkButton().setGraphic(SVG.GLOBE_BOOK.createIcon(Theme.blackFill(), -1));
FXUtils.installFastTooltip(content.getExternalLinkButton(), i18n("wiki.tooltip"));
}
hbox.getChildren().setAll(imageView, twoLineListItem, actions);
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(new RipplerContainer(hbox));
FXUtils.onClicked(this, this::onAction);
}
private void onAction() {
RemoteVersion item = getItem();
if (item == null)
return;
control.navigation.getSettings().put(control.libraryId, item);
control.callback.run();
}
private void onOpenWiki() {
RemoteVersion item = getItem();
if (!(item instanceof GameRemoteVersion))
return;
FXUtils.openLink(I18n.getWikiLink((GameRemoteVersion) item));
StackPane.setMargin(content, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(ripplerContainer);
}
@Override
@@ -223,35 +398,30 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
setGraphic(pane);
twoLineListItem.setTitle(I18n.getDisplaySelfVersion(remoteVersion));
content.setTitle(remoteVersion.getSelfVersion());
if (remoteVersion.getReleaseDate() != null) {
twoLineListItem.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
content.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
} else {
twoLineListItem.setSubtitle(null);
content.setSubtitle(null);
}
twoLineListItem.getTags().clear();
if (remoteVersion instanceof GameRemoteVersion) {
RemoteVersion.Type versionType = remoteVersion.getVersionType();
switch (versionType) {
switch (remoteVersion.getVersionType()) {
case RELEASE:
twoLineListItem.addTag(i18n("version.game.release"));
imageView.setImage(VersionIconType.GRASS.getIcon());
content.getTags().setAll(i18n("version.game.release"));
content.setImage(VersionIconType.GRASS.getIcon());
content.setExternalLink(i18n("wiki.version.game.release", remoteVersion.getGameVersion()));
break;
case PENDING:
case SNAPSHOT:
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
twoLineListItem.addTag(i18n("version.game.april_fools"));
imageView.setImage(VersionIconType.APRIL_FOOLS.getIcon());
} else {
twoLineListItem.addTag(i18n("version.game.snapshot"));
imageView.setImage(VersionIconType.COMMAND.getIcon());
}
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(VersionIconType.COMMAND.getIcon());
content.setExternalLink(i18n("wiki.version.game.snapshot", remoteVersion.getGameVersion()));
break;
default:
twoLineListItem.addTag(i18n("version.game.old"));
imageView.setImage(VersionIconType.CRAFT_TABLE.getIcon());
content.getTags().setAll(i18n("version.game.old"));
content.setImage(VersionIconType.CRAFT_TABLE.getIcon());
content.setExternalLink(null);
break;
}
} else {
@@ -262,8 +432,6 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
iconType = VersionIconType.OPTIFINE;
else if (remoteVersion instanceof ForgeRemoteVersion)
iconType = VersionIconType.FORGE;
else if (remoteVersion instanceof CleanroomRemoteVersion)
iconType = VersionIconType.CLEANROOM;
else if (remoteVersion instanceof NeoForgeRemoteVersion)
iconType = VersionIconType.NEO_FORGE;
else if (remoteVersion instanceof FabricRemoteVersion || remoteVersion instanceof FabricAPIRemoteVersion)
@@ -271,218 +439,15 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion)
iconType = VersionIconType.QUILT;
else
iconType = VersionIconType.COMMAND;
iconType = null;
imageView.setImage(iconType.getIcon());
if (twoLineListItem.getSubtitle() == null)
twoLineListItem.setSubtitle(remoteVersion.getGameVersion());
content.setImage(iconType != null ? iconType.getIcon() : null);
if (content.getSubtitle() == null)
content.setSubtitle(remoteVersion.getGameVersion());
else
twoLineListItem.addTag(remoteVersion.getGameVersion());
content.getTags().setAll(remoteVersion.getGameVersion());
content.setExternalLink(null);
}
}
}
private static final class VersionsPageSkin extends SkinBase<VersionsPage> {
private final JFXListView<RemoteVersion> list;
private final TransitionPane transitionPane;
private final JFXSpinner spinner;
private final JFXTextField nameField;
private final JFXComboBox<VersionTypeFilter> categoryField = new JFXComboBox<>();
VersionsPageSkin(VersionsPage control) {
super(control);
BorderPane root = new BorderPane();
GridPane searchPane = new GridPane();
root.setTop(searchPane);
searchPane.getStyleClass().addAll("card");
BorderPane.setMargin(searchPane, new Insets(10, 10, 0, 10));
ColumnConstraints nameColumn = new ColumnConstraints();
nameColumn.setMinWidth(USE_PREF_SIZE);
ColumnConstraints column1 = new ColumnConstraints();
column1.setHgrow(Priority.ALWAYS);
ColumnConstraints column2 = new ColumnConstraints();
column2.setMaxWidth(150);
ColumnConstraints column3 = new ColumnConstraints();
if (control.versionList.hasType())
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2, column3);
else
searchPane.getColumnConstraints().setAll(nameColumn, column1, column3);
searchPane.setHgap(16);
searchPane.setVgap(10);
{
int rowIndex = 0;
{
nameField = new JFXTextField();
nameField.setPromptText(i18n("version.search.prompt"));
nameField.textProperty().addListener(o -> updateList());
if ("game".equals(control.libraryId)) {
categoryField.getItems().setAll(
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
VersionTypeFilter.SNAPSHOTS,
VersionTypeFilter.APRIL_FOOLS,
VersionTypeFilter.OLD
);
categoryField.getSelectionModel().select(VersionTypeFilter.RELEASE);
} else {
categoryField.getItems().setAll(
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
VersionTypeFilter.SNAPSHOTS
);
categoryField.getSelectionModel().select(VersionTypeFilter.ALL);
}
categoryField.setConverter(stringConverter(type -> i18n("version.game." + type.name().toLowerCase(Locale.ROOT))));
categoryField.getSelectionModel().selectedItemProperty().addListener(o -> updateList());
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
refreshButton.setOnAction(event -> control.onRefresh());
if (control.versionList.hasType()) {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
new Label(i18n("version.game.type")), categoryField,
refreshButton
);
} else {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
refreshButton
);
}
}
// {
// HBox actionsBox = new HBox(8);
// GridPane.setColumnSpan(actionsBox, 4);
// actionsBox.setAlignment(Pos.CENTER_RIGHT);
//
// JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
// refreshButton.setOnAction(event -> control.onRefresh());
//
// actionsBox.getChildren().setAll(refreshButton);
//
// searchPane.addRow(rowIndex++, actionsBox);
// }
}
{
SpinnerPane spinnerPane = new SpinnerPane();
root.setCenter(spinnerPane);
transitionPane = new TransitionPane();
spinner = new JFXSpinner();
StackPane centerWrapper = new StackPane();
centerWrapper.setStyle("-fx-padding: 10;");
{
ComponentList centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
{
list = new JFXListView<>();
list.getStyleClass().add("jfx-list-view-float");
VBox.setVgrow(list, Priority.ALWAYS);
control.versions.addListener((InvalidationListener) o -> updateList());
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control));
ComponentList.setVgrow(list, Priority.ALWAYS);
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
centrePane.getContent().setAll(list);
}
centerWrapper.getChildren().setAll(centrePane);
}
StackPane failedPane = new StackPane();
failedPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.refresh"));
FXUtils.onClicked(label, control::onRefresh);
failedPane.getChildren().setAll(label);
}
StackPane emptyPane = new StackPane();
emptyPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.empty"));
FXUtils.onClicked(label, control::onBack);
emptyPane.getChildren().setAll(label);
}
FXUtils.onChangeAndOperate(control.status, status -> {
if (status == Status.LOADING)
transitionPane.setContent(spinner, ContainerAnimations.FADE);
else if (status == Status.SUCCESS)
transitionPane.setContent(centerWrapper, ContainerAnimations.FADE);
else // if (status == Status.FAILED)
transitionPane.setContent(failedPane, ContainerAnimations.FADE);
});
root.setCenter(transitionPane);
}
this.getChildren().setAll(root);
}
private void updateList() {
Stream<RemoteVersion> versions = getSkinnable().versions.stream();
VersionTypeFilter filter = categoryField.getSelectionModel().getSelectedItem();
if (filter != null)
versions = versions.filter(it -> {
RemoteVersion.Type versionType = it.getVersionType();
switch (filter) {
case RELEASE:
return versionType == RemoteVersion.Type.RELEASE;
case SNAPSHOTS:
return versionType == RemoteVersion.Type.SNAPSHOT
|| versionType == RemoteVersion.Type.PENDING;
case APRIL_FOOLS:
return versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
case OLD:
return versionType == RemoteVersion.Type.OLD;
case ALL:
default:
return true;
}
});
String nameQuery = nameField.getText();
if (!StringUtils.isBlank(nameQuery)) {
if (nameQuery.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(nameQuery.substring("regex:".length()));
versions = versions.filter(it -> pattern.matcher(it.getSelfVersion()).find());
} catch (Throwable e) {
LOG.warning("Illegal regular expression: " + nameQuery, e);
}
} else {
String lowerQueryString = nameQuery.toLowerCase(Locale.ROOT);
versions = versions.filter(it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString));
}
}
//noinspection DataFlowIssue
list.getItems().setAll(versions.collect(Collectors.toList()));
list.scrollTo(0);
}
}
}
@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.server.ServerModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.setting.Config;
import org.jackhuang.hmcl.setting.FontManager;
import org.jackhuang.hmcl.setting.Profile;
@@ -35,10 +34,10 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -55,31 +54,28 @@ public final class ExportWizardProvider implements WizardProvider {
}
@Override
public void start(SettingsMap settings) {
public void start(Map<String, Object> settings) {
}
@Override
public Object finish(SettingsMap settings) {
List<String> whitelist = settings.get(ModpackFileSelectionPage.MODPACK_FILE_SELECTION);
Path modpackFile = settings.get(ModpackInfoPage.MODPACK_FILE);
ModpackExportInfo exportInfo = settings.get(ModpackInfoPage.MODPACK_INFO);
public Object finish(Map<String, Object> settings) {
@SuppressWarnings("unchecked")
List<String> whitelist = (List<String>) settings.get(ModpackFileSelectionPage.MODPACK_FILE_SELECTION);
File modpackFile = (File) settings.get(ModpackInfoPage.MODPACK_FILE);
ModpackExportInfo exportInfo = (ModpackExportInfo) settings.get(ModpackInfoPage.MODPACK_INFO);
exportInfo.setWhitelist(whitelist);
String modpackType = settings.get(ModpackTypeSelectionPage.MODPACK_TYPE);
String modpackType = (String) settings.get(ModpackTypeSelectionPage.MODPACK_TYPE);
return exportWithLauncher(modpackType, exportInfo, modpackFile);
}
private Task<?> exportWithLauncher(String modpackType, ModpackExportInfo exportInfo, Path modpackFile) {
private Task<?> exportWithLauncher(String modpackType, ModpackExportInfo exportInfo, File modpackFile) {
Path launcherJar = JarUtils.thisJarPath();
boolean packWithLauncher = exportInfo.isPackWithLauncher() && launcherJar != null;
return new Task<>() {
Path tempModpack;
return new Task<Object>() {
File tempModpack;
Task<?> exportTask;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public boolean doPreExecute() {
return true;
@@ -87,9 +83,9 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void preExecute() throws Exception {
Path dest;
File dest;
if (packWithLauncher) {
dest = tempModpack = Files.createTempFile("hmcl", ".zip");
dest = tempModpack = Files.createTempFile("hmcl", ".zip").toFile();
} else {
dest = modpackFile;
}
@@ -104,9 +100,6 @@ public final class ExportWizardProvider implements WizardProvider {
case ModpackTypeSelectionPage.MODPACK_TYPE_SERVER:
exportTask = exportAsServer(exportInfo, dest);
break;
case ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH:
exportTask = exportAsModrinth(exportInfo, dest);
break;
default:
throw new IllegalStateException("Unrecognized modpack type " + modpackType);
}
@@ -121,7 +114,7 @@ public final class ExportWizardProvider implements WizardProvider {
@Override
public void execute() throws Exception {
if (!packWithLauncher) return;
try (Zipper zip = new Zipper(modpackFile)) {
try (Zipper zip = new Zipper(modpackFile.toPath())) {
Config exported = new Config();
exported.setBackgroundImageType(config().getBackgroundImageType());
@@ -164,14 +157,10 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) {
private Task<?> exportAsMcbbs(ModpackExportInfo exportInfo, File modpackFile) {
return new Task<Void>() {
Task<?> dependency = null;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new McbbsModpackExportTask(profile.getRepository(), version, exportInfo, modpackFile);
@@ -184,14 +173,10 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) {
private Task<?> exportAsMultiMC(ModpackExportInfo exportInfo, File modpackFile) {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
VersionSetting vs = profile.getVersionSetting(version);
@@ -232,14 +217,10 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) {
private Task<?> exportAsServer(ModpackExportInfo exportInfo, File modpackFile) {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new ServerModpackExportTask(profile.getRepository(), version, exportInfo, modpackFile);
@@ -252,39 +233,18 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new ModrinthModpackExportTask(
profile.getRepository(),
version,
exportInfo,
modpackFile
);
}
@Override
public Collection<Task<?>> getDependencies() {
return Collections.singleton(dependency);
}
};
}
@Override
public Node createPage(WizardController controller, int step, SettingsMap settings) {
return switch (step) {
case 0 -> new ModpackTypeSelectionPage(controller);
case 1 -> new ModpackInfoPage(controller, profile.getRepository(), version);
case 2 -> new ModpackFileSelectionPage(controller, profile, version, ModAdviser::suggestMod);
default -> throw new IllegalArgumentException("step");
};
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
switch (step) {
case 0:
return new ModpackTypeSelectionPage(controller);
case 1:
return new ModpackInfoPage(controller, profile.getRepository(), version);
case 2:
return new ModpackFileSelectionPage(controller, profile, version, ModAdviser::suggestMod);
default:
throw new IllegalArgumentException("step");
}
}
@Override
@@ -33,13 +33,10 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -48,7 +45,6 @@ import java.util.Objects;
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;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author huangyuhui
@@ -84,18 +80,16 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
this.setBottom(nextPane);
}
private CheckBoxTreeItem<String> getTreeItem(Path file, String basePath) {
if (Files.notExists(file))
private CheckBoxTreeItem<String> getTreeItem(File file, String basePath) {
if (!file.exists())
return null;
boolean isDirectory = Files.isDirectory(file);
ModAdviser.ModSuggestion state = ModAdviser.ModSuggestion.SUGGESTED;
if (basePath.length() > "minecraft/".length()) {
state = adviser.advise(StringUtils.substringAfter(basePath, "minecraft/") + (isDirectory ? "/" : ""), isDirectory);
if (!isDirectory && Objects.equals(FileUtils.getNameWithoutExtension(file), version))
state = adviser.advise(StringUtils.substringAfter(basePath, "minecraft/") + (file.isDirectory() ? "/" : ""), file.isDirectory());
if (file.isFile() && Objects.equals(FileUtils.getNameWithoutExtension(file), version)) // Ignore <version>.json, <version>.jar
state = ModAdviser.ModSuggestion.HIDDEN;
if (isDirectory && Objects.equals(FileUtils.getName(file), version + "-natives")) // Ignore <version>-natives
if (file.isDirectory() && Objects.equals(file.getName(), version + "-natives")) // Ignore <version>-natives
state = ModAdviser.ModSuggestion.HIDDEN;
if (state == ModAdviser.ModSuggestion.HIDDEN)
return null;
@@ -105,10 +99,11 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
if (state == ModAdviser.ModSuggestion.SUGGESTED)
node.setSelected(true);
if (isDirectory) {
try (var stream = Files.list(file)) {
stream.forEach(it -> {
CheckBoxTreeItem<String> subNode = getTreeItem(it, basePath + "/" + FileUtils.getName(it));
if (file.isDirectory()) {
File[] files = file.listFiles();
if (files != null) {
for (File it : files) {
CheckBoxTreeItem<String> subNode = getTreeItem(it, basePath + "/" + it.getName());
if (subNode != null) {
node.setSelected(subNode.isSelected() || node.isSelected());
if (!subNode.isSelected()) {
@@ -116,11 +111,8 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
}
node.getChildren().add(subNode);
}
});
} catch (IOException e) {
LOG.warning("Failed to list contents of " + file, e);
}
}
if (!node.isSelected()) node.setIndeterminate(false);
// Empty folder need not to be displayed.
@@ -163,7 +155,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
}
@Override
public void cleanup(SettingsMap settings) {
public void cleanup(Map<String, Object> settings) {
controller.getSettings().remove(MODPACK_FILE_SELECTION);
}
@@ -179,7 +171,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
return i18n("modpack.wizard.step.2.title");
}
public static final SettingsMap.Key<List<String>> MODPACK_FILE_SELECTION = new SettingsMap.Key<>("modpack.accepted");
public static final String MODPACK_FILE_SELECTION = "modpack.accepted";
private static final Map<String, String> TRANSLATION = mapOf(
pair("minecraft/hmclversion.cfg", i18n("modpack.files.hmclversion_cfg")),
pair("minecraft/servers.dat", i18n("modpack.files.servers_dat")),
@@ -41,13 +41,11 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import java.nio.file.Path;
import java.io.File;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -55,9 +53,9 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.jfxListCellFactory;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_SERVER;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class ModpackInfoPage extends Control implements WizardPage {
@@ -82,18 +80,14 @@ public final class ModpackInfoPage extends Control implements WizardPage {
private final SimpleStringProperty launchArguments = new SimpleStringProperty("");
private final SimpleStringProperty javaArguments = new SimpleStringProperty("");
private final SimpleStringProperty mcbbsThreadId = new SimpleStringProperty("");
private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty();
private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty();
public ModpackInfoPage(WizardController controller, HMCLGameRepository gameRepository, String version) {
this.controller = controller;
this.gameRepository = gameRepository;
this.options = controller.getSettings().get(MODPACK_INFO_OPTION);
this.options = tryCast(controller.getSettings().get(MODPACK_INFO_OPTION), ModpackExportInfo.Options.class)
.orElseThrow(() -> new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required"));
this.versionName = version;
if (this.options == null)
throw new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required");
name.set(version);
author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getUsername).orElse(""));
@@ -108,14 +102,8 @@ public final class ModpackInfoPage extends Control implements WizardPage {
private void onNext() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("modpack.wizard.step.initialization.save"));
if (controller.getSettings().get(MODPACK_TYPE) == ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH) {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.mrpack"));
fileChooser.setInitialFileName(name.get() + ".mrpack");
} else {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
fileChooser.setInitialFileName(name.get() + ".zip");
}
Path file = FileUtils.toPath(fileChooser.showSaveDialog(Controllers.getStage()));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
File file = fileChooser.showSaveDialog(Controllers.getStage());
if (file == null) {
controller.onEnd();
return;
@@ -134,8 +122,6 @@ public final class ModpackInfoPage extends Control implements WizardPage {
exportInfo.setLaunchArguments(launchArguments.get());
exportInfo.setJavaArguments(javaArguments.get());
exportInfo.setAuthlibInjectorServer(authlibInjectorServer.get());
exportInfo.setNoCreateRemoteFiles(noCreateRemoteFiles.get());
exportInfo.setSkipCurseForgeRemoteFiles(skipCurseForgeRemoteFiles.get());
if (StringUtils.isNotBlank(mcbbsThreadId.get())) {
exportInfo.setOrigins(Collections.singletonList(new McbbsModpackManifest.Origin(
@@ -149,7 +135,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
}
@Override
public void cleanup(SettingsMap settings) {
public void cleanup(Map<String, Object> settings) {
controller.getSettings().remove(MODPACK_INFO);
}
@@ -163,9 +149,9 @@ public final class ModpackInfoPage extends Control implements WizardPage {
return new ModpackInfoPageSkin(this);
}
public static final SettingsMap.Key<ModpackExportInfo> MODPACK_INFO = new SettingsMap.Key<>("modpack.info");
public static final SettingsMap.Key<Path> MODPACK_FILE = new SettingsMap.Key<>("modpack.file");
public static final SettingsMap.Key<ModpackExportInfo.Options> MODPACK_INFO_OPTION = new SettingsMap.Key<>("modpack.info.option");
public static final String MODPACK_INFO = "modpack.info";
public static final String MODPACK_FILE = "modpack.file";
public static final String MODPACK_INFO_OPTION = "modpack.info.option";
public static class ModpackInfoPageSkin extends SkinBase<ModpackInfoPage> {
private ObservableList<Node> originList;
@@ -192,11 +178,6 @@ public final class ModpackInfoPage extends Control implements WizardPage {
Hyperlink hyperlink = new Hyperlink(i18n("modpack.wizard.step.initialization.server"));
hyperlink.setOnAction(e -> FXUtils.openLink(Metadata.DOCS_URL + "/modpack/serverpack.html"));
borderPane.setTop(hyperlink);
}
if (skinnable.controller.getSettings().get(MODPACK_TYPE) == MODPACK_TYPE_MODRINTH) {
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
pane.setText(i18n("modpack.wizard.step.initialization.modrinth.info"));
borderPane.setTop(pane);
} else {
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
pane.setText(i18n("modpack.wizard.step.initialization.warning"));
@@ -385,32 +366,6 @@ public final class ModpackInfoPage extends Control implements WizardPage {
button.setMaxHeight(16);
pane.setRight(button);
}
if (skinnable.options.isRequireNoCreateRemoteFiles()) {
BorderPane noCreateRemoteFiles = new BorderPane();
noCreateRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.no_create_remote_files")));
list.getContent().add(noCreateRemoteFiles);
JFXToggleButton noCreateRemoteFilesButton = new JFXToggleButton();
noCreateRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.noCreateRemoteFiles);
noCreateRemoteFilesButton.setSize(8);
noCreateRemoteFilesButton.setMinHeight(16);
noCreateRemoteFilesButton.setMaxHeight(16);
noCreateRemoteFiles.setRight(noCreateRemoteFilesButton);
}
if (skinnable.options.isRequireSkipCurseForgeRemoteFiles()) {
BorderPane skipCurseForgeRemoteFiles = new BorderPane();
skipCurseForgeRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.skip_curseforge_remote_files")));
list.getContent().add(skipCurseForgeRemoteFiles);
JFXToggleButton skipCurseForgeRemoteFilesButton = new JFXToggleButton();
skipCurseForgeRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.skipCurseForgeRemoteFiles);
skipCurseForgeRemoteFilesButton.setSize(8);
skipCurseForgeRemoteFilesButton.setMinHeight(16);
skipCurseForgeRemoteFilesButton.setMaxHeight(16);
skipCurseForgeRemoteFiles.setRight(skipCurseForgeRemoteFilesButton);
}
}
{
@@ -28,12 +28,12 @@ import org.jackhuang.hmcl.mod.ModpackExportInfo;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.server.ServerModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.SettingsMap;
import java.util.Map;
import static org.jackhuang.hmcl.ui.export.ModpackInfoPage.MODPACK_INFO_OPTION;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -56,8 +56,7 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
title,
createButton(MODPACK_TYPE_MCBBS, McbbsModpackExportTask.OPTION),
createButton(MODPACK_TYPE_MULTIMC, MultiMCModpackExportTask.OPTION),
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION),
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION)
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION)
);
}
@@ -88,7 +87,7 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
}
@Override
public void cleanup(SettingsMap settings) {
public void cleanup(Map<String, Object> settings) {
}
@Override
@@ -96,10 +95,9 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
return i18n("modpack.wizard.step.3.title");
}
public static final SettingsMap.Key<String> MODPACK_TYPE = new SettingsMap.Key<>("modpack.type");
public static final String MODPACK_TYPE = "modpack.type";
public static final String MODPACK_TYPE_MCBBS = "mcbbs";
public static final String MODPACK_TYPE_MULTIMC = "multimc";
public static final String MODPACK_TYPE_SERVER = "server";
public static final String MODPACK_TYPE_MODRINTH = "modrinth";
}
@@ -1,28 +0,0 @@
/*
* 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.image;
import javafx.scene.image.Image;
import java.io.InputStream;
public interface ImageLoader {
Image load(InputStream input,
int requestedWidth, int requestedHeight,
boolean preserveRatio, boolean smooth) throws Exception;
}
@@ -1,411 +0,0 @@
/*
* 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.image;
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
import javafx.animation.Timeline;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import org.jackhuang.hmcl.ui.image.apng.Png;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Bitmap;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888BitmapSequence;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl;
import org.jackhuang.hmcl.util.SwingFXUtils;
import org.jetbrains.annotations.Nullable;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class ImageUtils {
// ImageLoaders
public static final ImageLoader DEFAULT = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
Image image = new Image(input,
requestedWidth, requestedHeight,
preserveRatio, smooth);
if (image.isError())
throw image.getException();
return image;
};
public static final ImageLoader WEBP = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
BufferedImage bufferedImage;
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
reader.setInput(imageInput, true, true);
bufferedImage = reader.read(0, reader.getDefaultReadParam());
} finally {
reader.dispose();
}
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
};
public static final ImageLoader APNG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
if (!"true".equals(System.getProperty("hmcl.experimental.apng", "true")))
return DEFAULT.load(input, requestedWidth, requestedHeight, preserveRatio, smooth);
try {
var sequence = Png.readArgb8888BitmapSequence(input);
final int width = sequence.header.width;
final int height = sequence.header.height;
boolean doScale;
if (requestedWidth > 0 && requestedHeight > 0
&& (requestedWidth != width || requestedHeight != height)) {
doScale = true;
if (preserveRatio) {
double scaleX = (double) requestedWidth / width;
double scaleY = (double) requestedHeight / height;
double scale = Math.min(scaleX, scaleY);
requestedWidth = (int) (width * scale);
requestedHeight = (int) (height * scale);
}
} else {
doScale = false;
}
if (sequence.isAnimated()) {
try {
return toImage(sequence, doScale, requestedWidth, requestedHeight);
} catch (Throwable e) {
LOG.warning("Failed to load animated image", e);
}
}
Argb8888Bitmap defaultImage = sequence.defaultImage;
int targetWidth;
int targetHeight;
int[] pixels;
if (doScale) {
targetWidth = requestedWidth;
targetHeight = requestedHeight;
pixels = scale(defaultImage.array,
defaultImage.width, defaultImage.height,
targetWidth, targetHeight);
} else {
targetWidth = defaultImage.width;
targetHeight = defaultImage.height;
pixels = defaultImage.array;
}
WritableImage image = new WritableImage(targetWidth, targetHeight);
image.getPixelWriter().setPixels(0, 0, targetWidth, targetHeight,
PixelFormat.getIntArgbInstance(), pixels,
0, targetWidth);
return image;
} catch (PngException e) {
throw new IOException(e);
}
};
public static final Map<String, ImageLoader> EXT_TO_LOADER = Map.of(
"webp", WEBP,
"apng", APNG
);
public static final Map<String, ImageLoader> CONTENT_TYPE_TO_LOADER = Map.of(
"image/webp", WEBP,
"image/apng", APNG
);
public static final Set<String> DEFAULT_EXTS = Set.of(
"jpg", "jpeg", "bmp", "gif"
);
public static final Set<String> DEFAULT_CONTENT_TYPES = Set.of(
"image/jpeg", "image/bmp", "image/gif"
);
// ------
public static final int HEADER_BUFFER_SIZE = 1024;
private static final byte[] RIFF_HEADER = {'R', 'I', 'F', 'F'};
private static final byte[] WEBP_HEADER = {'W', 'E', 'B', 'P'};
public static boolean isWebP(byte[] headerBuffer) {
return headerBuffer.length > 12
&& Arrays.equals(headerBuffer, 0, 4, RIFF_HEADER, 0, 4)
&& Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4);
}
private static final byte[] PNG_HEADER = {
(byte) 0x89, (byte) 0x50, (byte) 0x4e, (byte) 0x47,
(byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a,
};
private static final class PngChunkHeader {
private static final int IDAT_HEADER = 0x49444154;
private static final int acTL_HEADER = 0x6163544c;
private final int length;
private final int chunkType;
private PngChunkHeader(int length, int chunkType) {
this.length = length;
this.chunkType = chunkType;
}
private static @Nullable PngChunkHeader readHeader(ByteBuffer headerBuffer) {
if (headerBuffer.remaining() < 8)
return null;
int length = headerBuffer.getInt();
int chunkType = headerBuffer.getInt();
return new PngChunkHeader(length, chunkType);
}
}
public static boolean isApng(byte[] headerBuffer) {
if (headerBuffer.length <= 20)
return false;
if (!Arrays.equals(
headerBuffer, 0, 8,
PNG_HEADER, 0, 8))
return false;
ByteBuffer buffer = ByteBuffer.wrap(headerBuffer, 8, headerBuffer.length - 8);
PngChunkHeader header;
while ((header = PngChunkHeader.readHeader(buffer)) != null) {
// https://wiki.mozilla.org/APNG_Specification#Structure
// To be recognized as an APNG, an `acTL` chunk must appear in the stream before any `IDAT` chunks.
// The `acTL` structure is described below.
if (header.chunkType == PngChunkHeader.IDAT_HEADER)
break;
if (header.chunkType == PngChunkHeader.acTL_HEADER)
return true;
final int numBytes = header.length + 4;
if (buffer.remaining() > numBytes)
buffer.position(buffer.position() + numBytes);
else
break;
}
return false;
}
public static @Nullable ImageLoader guessLoader(byte[] headerBuffer) {
if (isWebP(headerBuffer))
return WEBP;
if (isApng(headerBuffer))
return APNG;
return null;
}
public static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile("^\\s(?<type>image/[\\w-])");
// APNG
private static int[] scale(int[] pixels,
int sourceWidth, int sourceHeight,
int targetWidth, int targetHeight) {
assert pixels.length == sourceWidth * sourceHeight;
double xScale = ((double) sourceWidth) / targetWidth;
double yScale = ((double) sourceHeight) / targetHeight;
int[] result = new int[targetWidth * targetHeight];
for (int row = 0; row < targetHeight; row++) {
for (int col = 0; col < targetWidth; col++) {
int sourceX = (int) (col * xScale);
int sourceY = (int) (row * yScale);
int color = pixels[sourceY * sourceWidth + sourceX];
result[row * targetWidth + col] = color;
}
}
return result;
}
private static Image toImage(Argb8888BitmapSequence sequence,
boolean doScale,
int targetWidth, int targetHeight) throws PngException {
final int width = sequence.header.width;
final int height = sequence.header.height;
List<Argb8888BitmapSequence.Frame> frames = sequence.getAnimationFrames();
var framePixels = new int[frames.size()][];
var durations = new int[framePixels.length];
int[] buffer = new int[Math.multiplyExact(width, height)];
for (int frameIndex = 0; frameIndex < frames.size(); frameIndex++) {
var frame = frames.get(frameIndex);
PngFrameControl control = frame.control;
if (frameIndex == 0 && (
control.xOffset != 0 || control.yOffset != 0
|| control.width != width || control.height != height)) {
throw new PngIntegrityException("Invalid first frame: " + control);
}
if (control.xOffset < 0 || control.yOffset < 0
|| width < 0 || height < 0
|| control.xOffset + control.width > width
|| control.yOffset + control.height > height
|| control.delayNumerator < 0 || control.delayDenominator < 0
) {
throw new PngIntegrityException("Invalid frame control: " + control);
}
int[] currentFrameBuffer = buffer.clone();
if (control.blendOp == 0) {
for (int row = 0; row < control.height; row++) {
System.arraycopy(frame.bitmap.array,
row * control.width,
currentFrameBuffer,
(control.yOffset + row) * width + control.xOffset,
control.width);
}
} else if (control.blendOp == 1) {
// APNG_BLEND_OP_OVER - Alpha blending
for (int row = 0; row < control.height; row++) {
for (int col = 0; col < control.width; col++) {
int srcIndex = row * control.width + col;
int dstIndex = (control.yOffset + row) * width + control.xOffset + col;
int srcPixel = frame.bitmap.array[srcIndex];
int dstPixel = currentFrameBuffer[dstIndex];
int srcAlpha = (srcPixel >>> 24) & 0xFF;
if (srcAlpha == 0) {
continue;
} else if (srcAlpha == 255) {
currentFrameBuffer[dstIndex] = srcPixel;
} else {
int srcR = (srcPixel >>> 16) & 0xFF;
int srcG = (srcPixel >>> 8) & 0xFF;
int srcB = srcPixel & 0xFF;
int dstAlpha = (dstPixel >>> 24) & 0xFF;
int dstR = (dstPixel >>> 16) & 0xFF;
int dstG = (dstPixel >>> 8) & 0xFF;
int dstB = dstPixel & 0xFF;
int invSrcAlpha = 255 - srcAlpha;
int outAlpha = srcAlpha + (dstAlpha * invSrcAlpha + 127) / 255;
int outR, outG, outB;
if (outAlpha == 0) {
outR = outG = outB = 0;
} else {
outR = (srcR * srcAlpha + dstR * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
outG = (srcG * srcAlpha + dstG * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
outB = (srcB * srcAlpha + dstB * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
}
outAlpha = Math.min(outAlpha, 255);
outR = Math.min(outR, 255);
outG = Math.min(outG, 255);
outB = Math.min(outB, 255);
currentFrameBuffer[dstIndex] = (outAlpha << 24) | (outR << 16) | (outG << 8) | outB;
}
}
}
} else {
throw new PngIntegrityException("Unsupported blendOp " + control.blendOp + " at frame " + frameIndex);
}
if (doScale)
framePixels[frameIndex] = scale(currentFrameBuffer,
width, height,
targetWidth, targetHeight);
else
framePixels[frameIndex] = currentFrameBuffer;
if (control.delayNumerator == 0) {
durations[frameIndex] = 10;
} else {
int durationsMills = 1000 * control.delayNumerator;
if (control.delayDenominator == 0)
durationsMills /= 100;
else
durationsMills /= control.delayDenominator;
durations[frameIndex] = durationsMills;
}
switch (control.disposeOp) {
case 0: // APNG_DISPOST_OP_NONE
System.arraycopy(currentFrameBuffer, 0, buffer, 0, currentFrameBuffer.length);
break;
case 1: // APNG_DISPOSE_OP_BACKGROUND
for (int row = 0; row < control.height; row++) {
int fromIndex = (control.yOffset + row) * width + control.xOffset;
Arrays.fill(buffer, fromIndex, fromIndex + control.width, 0);
}
break;
case 2: // APNG_DISPOSE_OP_PREVIOUS
// Do nothing, keep the previous frame.
break;
default:
throw new PngIntegrityException("Unsupported disposeOp " + control.disposeOp + " at frame " + frameIndex);
}
}
PngAnimationControl animationControl = sequence.getAnimationControl();
int cycleCount;
if (animationControl != null) {
cycleCount = animationControl.numPlays;
if (cycleCount == 0)
cycleCount = Timeline.INDEFINITE;
} else {
cycleCount = Timeline.INDEFINITE;
}
if (doScale)
return new AnimationImageImpl(targetWidth, targetHeight, framePixels, durations, cycleCount);
else
return new AnimationImageImpl(width, height, framePixels, durations, cycleCount);
}
private ImageUtils() {
}
}
@@ -1,57 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.argb8888.*;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.map.PngMap;
import org.jackhuang.hmcl.ui.image.apng.map.PngMapReader;
import org.jackhuang.hmcl.ui.image.apng.reader.DefaultPngChunkReader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReadHelper;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReader;
import org.jackhuang.hmcl.ui.image.apng.util.PngContainer;
import org.jackhuang.hmcl.ui.image.apng.util.PngContainerBuilder;
import java.io.InputStream;
/**
* Convenient one liners for loading PNG images.
*/
public class Png {
/**
* Read the provided stream and produce a PngMap of the data.
*
* @param is stream to read from
* @param sourceName optional name, mainly for debugging.
* @return PngMap of the data.
* @throws PngException
*/
public static PngMap readMap(InputStream is, String sourceName) throws PngException {
return PngReadHelper.read(is, new PngMapReader(sourceName));
}
public static PngContainer readContainer(InputStream is) throws PngException {
return PngReadHelper.read(is, new DefaultPngChunkReader<>(new PngContainerBuilder()));
}
public static <ResultT> ResultT read(InputStream is, PngReader<ResultT> reader) throws PngException {
return PngReadHelper.read(is, reader);
}
public static <ResultT> ResultT read(InputStream is, PngChunkProcessor<ResultT> processor) throws PngException {
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
public static Argb8888Bitmap readArgb8888Bitmap(InputStream is) throws PngException {
Argb8888Processor<Argb8888Bitmap> processor = new Argb8888Processor<>(new DefaultImageArgb8888Director());
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
public static Argb8888BitmapSequence readArgb8888BitmapSequence(InputStream is) throws PngException {
Argb8888Processor<Argb8888BitmapSequence> processor = new Argb8888Processor<>(new Argb8888BitmapSequenceDirector());
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
}
@@ -1,19 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* A PNG file has an animation type - most commonly, not animated.
* <p>
* This is primarily relevant to tracking whether the default image in a PNG
* is to be
* a) processed as normal (e.g. in a standard or "non-animated" PNG file);
* b) processed and used as the first frame in an animated PNG; or
* c) not processed and discarded (e.g. in an animated PNG which does not use the first frame).
*/
public enum PngAnimationType {
NOT_ANIMATED,
ANIMATED_KEEP_DEFAULT_IMAGE,
ANIMATED_DISCARD_DEFAULT_IMAGE;
}
@@ -1,123 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* One four-letter (32-byte) code identifying the type of a PNG chunk.
* <p>
* Common chunk codes are defined as constants that can be used in a switch statement.
* Users can add their own chunk codes separately.
*/
public class PngChunkCode {
public static final PngChunkCode IHDR = new PngChunkCode(PngConstants.IHDR_VALUE, "IHDR");
public static final PngChunkCode PLTE = new PngChunkCode(PngConstants.PLTE_VALUE, "PLTE");
public static final PngChunkCode IDAT = new PngChunkCode(PngConstants.IDAT_VALUE, "IDAT");
public static final PngChunkCode IEND = new PngChunkCode(PngConstants.IEND_VALUE, "IEND");
public static final PngChunkCode gAMA = new PngChunkCode(PngConstants.gAMA_VALUE, "gAMA");
public static final PngChunkCode bKGD = new PngChunkCode(PngConstants.bKGD_VALUE, "bKGD");
public static final PngChunkCode tRNS = new PngChunkCode(PngConstants.tRNS_VALUE, "tRNS");
public static final PngChunkCode acTL = new PngChunkCode(PngConstants.acTL_VALUE, "acTL");
public static final PngChunkCode fcTL = new PngChunkCode(PngConstants.fcTL_VALUE, "fcTL");
public static final PngChunkCode fdAT = new PngChunkCode(PngConstants.fdAT_VALUE, "fdAT");
public final int numeric;
public final String letters;
PngChunkCode(int numeric, String letters) {
this.numeric = numeric;
this.letters = letters;
}
/**
* Find out if the chunk is "critical" as defined by http://www.w3.org/TR/PNG/#5Chunk-naming-conventions
*
* <pre>
* cHNk <-- 32 bit chunk type represented in text form
* ||||
* |||+- Safe-to-copy bit is 1 (lower case letter; bit 5 is 1)
* ||+-- Reserved bit is 0 (upper case letter; bit 5 is 0)
* |+--- Private bit is 0 (upper case letter; bit 5 is 0)
* +---- Ancillary bit is 1 (lower case letter; bit 5 is 1)
* </pre>
*
* @return true if this chunk is considered critical according to the PNG spec.
*/
public boolean isCritical() {
// Ancillary bit: bit 5 o first byte
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) == 0;
}
public boolean isAncillary() {
// Ancillary bit: bit 5 of first byte
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) > 0;
}
public boolean isPrivate() {
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) > 0;
}
public boolean isPublic() {
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) == 0;
}
public boolean isReserved() {
return (numeric & PngConstants.BIT_CHUNK_IS_RESERVED) > 0;
}
public boolean isSafeToCopy() {
return (numeric & PngConstants.BIT_CHUNK_IS_SAFE_TO_COPY) > 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PngChunkCode that = (PngChunkCode) o;
if (numeric != that.numeric) return false;
return !(letters != null ? !letters.equals(that.letters) : that.letters != null);
}
@Override
public int hashCode() {
int result = numeric;
result = 31 * result + (letters != null ? letters.hashCode() : 0);
return result;
}
@Override
public String toString() {
return letters + "(" + numeric + ")";
}
public static PngChunkCode from(int code) {
switch (code) {
case PngConstants.IHDR_VALUE:
return IHDR;
case PngConstants.gAMA_VALUE:
return gAMA;
case PngConstants.bKGD_VALUE:
return bKGD;
case PngConstants.tRNS_VALUE:
return tRNS;
case PngConstants.IDAT_VALUE:
return IDAT;
case PngConstants.IEND_VALUE:
return IEND;
case PngConstants.acTL_VALUE:
return acTL;
case PngConstants.fdAT_VALUE:
return fdAT;
default:
byte[] s = new byte[4];
s[0] = (byte) ((code & 0xff000000) >> 24);
s[1] = (byte) ((code & 0x00ff0000) >> 16);
s[2] = (byte) ((code & 0x0000ff00) >> 8);
s[3] = (byte) ((code & 0x000000ff));
return new PngChunkCode(code, new String(s));
}
}
}
@@ -1,61 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* PNG images support 5 colour types as defined at http://www.w3.org/TR/PNG/#11IHDR
*/
public enum PngColourType {
PNG_GREYSCALE(0, 1, "1, 2, 4, 8, 16", "Greyscale", "Each pixel is a greyscale sample"),
PNG_TRUECOLOUR(2, 3, "8, 16", "Truecolour", "Each pixel is an R,G,B triple"),
PNG_INDEXED_COLOUR(3, 1, "1, 2, 4, 8", "Indexed-colour", "Each pixel is a palette index; a PLTE chunk shall appear."),
PNG_GREYSCALE_WITH_ALPHA(4, 2, "4, 8, 16", "Greyscale with alpha", "Each pixel is a greyscale sample followed by an alpha sample."),
PNG_TRUECOLOUR_WITH_ALPHA(6, 4, "8, 16", "Truecolour with alpha", "Each pixel is an R,G,B triple followed by an alpha sample.");
public final int code;
public final int componentsPerPixel;
public final String allowedBitDepths;
public final String name;
public final String descriptino;
PngColourType(int code, int componentsPerPixel, String allowedBitDepths, String name, String descriptino) {
this.code = code;
this.componentsPerPixel = componentsPerPixel;
this.allowedBitDepths = allowedBitDepths;
this.name = name;
this.descriptino = descriptino;
}
public boolean isIndexed() {
return (code & 0x01) > 0;
}
public boolean hasAlpha() {
return (code & 0x04) > 0;
}
public boolean supportsSubByteDepth() {
return code == 0 || code == 3;
}
public static PngColourType fromByte(byte b) throws PngException {
switch (b) {
case 0:
return PNG_GREYSCALE;
case 2:
return PNG_TRUECOLOUR;
case 3:
return PNG_INDEXED_COLOUR;
case 4:
return PNG_GREYSCALE_WITH_ALPHA;
case 6:
return PNG_TRUECOLOUR_WITH_ALPHA;
default:
throw new PngIntegrityException(String.format("Valid PNG colour types are 0, 2, 3, 4, 6. Type '%d' is invalid", b));
}
}
}
@@ -1,120 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
public class PngConstants {
public static final int LENGTH_SIGNATURE = 8;
public static final int LENGTH_fcTL_CHUNK = 4 + 4 + 4 + 4 + 4 + 2 + 2 + 1 + 1;
public static final int LENGTH_acTL_CHUNK = 4 + 4;
public static final byte[] BYTES_SIGNATURE = new byte[]{
(byte) 0x89, 'P', 'N', 'G', (byte) 0x0D, (byte) 0x0A, (byte) 0x1A, (byte) 0x0A
};
public static final int IHDR_VALUE = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R'; // 1229472850;
public static final int PLTE_VALUE = 'P' << 24 | 'L' << 16 | 'T' << 8 | 'E';
public static final int IDAT_VALUE = 'I' << 24 | 'D' << 16 | 'A' << 8 | 'T'; // 1229209940;
public static final int IEND_VALUE = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D'; // 1229278788;
public static final int gAMA_VALUE = 'g' << 24 | 'A' << 16 | 'M' << 8 | 'A'; // 1732332865;
public static final int bKGD_VALUE = 'b' << 24 | 'K' << 16 | 'G' << 8 | 'D'; // 1649100612;
public static final int tRNS_VALUE = 't' << 24 | 'R' << 16 | 'N' << 8 | 'S';
public static final int acTL_VALUE = 'a' << 24 | 'c' << 16 | 'T' << 8 | 'L';
public static final int fcTL_VALUE = 'f' << 24 | 'c' << 16 | 'T' << 8 | 'L';
public static final int fdAT_VALUE = 'f' << 24 | 'd' << 16 | 'A' << 8 | 'T';
public static final int ERROR_NOT_PNG = 1;
public static final int ERROR_EOF = 2;
public static final int ERROR_EOF_EXPECTED = 3;
public static final int ERROR_UNKNOWN_IO_FAILURE = 4;
public static final int ERROR_FEATURE_NOT_SUPPORTED = 5;
public static final int ERROR_INTEGRITY = 6;
public static final int ONE_K = 1 << 10;
public static final int DEFAULT_TARGET_BUFFER_SIZE = 32 * ONE_K;
public static final byte[] GREY_PALETTE_16 = new byte[]{
(byte) 0x00, // 0
(byte) 0x11, // 1
(byte) 0x22, // 2
(byte) 0x33, // 3
(byte) 0x44, // 4
(byte) 0x55, // 5
(byte) 0x66, // 6
(byte) 0x77, // 7
(byte) 0x88, // 8
(byte) 0x99, // 9
(byte) 0xaa, // 10
(byte) 0xbb, // 11
(byte) 0xcc, // 12
(byte) 0xdd, // 13
(byte) 0xee, // 14
(byte) 0xff, // 15
};
// public static final byte[] MASKS_1 = new byte[] {
// (byte)0x01, // bit 0
// (byte)0x02, // bit 1
// (byte)0x04, // bit 2
// (byte)0x08, // bit 3
// (byte)0x10, // bit 4
// (byte)0x20, // bit 5
// (byte)0x40, // bit 6
// (byte)0x80, // bit 7
// };
//
// public static final byte[] MASKS_2 = new byte[] {
// (byte)0x03, // bit 0-1
// (byte)0x0C, // bit 2-3
// (byte)0x30, // bit 4-5
// (byte)0xC0, // bit 6-7
// };
//
// public static final byte[] MASKS_4 = new byte[] {
// (byte)0x0F, // bit 0-3
// (byte)0xF0, // bit 4-7
// };
public static final byte[] SHIFTS_1 = new byte[]{
(byte) 0, // bit 0
(byte) 1, // bit 1
(byte) 2, // bit 2
(byte) 3, // bit 3
(byte) 4, // bit 4
(byte) 5, // bit 5
(byte) 6, // bit 6
(byte) 7, // bit 7
};
public static final byte[] SHIFTS_2 = new byte[]{
(byte) 0, // bit 0-1
(byte) 2, // bit 2-3
(byte) 4, // bit 4-5
(byte) 6 // bit 6-7
};
public static final byte[] SHIFTS_4 = new byte[]{
(byte) 0x00, // bit 0-3
(byte) 0x04, // bit 4-7
};
public static final int BIT_CHUNK_IS_ANCILLARY = 0x20000000;
public static final int BIT_CHUNK_IS_PRIVATE = 0x00200000;
public static final int BIT_CHUNK_IS_RESERVED = 0x00002000;
public static final int BIT_CHUNK_IS_SAFE_TO_COPY = 0x00000020;
// public static final byte[] SHIFTS_1 = new byte[] {
// (byte)0x01, // bit 0
// (byte)0x02, // bit 1
// (byte)0x04, // bit 2
// (byte)0x08, // bit 3
// (byte)0x10, // bit 4
// (byte)0x20, // bit 5
// (byte)0x40, // bit 6
// (byte)0x80, // bit 7
// };
//public static final PngChunkCodes Foo = new PngChunkCodes(123, "Foo");
}
@@ -1,125 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* Each "undo*" function reverses the effect of a filter on a given single scanline.
*/
public class PngFilter {
public static void undoSubFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
// int ai = 0;
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
//
// final int x = bytes[i];
// final int a = bytes[pixelStart + ai];
int ai = pixelStart;
for (int i = pixelStart + filterUnit; i < pixelEnd; i++) {
final int x = bytes[i];
final int a = bytes[ai];
//bytes[rowPosition] = (byte)((bytes[rowPosition] + left) & 0xff); // TODO & 0xff
bytes[i] = (byte) ((x + a) & 0xff);
ai++;
}
}
public static void undoUpFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
// for (int i=1; i<bytesPerLine; i++) {
// rowPosition++; // before the first op to skip filter byte
// bytes[rowPosition] = (byte)((bytes[rowPosition] + previousRow[i]) & 0xff);
// }
int bi = 0;
for (int i = pixelStart; i < pixelEnd; i++) {
final int x = bytes[i];
final int b = previousRow[bi];
bytes[i] = (byte) ((x + b) & 0xff);
bi++;
}
}
public static void undoAverageFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
int ai = pixelStart - filterUnit;
int bi = 0;
for (int i = pixelStart; i < pixelEnd; i++) {
final int x = bytes[i];
final int a = (ai < pixelStart) ? 0 : (0xff & bytes[ai]);
final int b = (0xff & previousRow[bi]);
// int ai = pixelStart;
// int bi = 0;
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
// final int x = bytes[i];
// final int a = bytes[ai];
// final int b = previousRow[bi];
//bytes[i] = (byte)((x+((a+b)>>1))&0xff);
//int z = x+(a+b)/2;
int z = x + ((a + b) / 2);
bytes[i] = (byte) (0xff & z);
ai++;
bi++;
}
/*
int bi = 0;
for (int i=pixelStart; i<pixelStart+filterUnit; i++) {
final int x = bytes[i];
final int b = previousRow[bi];
bytes[i] = (byte)((x + (b >> 1)) & 0xff);
bi++;
}
for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
final int x = bytes[i];
final int a = bytes[pixelStart+bi-filterUnit];
final int b = previousRow[bi];
bytes[i] = (byte)((x + ((a+b) >> 1)) & 0xff);
bi++;
}*/
}
/**
* See http://www.libpng.org/pub/png/spec/1.2/PNG-Filters.html
*
*/
public static void undoPaethFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
//int scratch;
//int previousLeft = 0;
//int left = 0;
int ai = pixelStart - filterUnit;
int bi = 0;
int ci = -filterUnit;
//for (int i=0; i<bytesPerLine-1; i++) {
//for (int i=filterUnit; i<bytesPerLine-1; i++) {
for (int i = pixelStart; i < pixelEnd; i++) {
final int a, b, c, x;
x = bytes[i];
if (ai < pixelStart) {
a = c = 0;
} else {
a = 0xff & bytes[ai];
c = 0xff & previousRow[ci];
}
b = 0xff & previousRow[bi];
final int p = a + b - c;
final int pa = p >= a ? p - a : a - p;
final int pb = p >= b ? p - b : b - p;
final int pc = p >= c ? p - c : c - p;
final int predicted = (pa <= pb && pa <= pc) ? a
: (pb <= pc) ? b
: c;
bytes[i] = (byte) ((x + predicted) & 0xff);
ai++;
bi++;
ci++;
}
}
}
@@ -1,166 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngScanlineProcessor;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
/**
* A PngScanlineBuffer allows up-front once-off memory allocation that can be used
* across an entire PNG (no matter how many frames) to accumulate and process image data.
*/
public class PngScanlineBuffer {
protected final byte[] bytes;
protected final int filterUnit;
protected byte[] previousLine;
protected int readPosition;
protected int writePosition;
//protected int numBytesPerLine;
public PngScanlineBuffer(int size, int maxBytesPerLine, int filterUnit) {
this.bytes = new byte[size];
this.previousLine = new byte[maxBytesPerLine];
//this.scratchLine = new byte[maxBytesPerLine];
//this.numBytesPerLine = numBytesPerLine;
this.filterUnit = filterUnit;
reset();
}
public byte[] getBytes() {
return bytes;
}
public int getReadPosition() {
return readPosition;
}
public int getWritePosition() {
return writePosition;
}
public boolean canRead(int minNumBytes) {
return availableRead() >= minNumBytes;
}
public int availableRead() {
return writePosition - readPosition;
}
public int availableWrite() {
return bytes.length - writePosition;
}
public void skip(int numBytes) {
if (numBytes < 0 || numBytes > availableRead()) {
throw new IllegalArgumentException(String.format("Skip bytes must be 0 > n >= %d: %d", availableRead(), numBytes));
}
readPosition += numBytes;
}
public void reset() {
this.readPosition = 0;
this.writePosition = 0;
Arrays.fill(previousLine, (byte) 0);
}
public void shift() {
// It is difficult to say whether there is a more efficient approach - e.g. a for loop -
// to shifting the bytes to the start of the array. For now, I consider System.arraycopy
// to be "good enough".
int len = availableRead();
System.arraycopy(bytes, readPosition, bytes, 0, len);
writePosition = len;
readPosition = 0;
}
/**
* Read the decompressed data into the buffer.
*
* @param is an InputStream yielding decompressed data.
* @return count of how many bytes read, or -1 if EOF hit.
* @throws IOException
*/
public int read(InputStream is) throws IOException {
int r = is.read(bytes, writePosition, availableWrite());
if (r > 0) {
writePosition += r;
}
return r;
}
public boolean decompress(InputStream inputStream, PngScanlineProcessor processor) throws IOException, PngException {
int bytesPerLine = processor.getBytesPerLine();
try (InputStream iis = processor.makeInflaterInputStream(inputStream)) {
while (true) {
// Ally says
// I LOVE Y.O.U.
int len = read(iis);
if (len <= 0) {
return processor.isFinished();
}
// Or could do: len -= bytesPerLine until len < bytesPerLine
while (canRead(bytesPerLine)) {
undoFilterScanline(bytesPerLine);
processor.processScanline(bytes, readPosition + 1);//(), getReadPosition());
skip(bytesPerLine);
}
shift();
}
}
}
public void undoFilterScanline(int bytesPerLine) {
int filterCode = bytes[readPosition];
switch (filterCode) {
case 0:
// NOP
break;
case 1:
PngFilter.undoSubFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
case 2:
PngFilter.undoUpFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
case 3:
PngFilter.undoAverageFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
case 4:
PngFilter.undoPaethFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
default: // I toyed with an exception here. But why? Just treat as if no filter.
//throw new IllegalArgumentException(String.format("Filter type %d invalid; valid is 0..4", filterCode);
break;
}
// when un-filtering scanlines, the previous row is a copy of the *un-filtered* row (not the original).
System.arraycopy(bytes, readPosition + 1, previousLine, 0, bytesPerLine - 1); // keep a copy of the unmodified row
}
public static PngScanlineBuffer from(PngHeader header) {
return from(header, PngConstants.DEFAULT_TARGET_BUFFER_SIZE);
}
public static PngScanlineBuffer from(PngHeader header, int minBufferSize) {
return new PngScanlineBuffer(sizeFrom(header, minBufferSize), header.bytesPerRow, header.filterOffset);
}
public static int sizeFrom(PngHeader header, int minBufferSize) {
int bytesPerRow = header.bytesPerRow;
int bytesFullBitmap = bytesPerRow * header.height;
if (bytesFullBitmap < minBufferSize) {
return bytesFullBitmap;
}
int numRows = Math.max(1, minBufferSize / bytesPerRow);
return numRows * bytesPerRow;
}
}
@@ -1,48 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
/**
* A bitmap where each pixel is represented by 8-bits for alpha, red, green and blue
* respectively, and each pixel stored in a single 32-bit integer. This is expressly designed
* to be compatible with the input array used to build Android Bitmap objects, though of course
* its use is not limited to that.
*/
public final class Argb8888Bitmap {
public final int[] array;
public final int width;
public final int height;
public Argb8888Bitmap(int width, int height) {
this(new int[width * height], width, height);
}
public Argb8888Bitmap(int[] array, int width, int height) {//}, int x, int y) {
this.array = array;
this.width = width;
this.height = height;
// this.x = y;
}
/**
* Create a new Bitmap that shares the byte array of this bitmap.
* This is useful when rendering a number of animation frames from an APNG file
* and wanting to minimise memory allocation, while still wanting a "new" bitmap
* to work on.
*
* @param width in pixels of "new" bitmap (actual pixel array is the exact array
* from the original bitmap).
* @param height in pixels of "new" bitmap (actual pixel array is the exact array
* from the original bitmap).
* @return new bitmap object sharing the same data array.
*/
public Argb8888Bitmap makeView(int width, int height) {//}, int x, int y) {
if ((width * height) > (this.width * this.height)) {
throw new IllegalArgumentException(String.format(
"Requested width and height (%d x %d) exceeds maximum pixels allowed by host bitmap (%d x %d",
width, height, this.width, this.height));
}
return new Argb8888Bitmap(array, width, height);
}
}
@@ -1,72 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import java.util.ArrayList;
import java.util.List;
/**
* An Argb8888BitmapSequence object represents all bitmaps in a single PNG file,
* whether it has one and only one default image, any number of frames in an animation
* or a default image and a separate set of animation frames.
* <p>
* Note that instances of this class will hold an individual bitmap for every frame
* and does <em>not</em> do composition of images in any way. Composition is done in
* the japng_android library using an Android Canvas. This class is not used for that,
* as the intermediate Argb8888Bitmap objects are not required, only one bitmap and
* the output buffer is required during composition.
*/
public final class Argb8888BitmapSequence {
public final PngHeader header;
public final Argb8888Bitmap defaultImage;
private boolean defaultImageIsSet = false;
private PngAnimationControl animationControl;
List<Frame> animationFrames;
public Argb8888BitmapSequence(PngHeader header) {
this.header = header;
this.defaultImage = new Argb8888Bitmap(header.width, header.height);
}
public void receiveAnimationControl(PngAnimationControl animationControl) {
this.animationControl = animationControl;
this.animationFrames = new ArrayList<>(animationControl.numFrames);
}
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
defaultImageIsSet = true;
}
public boolean hasDefaultImage() {
return defaultImageIsSet;
}
public boolean isAnimated() {
return null != animationControl && animationControl.numFrames > 0;
}
public PngAnimationControl getAnimationControl() {
return animationControl;
}
public List<Frame> getAnimationFrames() {
return animationFrames;
}
public static final class Frame {
public final PngFrameControl control;
public final Argb8888Bitmap bitmap;
public Frame(PngFrameControl control, Argb8888Bitmap bitmap) {
this.control = control;
this.bitmap = bitmap;
}
}
}
@@ -1,82 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* Argb8888BitmapSequenceDirector instances direct an Argb8888Processor to build all frames
* of an animation into an Argb8888BitmapSequence object.
*/
public class Argb8888BitmapSequenceDirector extends BasicArgb8888Director<Argb8888BitmapSequence> {
Argb8888BitmapSequence bitmapSequence = null;
PngFrameControl currentFrame = null;
private PngHeader header;
@Override
public void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException {
this.header = header;
this.bitmapSequence = new Argb8888BitmapSequence(header);
//this.header = header;
//defaultImage = new Argb8888Bitmap(header.width, header.height);
this.scanlineProcessor = Argb8888Processors.from(header, buffer, this.bitmapSequence.defaultImage);
}
@Override
public boolean wantDefaultImage() {
return true;
}
@Override
public boolean wantAnimationFrames() {
return true;
}
@Override
public Argb8888ScanlineProcessor beforeDefaultImage() {
return scanlineProcessor;
}
@Override
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
this.bitmapSequence.receiveDefaultImage(bitmap);
}
@Override
public void receiveAnimationControl(PngAnimationControl control) {
this.bitmapSequence.receiveAnimationControl(control);
}
@Override
public Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control) {
//throw new IllegalStateException("TODO up to here");
//return null;
currentFrame = control;
//System.out.println("Frame: "+control);
return scanlineProcessor.cloneWithNewBitmap(header.adjustFor(control)); // TODO: is this going to be a problem?
}
@Override
public void receiveFrameImage(Argb8888Bitmap bitmap) {
if (null == currentFrame) {
throw new IllegalStateException("Received a frame image with no frame control in place");
}
if (null == bitmapSequence.animationFrames) {
throw new IllegalStateException("Received a frame image without animation control (or frame list?) in place");
}
bitmapSequence.animationFrames.add(new Argb8888BitmapSequence.Frame(currentFrame, bitmap));
currentFrame = null;
}
@Override
public Argb8888BitmapSequence getResult() {
return bitmapSequence;
}
}
@@ -1,47 +0,0 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* Argb8888Director implementations "direct" a given Argb8888Processor how to
* control the output. This allows the Argb8888Processor to transform pixels into
* ARGB8888 format while allowing for radically different final output objects,
* e.g. a single bitmap, a sequence of bitamps, an Android View or Drawable, etc.
* <p>
* TODO: not sure if this will stay in this form. Needs refinement.
*/
public interface Argb8888Director<ResultT> {
void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException;
void receivePalette(Argb8888Palette palette);
void processTransparentPalette(byte[] bytes, int position, int length) throws PngException;
void processTransparentGreyscale(byte k1, byte k0) throws PngException;
void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) throws PngException;
boolean wantDefaultImage();
boolean wantAnimationFrames();
Argb8888ScanlineProcessor beforeDefaultImage();
void receiveDefaultImage(Argb8888Bitmap bitmap);
void receiveAnimationControl(PngAnimationControl control);
Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control);
void receiveFrameImage(Argb8888Bitmap bitmap);
ResultT getResult();
}
Loaded 100 of 493 files, more files were not shown because too many files have changed in this diff. Show more